What does runtime.AddCleanup fix that made runtime.SetFinalizer error-prone?
answer
- the callback never sees the object
- no pointer, no resurrection
- cycles stop being a trap
- one collection cycle instead of two
- and a Stop that cancels it
basics
~20 sruntime.AddCleanup never hands the object to the cleanup function. That removes resurrection, keeps reference cycles collectable, and frees the memory without the extra collection cycle a finalizer forces. It also allows several cleanups per object.
solid answer
~50 s`runtime.AddCleanup(ptr, cleanup, arg)` attaches `cleanup(arg)` to `ptr`, and the decisive difference from `runtime.SetFinalizer` is that the function never receives `ptr`. That single change removes three traps. It cannot resurrect the object, because it has no pointer to publish. Objects that reference each other in a cycle stay collectable, whereas a cycle containing a finalized object is not guaranteed to be collected at all. And the object no longer has to survive an extra cycle just to be passed to its own finalizer, so its memory comes back sooner. On top of that, more than one cleanup may be attached to the same pointer, and the call returns a `runtime.Cleanup` whose `Stop` method cancels the registration — the counterpart of `runtime.SetFinalizer(obj, nil)`. What does not change: neither mechanism is guaranteed to run, and neither runs at program exit.
code
go · 16 linestype file struct {
fd int
cl runtime.Cleanup
}
func open(fd int) *file {
f := &file{fd: fd}
// the cleanup is handed the descriptor, never the *file
f.cl = runtime.AddCleanup(f, func(fd int) { _ = syscall.Close(fd) }, fd)
return f
}
func (f *file) Close() error {
f.cl.Stop() // released explicitly, so the backstop must not fire later
return syscall.Close(f.fd)
}go deeper
Know that Go 1.24 added runtime.AddCleanup as the modern replacement for runtime.SetFinalizer, and that neither of them is a destructor you may rely on for releasing a resource.
Explain the mechanism: the callback is handed only the argument you registered, never the pointer. That is what removes resurrection, keeps reference cycles collectable, and saves the extra cycle a finalizer costs.
Be ready to review real code with this. Spot the closure that captures the object, the cleanup that blocks, and the Close that forgets to Stop the registration so the backstop fires on an already-released handle.
The call you own is migration policy: whether existing SetFinalizer uses get rewritten, what minimum Go version your modules can demand, and whether these hooks belong in shared packages at all.
## The signature, and why its shape is the whole answer ``` func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup ``` You give the runtime a pointer to watch, a function, and a value to pass that function. Some time after `ptr` becomes unreachable, the runtime calls `cleanup(arg)`. Compare it with `runtime.SetFinalizer(obj, f)`, where the runtime calls `f(obj)`. The difference is one argument, and almost every advantage follows from it. ## The three traps it closes **Resurrection.** A finalizer is handed the pointer to a dead object, so it can store it in a package-level variable, send it on a channel, or otherwise publish it — and the object is alive again. The runtime then has to leave it alone until it dies a second time, and the finalizer will not be run again. It is legal, it is confusing, and it makes object lifetimes non-monotonic. A cleanup cannot do it: it receives only the `arg` you supplied, and by the time it runs the runtime already treats the object as gone. **Cycles.** If two objects reference each other and at least one carries a finalizer, there is no finalization order that respects the dependencies. The documented behaviour is that such a cycle is not guaranteed to be collected and its finalizers are not guaranteed to run — a permanent, silent leak. Cleanups hold no pointer into the cycle, so a cycle of objects with cleanups is collected like any other garbage. **The extra cycle.** Because a finalizer needs the pointer, the cycle that finds the object unreachable cannot free it: the object is kept alive, the call is queued, and only a later cycle reclaims the memory. A cleanup needs nothing from the object, so the memory can go back on the first cycle that finds it unreachable. On a workload that attaches hooks to many short-lived objects, that is the difference between paying for one collection and paying for two. ## The ergonomics it adds Several cleanups may be attached to the same pointer, which lets independent layers register their own release without coordinating. And `AddCleanup` returns a `runtime.Cleanup` value with a `Stop` method that cancels a registration which has not yet started — safe to call more than once, and the natural thing to do inside an explicit `Close`, so that releasing the resource yourself also disarms the backstop. There are rules on the arguments. `ptr` must point at an allocated object, and `arg` must be able to reach nothing that leads back to `ptr`. If it can — a closure that captures the object, or a struct that points at it — the object is reachable from its own registration and can never become garbage, so the cleanup can never fire. That is the one footgun `AddCleanup` does not remove, and it deserves its own attention in review. ## What is exactly the same Everything about the *guarantee*. A cleanup runs only if a collection cycle observes the object unreachable, so a program that never collects never runs it. Nothing runs cleanups when `main` returns, when the program calls `os.Exit`, or when the process dies. A cleanup cannot return an error to anyone. It must not block for long or depend on the state of the program at a particular moment. So `AddCleanup` does not turn a runtime hook into a release mechanism — an explicit `Close` is still the contract, and the cleanup is still only a hedge against the caller who forgets. ## Migrating In new code, prefer `AddCleanup`. When rewriting an existing `SetFinalizer` use, the mechanical step is to work out what the finalizer actually needed from the object — usually a descriptor, a handle, or a key — and pass that as `arg`, moving the field reads to registration time. If the finalizer needed several fields, copy them into a small struct that the object may point at but which must not point back. If it needed to call a method on the object, use a method expression on a separate state value the object holds, not on the object itself. The one cost of the migration is a version floor: `runtime.AddCleanup` and the `weak` package both arrived in Go 1.24, so requiring it raises the minimum Go version of your module, and of everything that imports it.
- Why can a cleanup function not resurrect its object the way a finalizer can?A finalizer is called with the pointer, so it can store it in a package-level variable or send it on a channel and make a dead object live again. `runtime.AddCleanup` gives the function only the `arg` you registered, and by then the runtime already treats the object as gone. There is no pointer to publish, so there is nothing to bring back.
- What does the Stop method on the runtime.Cleanup value returned by AddCleanup do?It cancels the registration, so a cleanup that has not started will not run. The usual use is inside an explicit `Close`: release the resource yourself, then stop the backstop so it cannot fire on an already-released handle. It is safe to call more than once, and it never forces the cleanup to run.
- Does switching from SetFinalizer to AddCleanup make the callback more likely to run?No. Both are best-effort. Nothing forces a collection cycle, and neither runs when the program returns from `main`, panics, or calls `os.Exit`. AddCleanup removes footguns and reclaims memory a cycle sooner; it does not turn a backstop into a guarantee, so an explicit release path is still required.
saying these in an interview costs you the question
- Thinks AddCleanup guarantees the cleanup runs before exit
- Passes the object itself as the cleanup argument
- Believes finalizers and cleanups both free memory in one cycle
- Claims SetFinalizer was removed when AddCleanup landed
- Says a finalizer cannot resurrect the object it receives