-
Notifications
You must be signed in to change notification settings - Fork 23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix self-referencing closure memory leaks #161
base: 4
Are you sure you want to change the base?
Conversation
d6bdcc0
to
13438eb
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe looking at https://github.com/go-gst/go-glib/blob/main/glib/connect.go can help, the connect implementation there is pretty robust.
// weak.Pointer's behavior is to be invalidated by the time the | ||
// finalizer is called, so we temporarily resurrect the box so that | ||
// destroy signal handlers can obtain it. We'll purge it from the | ||
// registry after the destroy callbacks. | ||
shared.weak[obj] = weak.Make(box) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sounds like a very bad idea, what are you trying to solve here? Maybe I can help.
An easy fix for #106 and #126 is the following, I don't know if this can be solved any other way: app := gtk.NewApplication("com.github.diamondburned.gotk4-examples.gtk4.simple", gio.ApplicationFlagsNone)
weakApp := weak.Make(app)
app.ConnectActivate(func() {
app := weakApp.Value()
if app == nil {
return // activated after cleanup?
}
window1 := gtk.NewApplicationWindow(app)
// ...
}) The problem is that the signal handler closes over There are two ways to solve this with the current bindings:
There is no other way of doing this from the bindings, because you cannot know which references an object has that the closure closes over, because these references might be in the C code. |
This PR already works for those 2 issues without needing the user to explicitly use weak references themselves. My main goal with keeping this a draft right now is just to encourage others to test it and report any new bugs/regressions before I go ahead and merge it. |
There actually is! gotk4 accomplishes this by using Go's weak pointers and GLib's toggle references to be able to detect when to appropriately free objects for this reason. Specifically:
[2]: This should answer your code review for |
I believe GJS also uses toggle references for this reason. |
This commit experiments with refactoring package `core/intern` to use the newly added `runtime.AddCleanup` and `weak.Pointer` APIs from Go 1.24. This commit has been tested against issues #106 and #126 to ensure stability. It is still not recommended to use this in production until further testing has been done, however.
13438eb
to
c9afb05
Compare
Also side note: Specifically for issue #106, referencing |
How would this work in the following use case? It is using gstreamer elements, because I'm not familiar with gtk. el := gst.ElementFactoryMake("audiotestsrc", "").(*gst.Element)
el.ConnectPadAdded(func(pad *gst.Pad) {
el.GetObjectProperty("")
})
runtime.GC()
// potentially more references to el after connecting
el.SetObjectProperty("", "")
We have some places to break the cycle here:
The thing with this example is that there is no additional reference taken. It is just the closure that prevents the GC of the element. IMO doing anything else with weak pointers in the bindings will not work in all cases and will potentially introduce hard to find bugs. |
Then what's the point of this object existing? It would have to make it over to the C side eventually, because the core logic of everything is in C, so if it's not, then what is it doing by just being on the Go side? If |
The object can still create threads and do stuff on the c side. Toplevel structs like pipelines in gstreamer are owned by the user and do everything in C, but the only reference there is exists in go. That means that this will leak if we create a reference cycle, but be prematurely cleaned up if we drop the reference. I talked to @sdroege about this and he thinks that toggle refs will not solve the problem and potentially create more issues. Quoting him:
His pseudo example:
The pipeline owns the bus, the bus has a watch and the watch references the pipeline -> cycle! There is no way to detect this from the runtime. Python, JS, and rust bindings also leak in this case. The user has to resort to weak pointers here. |
I think in this case, I would be happy with letting the user handle this for Go as well then. Still, I'm not willing to compromise the ease of use for all of GTK just for a handful of edge cases.
This is a fair point! How does GJS and other stuff handle this? They definitely rely on toggle references too, AFAICT. |
They don't (and Python AFAIK doesn't even have a |
I'm not familiar with GTK, but I don't think that these cases are that rare in reality. If you never reference the object itself in the closure, then the current implementation already works fine. I'd expect this to be true for e.g. buttons that perform some action without giving user feedback (to not reference any other object in the tree). But if you want to show feedback then you'd need a handle to your app or similar, which then creates a circular reference again. In gstreamer the trivial cases also do not produce that issue, but once you get more advanced then you will somehow react to the signal which easily creates reference cycles.
The good thing is that since go 1.24 we don't even need new bindings for this, we can just pass a go weak pointer. |
A way to warn the user would be to inspect the closure context for anything that contains a gotk4 internal object. This is not documented at all though, I tried to look at how delve does it, and they are just casting the function to a struct to inspect the internal pointers. But I think that once you go there you open pandora's box. Doing this at runtime could potentially introduce massive performance penalties. |
This case is being handled perfectly fine by this PR, with everything being GC'd as normal. I believe the edge case here is more the "pipeline owns the bus, the bus has a watch and the watch references the pipeline -> cycle" part though. It would help me tremendously if there's like a directed graph diagram for this sort of thing...
I'm not too sure, actually. Right now, gotk4 explicitly supports GWeakRef for this exact issue, but it does not use Go's weak pointer. The GWeakRef is moreso to obtain a weak pointer to a C object. |
As shown above: #161 (comment) , this prevents the closure from closing over the finalized object, it instead closes over the weak pointer, which allows the underlying C object to be cleaned up.
I don't understand how your case is handled if my example gives you headaches. How does your solution prevent something similar to this from being a cycle?
If this is all happening in go then fine, but as soon as one of those components is implemented e.g. in C then there is more than one reference taken, and a leak would be undetectable.
That would require traversing the specific implementation of each object. This would require big changes to GObject. |
Once If that wasn't the case with GTK, I don't see either how this wouldn't leak. |
I think you should just give the PR a try. I think you and I are describing very different edge cases to the same problem, so having reproducible code (or at least a simple directed graph diagram illustrating the problem) would help me a lot in figuring out the right solution to both cases. As it stands, both #106 and #126 work with this PR, and nothing gets leaked. |
FWIW, I agree that this PR might not work for GStreamer specifically, and I'd be happy to draft up some kind of opt-out mechanism for GStreamer objects to not be memory-managed in such manner. In the GTK world, there's not a lot of multi-language interop, though. It's usually just C and Go, maybe Vala. |
I get that it fixes the specific problems, but I find the intern package hard to reason about for several reasons: Objects get resurrected, toggle refs check for GC on the C side, etc. I have one more question: Who holds a strong reference to the box if it gets moved to the intern weak part? From what I understand, the box holds the go closure that needs to be called in the go marshal function. I'll experiment a bit with the changes. |
If the box is intern-weaked, then that implies that the C side holds no reference to this box. The only other references would just be other Go code that still needs this object. Once that's no longer true, the box gets freed. |
Wait - the box holds the object, not the closure? The closure is saved in a global A slab doesn't have any weak pointers - because it mustn't have any. Meaning that you always have a strong reference to to the closure. If the closure closes over the object, then the object is strongly referenced too, meaning that it will never leave go's heap. That means that you still easily leak the object, because it is always alive on the go heap. I tried this branch with the gst bindings, and did the following: package main
import (
"log"
"runtime"
"time"
"github.com/go-gst/go-gst/pkg/gst"
)
func mkPipeline() *gst.Pipeline {
ret, err := gst.ParseLaunch("audiotestsrc ! decodebin name=decodebin0 ! fakesink")
if err != nil {
panic(err)
}
pipeline := ret.(*gst.Pipeline)
el := pipeline.ByName("decodebin0").(*gst.Bin)
h := el.ConnectPadAdded(func(newPad *gst.Pad) {
el.SetName("foobar") // dummy method, to make the handler close over el
})
runtime.AddCleanup(el, func(_ struct{}) {
log.Println("garbage collected element")
}, struct{}{})
// runtime.AddCleanup(pipeline, func(el *gst.Bin) {
// el.HandlerDisconnect(h)
// }, el)
return pipeline
}
func main() {
gst.Init()
for range 100 {
pipeline := mkPipeline()
runtime.GC()
pipeline.State(gst.ClockTimeNone)
runtime.GC()
runtime.GC()
runtime.GC()
runtime.GC()
}
runtime.GC()
time.Sleep(2 * time.Second)
runtime.GC()
} This creates a new pipeline, retrieves an object from it, attaches a signal handler and adds a cleanup to the object, where it should log "garbage collected element". This line never happens, because of the reference cycle. There are two ways to break the cycle here:
In both cases the log line appears. I don't think that this PR is doing what you want to @diamondburned , and I don't know if it is even possible. |
This isn't true. The box holds both the object and the closures. See
You'd want to enable log/slog with debug levels then run the program with
-_- Why are you acting like I've not done any testing myself? Have you even bothered looking into #126 and running it with this PR? I never claimed it'll work for GStreamer. |
Relevant log section:
I think the reference cycle happens in the box itself? I'm not sure, but I can definitely say that the objects are not getting cleaned up and are still being referenced strongly by go.
Sorry if it sounded rude, but what are you trying to solve here? A bug in the GTK bindings or a bug in the GLib bindings? I have not looked into the original issue, because I don't know GTK. Using the gst bindings is an easy way for me to attach a signal handler that does not get detached by any side effects. The code above doesn't do anything else besides creating new object instances and attach some signal handlers. In fact it can be simplified even more: package main
import (
"log"
"log/slog"
"runtime"
"time"
"github.com/go-gst/go-gst/pkg/gst"
)
func mkObject() {
el := gst.ElementFactoryMake("decodebin", "").(*gst.Bin)
el.ConnectPadAdded(func(newPad *gst.Pad) {
el.SetName("foobar")
// log.Println("handler")
})
runtime.AddCleanup(el, func(_ struct{}) {
log.Println("garbage collected element")
}, struct{}{})
return
}
func main() {
gst.Init()
slog.SetLogLoggerLevel(slog.LevelDebug)
log.Println("go:")
for range 5 {
log.Println("loop")
mkObject()
runtime.GC()
runtime.GC()
runtime.GC()
}
runtime.GC()
time.Sleep(2 * time.Second)
runtime.GC()
} |
I think to the go GC the reference "cycle" looks as follows:
Thus the GC always thinks that the go object is always reachable, even if no other scope is able to call the closure. It cannot delete the map entry, because at any time the |
For GTK the cycle is automatically broken if the signal handler is removed, like @sdroege said above:
|
This commit experiments with refactoring package
core/intern
to usethe newly added
runtime.AddCleanup
andweak.Pointer
APIs from Go1.24.
This commit has been tested against issues #106 and #126 to ensure
stability. It is still not recommended to use this in production
until further testing has been done, however.