Repository navigation
runtime: add AddCleanup #67535
Description
Background
Go provides one function for object finalization in the form of runtime.SetFinalizer. Finalizers are notoriously hard to use, and the documentation of runtime.SetFinalizer describes all the caveats with a lot of detail. For instance:
SetFinalizermust always refer to the first word of an allocation. This means programmers must be aware of what an 'allocation' is whereas that distinction isn't generally exposed in the language.- There cannot be more than one finalizer on any object.
- Objects with finalizers that are involved in any reference cycle will silently fail to be freed and the finalizer will never run.
- Objects with finalizers require at least two GC cycles to be freed.
The last two of these caveats boil down to the fact that runtime.SetFinalizer allows object resurrection.
Proposal
I propose adding the following API to the runtime package as a replacement for SetFinalizer. I also propose officially deprecating runtime.SetFinalizer.
// AddCleanup attaches a cleanup function to ptr. Some time after ptr is no longer
// reachable, the runtime will (probably) call cleanup(arg) in a separate goroutine.
//
// If ptr is reachable from cleanup or arg, ptr will never be collected
// and the cleanup will never run. AddCleanup panics if arg == ptr.
//
// The cleanup(arg) call is not always guaranteed to run; in particular it is not
// guaranteed to run before program exit.
//
// A single goroutine runs all cleanup calls for a program, sequentially. If a cleanup
// function must run for a long time, it should create a new goroutine.
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup
// Cleanup is a handle to a cleanup call for a specific object.
type Cleanup struct { ... }
// Stop cancels the cleanup call. Stop will have no effect if the cleanup call
// has already been queued for execution (because ptr became unreachable).
// To guarantee that Stop removes the cleanup function, the caller must ensure
// that the pointer that was passed to AddCleanup is reachable across the call to Stop.
func (c Cleanup) Stop() { ... }
AddCleanup resolves many of the problems with SetFinalizer.
It forbids objects from being resurrected, resulting in prompt cleanup, as well as allowing cycles of objects to be cleaned up. Its definition also allows attaching cleanup functions to objects the caller does not own, and possibly attaching multiple cleanup functions to a single object.
However, it is still fundamentally a finalization mechanism, so to avoid restricting the GC implementation, it does not guarantee that the cleanup function will ever run.
Similar to finalizers' restriction on the object not being reachable from the finalizer function, ptr must not be reachable from the value passed to the cleanup function, or from the cleanup function. Usually this results in a memory leak, but the common case of accidentally passing ptr as s out of convenience can be easily caught.
In terms of interactions with finalizers, the cleanup function will run after the value pointed to by ptr becomes unreachable and its finalizer has run (and that finalizer does not make the object reachable again). That is, if an object has both a cleanup function and a finalizer, the cleanup function is guaranteed to run after the finalizer. In other words, the cleanup function tracks object resurrection.
Design discussion
Avoiding allocations in the implementation of AddCleanup
AddCleanup needs somewhere to store cleanupValue until cleanup is ready to be called. Naively, it could just put that value in an any variable somewhere, but this would result in an unnecessary additional allocation.
In the actual implementation, a cleanup will be represented as a runtime "special," an off-heap manually-managed linked-list node data structure whose individual fields are sometimes explicitly inspected by the GC as roots, depending on the "special" type (for example, a finalizer special treats the finalizer function as a root).
Since each "special" is already specially (ha) treated by the GC, we can play some interesting tricks. For example, we could type-specialize specials and store cleanupValue directly in the "special." As long as we retain the type information for cleanupValue, we can get the GC to scan it directly in the special. But this is quite complex.
To begin with, I propose just specializing for word-sized cleanup values. If the cleanup value fits in a pointer-word, we store that directly in the special, and otherwise fall back on the equivalent of an any. This would cover many use-cases. For example, cleanup values that are already heap-allocated pointers wouldn't require an additional allocation. Also, simple cases like passing off a file descriptor to a cleanup function would not create an allocation.
Why func(S) and not func()?
The choice to require an explicit parameter to the cleanup function is to reduce the risk of the cleanup function accidentally closing over ptr. It also makes it easier for a caller to avoid allocating a closure for each cleanup.
Why func(S) and not chan S?
Channels are an attractive alternative because they allow users to build their own finalization queues. The downside however is that each channel owner needs its own goroutine for this to be composable, or some third party package needs to exist to accumulate all these channels and select over them (likely with reflection). It's much simpler if that package is just the runtime: there's already a system goroutine to handle the finalization queue. While this does mean that the handling of finalization is confined to an implementation detail, that's rarely an issue in practice and having the runtime handle it is more resource-efficient overall.
Why return Cleanup instead of *Cleanup?
While Cleanup is a handle and it's nice to represent that handle's unique identity with an explicit pointer, it also forces an allocation of Cleanup's contents in many cases. By returning a value, we can avoid an additional allocation.
Why not have the type arguments (T and/or S) on Cleanup too?
It's not necessary for the implementation of Cleanup for the type arguments to be available, since the internal representation will not even contain a reference to ptr, cleanup, or cleanupValue directly. It does close the door to obtaining these values from Cleanup in a type-safe way, but that's OK: the caller of AddCleanup can already package those up together if it wants to.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
randall77 commentedon May 20, 2024
Another possibility:
When cleaning up, we pass a (shallow) copy of
*Tto the cleanup function.This gets rid of the need to store
cleanupValueanywhere. It is effectively the final state of the object.On the downside, this may get weird if you're putting a cleanup on a type defined in another package, as you might not be able to do much with the argument.
cespare commentedon May 20, 2024
@mknyszek I find it hard from reading the proposed signatures and doc comments to understand what
SandcleanupValueare. Could you clarify that a bit? It might also help to get an example of how one would use the new API. For instance, how would one useAddCleanupto replace the use ofSetFinalizerto close a file when it is GCed?Another question: would
AddCleanupreplace some or all uses ofSetFinalizerin the standard library? Are there instances where it cannot work, or would be undesirable?Merovius commentedon May 20, 2024
What's the reasoning here? This means that you can not assume that
ptris unreachable when the cleanup runs. e.g. in the case ofgo4.org/intern(yes, I know that this particular case will be obsolete by #62483, but just for illustration) that would mean you might get differentValues for the same interned string. This seems to combine particularly badly with the idea that you can attach cleanups to values you don't own.If this was done the other way around - cleanups run after finalizers and only if
ptrdid not get resurrected - you could rely onptrbeing unreachable after the cleanup. And if there are no finalizers, ISTM that everything could work the same. So you'd get better invariants now at no cost for The Bright Future of no Finalizers™.Or is there something (perhaps in the implementation of the runtime) that I'm unaware of?
mknyszek commentedon May 20, 2024
Unfortunately, I think that requires treating the contents of
ptras a root, so you can no longer reclaimptrif it participates in a cycle.The idea behind this API is to decouple cleanup from the object being freed, so
SandcleanupValuerepresent that decoupling. I can understand that at first glance it seems odd, but this is really the way to avoid a lot of the footguns. Most of the time, finalizers (and cleanup functions) are necessary for cleaning up things that the GC is unaware of, like file descriptors, memory passed from C, etc. You pass that to the cleanup function, not the object itself.I'm taking a lot of liberties in this snippet, but that would be the general idea. I can update the proposal later.
randall77 commentedon May 20, 2024
That's a good point.
What about this case?
During the lifetime of a
T, we may changebufseveral times, each time with a free/malloc pair.When the
Tgoes dead, we want to free the last value that was inbuf.How do I write a cleanup for that? I think I would need to Stop/AddCleanup each time I updated
buf. I don't see a way to set a cleanup whenTis allocated that does the right thing. Unlessbufis indirect somehow.But maybe that's the intention, that you have to Stop/AddCleanup each time?
mknyszek commentedon May 20, 2024
@Merovius I think your point about interacting with existing objects using finalizers is interesting. AFAIK there's nothing in the implementation preventing the semantics you propose. I'll have to give this some more thought.
mknyszek commentedon May 20, 2024
Ah, that's interesting. Yeah, you would need to
Stop/AddCleanupeach time. I don't see a way around that (unless, like you say, you add an indirection onbuf, and pass that to theAddCleanup), and having to do that makes the malloc/free somewhat more expensive compared to what you can do with finalizers.I'm inclined to say that requiring the indirection isn't that bad. It's going to result in an additional allocation for each
Tthat exists, but that allocation will be bound to the lifetime ofT. In other words, it's kinda like extendingTby 8 bytes. And I think the fact that you can releaseT's memory immediately (as opposed to waiting an extra GC cycle) may actually makeAddCleanupwin out in the long run.randall77 commentedon May 20, 2024
The indirect scheme is basically the same thing you would do with finalizers if, e.g., the T's were in a cycle. You would allocate a child object, point T to it, put
bufin the child object, and put a finalizer on the child object.Having the option to Stop/AddCleanup each time instead of using the indirection technique might be useful.
I'm not sure I'm arguing for or against anything at this point. Just trying to understand.
RogerDilmer commentedon May 21, 2024
What about deferCleanup as a name. Defer is already very understandable by Go developers (albeit in a different context). Add doesn't really say anything about when it happens.
bjorndm commentedon May 21, 2024
Ruby does something similar for finalizers. Basically the finalizer is run after the object is deallocated. They are also guaranteed to run if the object is deallocated. The Ruby finalizer is not allowed to reference the object that it finalizes. In stead you have to reference to any members through a closure. In my experience this works better than Go finalizers. I would recommend Go adopts the Ruby way of implementing finalizers.
https://ruby-doc.org/core-3.0.0/ObjectSpace.html
[-]proposal: runtime: add `AddCleanup` and deprecate `SetFinalizer`[/-][+]proposal: runtime: add AddCleanup and deprecate SetFinalizer[/+]54 remaining items
runtime: implement Stop for AddCleanup
gopherbot commentedon Nov 18, 2024
Change https://go.dev/cl/629215 mentions this issue:
runtime: deprecate SetFinalizer[-]runtime: add AddCleanup and deprecate SetFinalizer[/-][+]runtime: add AddCleanup[/+]cagedmantis commentedon Nov 18, 2024
This proposal includes the deprecation of
SetFinalizer. We don't want to deprecateSetFinalizerin the same release of Go as the version when AddCleanup is added. Issue #70425 tracks the future deprecation ofSetFinalizerand the discussion surrounding it.gopherbot commentedon Dec 7, 2024
Change https://go.dev/cl/634318 mentions this issue:
runtime: improve AddCleanup documentationruntime: improve AddCleanup documentation
gopherbot commentedon Dec 11, 2024
Change https://go.dev/cl/635275 mentions this issue:
_content/doc: update runtime.AddCleanup note_content/doc: update runtime.AddCleanup note
gopherbot commentedon Dec 18, 2024
Change https://go.dev/cl/637555 mentions this issue:
_content/doc/gc-guide: add section on object death features_content/doc/gc-guide: add section on object death features