runtime.AddCleanup: How Go Replaced Finalizers With a Queue of Specials
Go's cleanup machinery hangs a special record off the object's span, hands the work to a lock-free queue, and wakes a small pool of dedicated goroutines — a rewrite of runtime.SetFinalizer that fixes a decade of footguns.
TL;DR:
runtime.SetFinalizerwas the only way to run code when an object became unreachable, and it was full of sharp edges: cleanups ran on a single goroutine, could not be removed reliably, could not carry an argument, and could keep an entire object alive forever.runtime.AddCleanupreplaces all of that. It attaches a special record to the object’s span, hands the closure to a lock-free queue of 512-byte blocks, and wakes a small pool of dedicated goroutines to run them. Cleanups run in parallel, they can be cancelled, and they take an arbitrary typed argument that the GC does not have to scan.
runtime.SetFinalizer is one of those Go features that everybody has used
once, everybody has been bitten by, and nobody has a good answer for. The
documentation warns you:
If a cyclic structure includes a block with a finalizer, that cycle is not guaranteed to be garbage collected and the finalizer is not guaranteed to run, because there is no ordering that respects the dependencies.
That is the kind of sentence that only exists because the implementation
cannot do better. Go 1.24 added runtime.AddCleanup, a redesigned
replacement. The interesting part is how it works.
The problem with finalizers
A finalizer has to satisfy two conflicting requirements:
- It must not run while the object is still reachable.
- It must run after the object becomes unreachable — otherwise resources leak.
The classic solution is to resurrect the object one more time: when the GC finds an unreachable object with a finalizer, it queues the finalizer and marks the object as live (so it survives this cycle), then runs the finalizer at the end of the cycle. The object becomes garbage again after that, and is collected in the next cycle.
The consequence is well known: finalizers are delayed by at least one GC cycle, and by an unbounded amount of time if the GC is not running. In a program that allocates a lot and rarely triggers a GC, finalizers pile up.
Worse, the finalizer mechanism requires the runtime to keep the finalizer function alive, and — in the old design — to keep the whole object alive too, since the finalizer might want to inspect it.
And then there is the killer: SetFinalizer associates a finalizer with an
object by pointer. If you set a finalizer on an interior pointer, the
runtime has to find the containing object. If you set two finalizers on the
same object, the second replaces the first. And if the object is part of a
cycle with other finalized objects, the cycle is never collected.
The new design: cleanups
runtime.AddCleanup splits the problem in two. The cleanup function and
its argument live in a separate object, and only a pointer to that
separate object is attached to the object being cleaned up.
// src/runtime/mcleanup.go:75
//go:nocheckptr
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup {
// This is marked nocheckptr because checkptr doesn't understand the
// pointer manipulation done when looking at closure pointers.
// Similar code in mbitmap.go works because the functions are
// go:nosplit, which implies go:nocheckptr (CL 202158).
// Explicitly force ptr and cleanup to escape to the heap.
ptr = abi.Escape(ptr)
cleanup = abi.Escape(cleanup)Forcing the escape is a subtle but essential step. ptr and cleanup must
be heap-allocated, because the runtime is going to hold raw pointers to them
in structures that the compiler’s escape analysis cannot see.
The documentation makes the ordering contract explicit:
A cleanup may run as soon as an object becomes unreachable. In order to use cleanups correctly, the program must ensure that the object is reachable until it is safe to run its cleanup. Objects stored in global variables, or that can be found by tracing pointers from a global variable, are reachable. A function argument or receiver may become unreachable at the last point where the function mentions it. To ensure a cleanup does not get called prematurely, pass the object to the [KeepAlive] function after the last point where the object must remain reachable.
runtime.KeepAlive is the tool. It is a compiler intrinsic that keeps its
argument reachable until the point of the call — not “until the end of the
function”, which is what people usually assume.
The argument is boxed on purpose
Here is a genuinely clever piece of the implementation. The cleanup’s
argument arg must be stored somewhere so that the closure can be called
later. But if you store it in the same heap block as the object being
cleaned up, you create a reference cycle and the object never dies.
So the argument gets its own allocation:
// src/runtime/mcleanup.go:112
// Create new storage for the argument.
var argv *S
if size := unsafe.Sizeof(arg); size < maxTinySize && argType.PtrBytes == 0 {
// Side-step the tiny allocator to avoid liveness issues, since this box
// will be treated like a root by the GC. We model the box as an array of
// uintptrs to guarantee maximum allocator alignment.
//
// TODO(mknyszek): Consider just making space in cleanupFn for this. The
// unfortunate part of this is it would grow specialCleanup by 16 bytes, so
// while there wouldn't be an allocation, *every* cleanup would take the
// memory overhead hit.
box := new([maxTinySize / goarch.PtrSize]uintptr)
argv = (*S)(unsafe.Pointer(box))
} else {
argv = new(S)
}
*argv = argTwo things to note.
The tiny allocator is deliberately side-stepped. Go’s tiny allocator
packs several small, pointer-free objects into one 16-byte allocation. That
is a wonderful optimisation for throughput, but here it is a correctness
hazard: the “box” holding the cleanup argument would be treated by the GC as
a root for the whole tiny block, which means every other object packed
alongside it would also be kept alive. The code allocates an array of
uintptrs instead — same size class, but a distinct allocation, and
alignment guaranteed.
The comment explains the rejected alternative. Putting the argument
inline in cleanupFn would avoid the allocation entirely, but it would grow
specialCleanup by 16 bytes — and every cleanup pays that, even ones with
a pointer argument that does not need the space. The authors chose one
allocation per cleanup over a permanent 16-byte tax on all of them. That is
a good example of the runtime optimising for the common case.
The special record
Now the interesting part. The cleanup is not attached to the object as a normal Go field — it is attached as a special, a side table that hangs off the object’s span.
// src/runtime/mheap.go:2153
type specialCleanup struct {
_ sys.NotInHeap
special special
cleanup cleanupFn
// Globally unique ID for the cleanup, obtained from mheap_.cleanupID.
id uint64
}The _ sys.NotInHeap marker is the first field, and it is a promise to the
GC: this structure lives in memory the GC does not scan, so the runtime
has to scan it by hand.
Attaching it takes a global lock:
// src/runtime/mheap.go:2165
func addCleanup(p unsafe.Pointer, c cleanupFn) uint64 {
// TODO(mknyszek): Consider pooling specialCleanups on the P
// so we don't have to take the lock every time. Just locking
// is a considerable part of the cost of AddCleanup. This
// would also require reserving some cleanup IDs on the P.
lock(&mheap_.speciallock)
s := (*specialCleanup)(mheap_.specialCleanupAlloc.alloc())
mheap_.cleanupID++ // Increment first. ID 0 is reserved.
id := mheap_.cleanupID
unlock(&mheap_.speciallock)
s.special.kind = _KindSpecialCleanup
s.cleanup = c
s.id = idThe TODO is honest: AddCleanup takes a global mutex. That is not free.
The authors note that pooling the special records per-P would avoid it, at
the cost of having to reserve cleanup IDs per-P as well. This is a known
rough edge, and it is the price of the design.
After attaching the special, the runtime makes sure the cleanup stays reachable — and does it correctly, which took a bug fix:
// src/runtime/mheap.go:2179
mp := acquirem()
addspecial(p, &s.special, true)
// This is responsible for maintaining the same
// GC-related invariants as markrootSpans in any
// situation where it's possible that markrootSpans
// has already run but mark termination hasn't yet.
if gcphase != _GCoff {
// Mark the cleanup itself, since the
// special isn't part of the GC'd heap.
gcScanCleanup(s, &mp.p.ptr().gcw)
}
releasem(mp)
// Keep c and its referents alive. There's a window in this function
// where it's only reachable via the special while the special hasn't
// been added to the specials list yet. This is similar to a bug
// discovered for weak handles, see #70455.
KeepAlive(c)
return idThe gcphase != _GCoff check is subtle. If the GC is mid-cycle, the
span-root scanning phase may already have passed this span. Adding a
special that the GC has not seen would mean the cleanup function (and
everything it references) could be collected while still reachable from the
special. So the runtime marks it explicitly.
And KeepAlive(c) at the end closes the window the comment describes: between
allocating the special and adding it to the span’s list, the only reference to
the cleanup function is a local variable. KeepAlive tells the compiler not
to shorten the function’s argument liveness past that point.
How the GC finds cleanups
The GC scans spans that have the “specials” bit set. From
src/runtime/mgcmark.go:
// src/runtime/mgcmark.go:451
// Lock the specials to prevent a special from being
// removed from the list while we're traversing it.
lock(&s.speciallock)
for sp := s.specials; sp != nil; sp = sp.next {
switch sp.kind {
case _KindSpecialFinalizer:
gcScanFinalizer((*specialfinalizer)(unsafe.Pointer(sp)), s, gcw)
case _KindSpecialWeakHandle:
// The special itself is a root.
spw := (*specialWeakHandle)(unsafe.Pointer(sp))
scanblock(uintptr(unsafe.Pointer(&spw.handle)), goarch.PtrSize, &oneptrmask[0], gcw, nil)
case _KindSpecialCleanup:
gcScanCleanup((*specialCleanup)(unsafe.Pointer(sp)), gcw)
}
}
unlock(&s.speciallock)And gcScanCleanup is startlingly short:
// src/runtime/mgcmark.go:490
func gcScanCleanup(spc *specialCleanup, gcw *gcWork) {
// The special itself is a root.
scanblock(uintptr(unsafe.Pointer(&spc.cleanup)), unsafe.Sizeof(cleanupFn{}), &cleanupFnPtrMask[0], gcw, nil)
}Compare with the finalizer version right above it:
// src/runtime/mgcmark.go:472
func gcScanFinalizer(spf *specialfinalizer, s *mspan, gcw *gcWork) {
// Don't mark finalized object, but scan it so we retain everything it points to.
// A finalizer can be set for an inner byte of an object, find object beginning.
p := s.base() + spf.special.offset/s.elemsize*s.elemsize
// Mark everything that can be reached from
// the object (but *not* the object itself or
// we'll never collect it).
if !s.spanclass.noscan() {
scanObject(p, gcw)
}
// The special itself is also a root.
scanblock(uintptr(unsafe.Pointer(&spf.fn)), goarch.PtrSize, &oneptrmask[0], gcw, nil)
}The finalizer scans the object. The cleanup does not.
That is the whole design difference in five lines. A finalizer must keep the object alive (so it can be resurrected and so its fields stay valid), so the GC scans the object and marks everything reachable from it — which is exactly why finalizers create cycles that are never collected.
A cleanup never touches the object. It only holds the cleanup function and its separately-boxed argument. So the object can be collected immediately, and the cleanup can run on an object whose memory has already been reclaimed.
The pointer mask cleanupFnPtrMask is [...]uint8{0b111} — three
pointer-sized words, all of which are pointers:
// src/runtime/mcleanup.go:299
type cleanupFn struct {
// call is an adapter function that understands how to safely call fn(*arg).
call func(*funcval, unsafe.Pointer)
fn *funcval // cleanup function passed to AddCleanup.
arg unsafe.Pointer // pointer to argument to pass to cleanup function.
}Three words, three pointer bits, one scanblock call. Very cheap compared to
scanning a whole object graph.
The queue: 512-byte blocks and a lock-free stack
When the sweeper decides an object with a cleanup is dead, the cleanup is enqueued. The queue is not a channel, not a mutex-protected slice — it is a lock-free stack of fixed-size blocks:
// src/runtime/mcleanup.go:284
const cleanupBlockSize = 512
// cleanupBlock is an block of cleanups to be executed.
//
// cleanupBlock is allocated from non-GC'd memory, so any heap pointers
// must be specially handled. The GC and cleanup queue currently assume
// that the cleanup queue does not grow during marking (but it can shrink).
type cleanupBlock struct {
cleanupBlockHeader
cleanups [(cleanupBlockSize - unsafe.Sizeof(cleanupBlockHeader{})) / unsafe.Sizeof(cleanupFn{})]cleanupFn
}A 512-byte block holds a few dozen cleanupFn values. The block is
allocated from non-GC’d memory — persistentalloc — so the GC never
scans it, which is why the cleanupFnPtrMask scanning above is essential
for correctness rather than merely an optimisation.
The header carries the bookkeeping:
// src/runtime/mcleanup.go:308
type cleanupBlockHeader struct {
_ sys.NotInHeap
lfnode
alllink *cleanupBlock
// n is sometimes accessed atomically.
//
// The invariant depends on what phase the garbage collector is in.
// During the sweep phase (gcphase == _GCoff), each block has exactly
// one owner, so it's always safe to update this without atomics.
// But if this *could* be updated during the mark phase, it must be
// updated atomically to synchronize with the garbage collector
// scanning the block as a root.
n uint32
}That comment is a whole concurrency protocol in prose. During the sweep
phase the block has exactly one owner, so n can be a plain increment. But
if the block could be modified during the mark phase, n would have to be
atomic, because the GC might be scanning the block as a root at the same
time.
The queue itself separates “full” blocks from “free” blocks into two lock-free stacks, with the work counter carefully isolated on its own cache line:
// src/runtime/mcleanup.go:366
type cleanupQueue struct {
// Stack of full cleanup blocks.
full lfstack
workUnits atomic.Uint64 // length of full; decrement before pop from full, increment after push to full
_ [cpu.CacheLinePadSize - unsafe.Sizeof(lfstack(0)) - unsafe.Sizeof(atomic.Uint64{})]byte
// Stack of free cleanup blocks.
free lfstack
// flushed indicates whether all local cleanupBlocks have been
// flushed, and we're in a period of time where this condition is
// stable (after the last sweeper, before the next sweep phase
// begins).
flushed atomic.Bool // Next to free because frequently accessed together.
...
// Goroutine block state.
lock mutex
// sleeping is the list of sleeping cleanup goroutines.
//
// Protected by lock.
sleeping gListThe cpu.CacheLinePadSize padding between workUnits and free is the
classic false-sharing avoidance: workUnits is hammered by the sweeper
(producer) and by the cleanup goroutines (consumers), while free is only
touched by producers.
The sweeper enqueues, per-P
Enqueueing happens on the P that is doing the sweeping, and the block is cached on that P:
// src/runtime/mcleanup.go:459
// enqueue queues a single cleanup for execution.
//
// Called by the sweeper, and only the sweeper.
func (q *cleanupQueue) enqueue(c cleanupFn) {
mp := acquirem()
pp := mp.p.ptr()
b := pp.cleanups
if b == nil {
if q.flushed.Load() {
q.flushed.Store(false)
}
b = (*cleanupBlock)(q.free.pop())
if b == nil {
b = (*cleanupBlock)(persistentalloc(cleanupBlockSize, tagAlign, &memstats.gcMiscSys))
for {
next := (*cleanupBlock)(q.all.Load())
b.alllink = next
if q.all.CompareAndSwap(unsafe.Pointer(next), unsafe.Pointer(b)) {
break
}
}
}
pp.cleanups = b
}
if full := b.enqueue(c); full {
q.full.push(&b.lfnode)
pp.cleanups = nil
q.addWork(1)
}
pp.cleanupsQueued++
releasem(mp)
}The per-P caching means the common case (a few cleanups on this P) never
touches a shared structure at all. Only when a block fills up does it go on
the global full stack, and the block is recycled from the global free
stack.
Blocks are allocated from persistentalloc — non-GC’d, never returned to
the OS — and recycled through the free stack, so the steady-state
allocation rate of the cleanup system is essentially zero.
Flushing and waking the workers
At the end of the sweep phase, all the partially-filled per-P blocks are coalesced and pushed to the global stack:
// src/runtime/mcleanup.go:528
func (q *cleanupQueue) flush() {
mp := acquirem()
flushed := 0
emptied := 0
missing := 0
// Coalesce the partially-filled blocks to present a more accurate picture of demand.
// We use the number of coalesced blocks to process as a signal for demand to create
// new cleanup goroutines.
var cb *cleanupBlock
for _, pp := range allp {
...
}The comment explains why coalescing matters: it is not about correctness but about demand estimation. The number of coalesced blocks is used as a proxy for “how many cleanup goroutines do we need”.
The wake-up logic is where the parallelism comes from:
// src/runtime/mcleanup.go:588
// needsWake returns true if cleanup goroutines may need to be awoken or created to handle cleanup load.
func (q *cleanupQueue) needsWake() bool {
return q.workUnits.Load() > 0 && (q.asleep.Load() > 0 || q.ng.Load() < maxCleanupGs())
}So there is a maximum number of cleanup goroutines, and the runtime creates them on demand up to that limit:
// src/runtime/mcleanup.go:708
func maxCleanupGs() uint32 {
// N.B. Left as a function to make changing the policy easier.
return uint32(max(gomaxprocs/4, 1))
}A quarter of GOMAXPROCS, at least one. That is the direct answer to the
biggest complaint about SetFinalizer: cleanups used to run one at a time on
the sweeper’s goroutine, so a slow cleanup blocked all the others. Now they
run in parallel on a small pool, and the pool scales with the machine.
The sleeping protocol
The dequeue path has to handle the “no work right now” case without spinning:
// src/runtime/mcleanup.go:490
func (q *cleanupQueue) dequeue() *cleanupBlock {
for {
if q.tryTakeWork() {
// Guaranteed to be non-nil.
return (*cleanupBlock)(q.full.pop())
}
lock(&q.lock)
// Increment asleep first. We may have to undo this if we abort the sleep.
// We must update asleep first because the scheduler might not try to wake
// us up when work comes in between the last check of workUnits and when we
// go to sleep. (It may see asleep as 0.) By incrementing it here, we guarantee
// after this point that if new work comes in, someone will try to grab the
// lock and wake us. However, this also means that if we back out, we may cause
// someone to spuriously grab the lock and try to wake us up, only to fail.
// This should be very rare because the window here is incredibly small: the
// window between now and when we decrement q.asleep below.
q.asleep.Add(1)
...
q.sleeping.push(getg())
goparkunlock(&q.lock, waitReasonCleanupWait, traceBlockSystemGoroutine, 1)
}
}This is the classic lost-wakeup problem, and the comment walks through it
step by step: increment asleep before re-checking workUnits, so that a
producer that races with the sleep is guaranteed to see asleep > 0 and
wake somebody up. The cost of the conservative ordering is a rare spurious
wakeup — and the comment says so, and explains why it is acceptable.
The “take work” itself is a CAS on the counter, done before the lock:
// src/runtime/mcleanup.go:446
func (q *cleanupQueue) tryTakeWork() bool {
for {
wu := q.workUnits.Load()
if wu == 0 {
return false
}
// CAS to prevent us from going negative.
if q.workUnits.CompareAndSwap(wu, wu-1) {
return true
}
}
}The CAS loop is there to stop the counter from going negative if two workers
race. A plain Add(-1) would allow workUnits to wrap below zero and the
wake logic would then never fire again — a permanent hang of the cleanup
system. The comment “CAS to prevent us from going negative” is one of those
one-line comments that hides a catastrophic bug.
Trade-offs
Finalizer vs cleanup. The finalizer keeps the object alive and scans it; the cleanup does not. That single difference removes the cycle-collection problem, removes the “finalizer delays the object by a GC cycle” problem, and removes the need for the object to be alive when the cleanup runs. In exchange, a cleanup cannot inspect the object it is attached to.
Latency vs throughput. Old finalizers ran on the sweeper goroutine, one at a time. New cleanups run on a pool of dedicated goroutines, in parallel. A single slow cleanup no longer stalls the others.
Cancellation. AddCleanup returns a Cleanup value that can be
Stop()ped, and Stop does the real work: it finds the span, ensures it
has been swept, removes the special record and frees it. You can also call
runtime.SetFinalizer(obj, nil) to clear a finalizer.
One global lock per AddCleanup. addCleanup takes mheap_.speciallock.
The TODO in the source says it plainly: “Just locking is a considerable part
of the cost of AddCleanup.” If you are registering a cleanup per object in a
hot path, this is the bottleneck.
The argument is boxed. For pointer-free arguments smaller than 16 bytes you pay one allocation. That is a deliberate trade of a per-cleanup allocation against a per-cleanup 16-byte tax on every special record.
Cleanups are not guaranteed to run. The documentation is blunt:
The cleanup(arg) call is not always guaranteed to run; in particular it is not guaranteed to run before program exit.
And:
It is not guaranteed that a cleanup will run for objects allocated in initializers for package-level variables. Such objects may be linker-allocated, not heap-allocated.
Do not use cleanups to release OS resources that must be released. Use them to break reference cycles, close loggers, or flush buffers — things where “usually runs” is good enough.
Caveats
The tiny-allocator interaction is subtle. If you pass a small, pointer-free argument, the runtime deliberately avoids the tiny allocator. If you are copying cleanup code from elsewhere, do not “optimise” that allocation.
Zero-sized objects cannot have cleanups. The docs explain why:
Cleanups are not guaranteed to run if the size of T is zero bytes, because it may share same address with other zero-size objects in memory.
arg must not be ptr. AddCleanup panics if the argument is the same
pointer as the object, because the cleanup would never run — the argument
keeps the object alive forever.
Arena-allocated objects are not eligible. The runtime panics with “runtime.AddCleanup: ptr is arena-allocated”. Arena memory is freed in bulk; per-object cleanup semantics do not make sense there.
The specialCleanup record is not scanned automatically. It lives in
non-GC’d memory. If you write code that manipulates these structures (you
should not), you must scan them by hand as gcScanCleanup does.
Ordering is not guaranteed. If you register cleanups on several objects that become unreachable together, the order in which they run is unspecified.
Wrapping up
The one thing to remember: runtime.AddCleanup does not resurrect the
object. It attaches a special record to the object’s span holding a
closure and a separately-boxed argument, and the GC scans only that record —
never the object itself. That is why cleanups have no cycle problem and no
one-GC-cycle delay.
The part people get wrong: the cleanup system’s parallelism is the whole point of the rewrite, and it comes from a lock-free queue of 512-byte non-GC’d blocks plus a pool of on-demand cleanup goroutines — not from the GC. If your cleanup is slow, it now only delays other cleanups by the pool size, not all of them.
What to go do about it: if you are using runtime.SetFinalizer today,
read the AddCleanup documentation and the Cleanup type’s Stop method.
The migration is usually mechanical: move the closure’s captured state into
the arg parameter, and add a runtime.KeepAlive at the last point where
the object must stay reachable.
Further reading
src/runtime/mcleanup.go— the whole cleanup subsystem:AddCleanup, thecleanupFn/cleanupBlocktypes, thecleanupQueue, and the flush/wake logic.src/runtime/mheap.go—specialCleanup,addCleanup,setCleanupContext, and theKeepAlive(c)window fix.src/runtime/mgcmark.go—gcScanCleanupandgcScanFinalizer, and the span-specials scan loop that dispatches between them.src/runtime/mfinal.go— the older finalizer machinery, for comparison.src/runtime/mbitmap.go— the special record types and thespecialslist attached to spans.src/runtime/lfstack.go— the lock-free stack used for thefullandfreeblock lists.src/runtime/lock2.go— the runtime’s mutex, used formheap_.speciallockand the cleanup queue’slock.