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.SetFinalizer was 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.AddCleanup replaces 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:

  1. It must not run while the object is still reachable.
  2. 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.

go
// 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:

go
// 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 = arg

Two 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.

go
// 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:

go
// 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 = id

The 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:

go
// 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 id

The 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:

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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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 gList

The 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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, the cleanupFn/cleanupBlock types, the cleanupQueue, and the flush/wake logic.
  • src/runtime/mheap.go — specialCleanup, addCleanup, setCleanupContext, and the KeepAlive(c) window fix.
  • src/runtime/mgcmark.go — gcScanCleanup and gcScanFinalizer, 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 the specials list attached to spans.
  • src/runtime/lfstack.go — the lock-free stack used for the full and free block lists.
  • src/runtime/lock2.go — the runtime’s mutex, used for mheap_.speciallock and the cleanup queue’s lock.

GoInternalsGarbage collectionFinalizersRuntime