Weak Pointers in Go: The Runtime's Special Span for Non-Tracked Objects
The weak package gives Go a pointer that does not keep its target alive; the runtime implements it with a per-object special record holding a uintptr handle that the GC clears when the object dies.
TL;DR:
weak.Pointer[T]is a pointer the garbage collector does not honour. It is aspecialWeakHandlerecord on the object’s span holding anatomic.Uintptr— a raw address, not a Go pointer — so the GC never treats it as a reference. When the object dies, the sweeper zeroes the handle.
Every garbage-collected language eventually needs a way to say “I would like
to point at that object, but please don’t keep it alive for me”. Caches need
it. Canonicalisation tables need it. Graph algorithms with weak edges need
it. Go did not have it for a very long time, and the usual workaround —
runtime.SetFinalizer plus a map[uintptr]T — is a mess of resurrection
semantics and race conditions.
Go 1.24 added the weak package. It is small, and the implementation is
one of the more elegant pieces of runtime code in the tree, because it reuses
machinery that already existed: span specials.
The API
The whole package is one type and two methods:
// src/weak/pointer.go:60
type Pointer[T any] struct {
// Mention T in the type definition to prevent conversions
// between Pointer types, like we do for sync/atomic.Pointer.
_ [0]*T
u unsafe.Pointer
}
// Make creates a weak pointer from a pointer to some value of type T.
func Make[T any](ptr *T) Pointer[T] {
// Explicitly force ptr to escape to the heap.
ptr = abi.Escape(ptr)
var u unsafe.Pointer
if ptr != nil {
u = runtime_registerWeakPointer(unsafe.Pointer(ptr))
}
runtime.KeepAlive(ptr)
return Pointer[T]{u: u}
}
// Value returns the original pointer used to create the weak pointer.
// It returns nil if the value pointed to by the original pointer was reclaimed by
// the garbage collector.
// If a weak pointer points to an object with a finalizer, then Value will
// return nil as soon as the object's finalizer is queued for execution.
func (p Pointer[T]) Value() *T {
if p.u == nil {
return nil
}
return (*T)(runtime_makeStrongFromWeak(p.u))
}Three details in twenty lines carry most of the weight.
The _ [0]*T field. It is a zero-sized array whose only job is to make
the type mention T, so that Pointer[int] and Pointer[string] are
distinct types and cannot be converted between each other. The same trick
sync/atomic.Pointer uses.
abi.Escape(ptr). The pointed-to object must live on the heap, because
the runtime stores a special record on the span that contains it, and the
stack has no spans. Escape is a compiler intrinsic that forces the
argument to escape:
// src/internal/abi/escape.go:27
// Escape forces any pointers in x to escape to the heap.
func Escape[T any](x T) T {
if alwaysFalse {
escapeSink = x
}
return x
}The alwaysFalse branch is never taken, but the compiler’s escape analysis
sees the assignment to a global sink and therefore concludes that x
escapes. It compiles down to nothing at runtime.
runtime.KeepAlive(ptr) after the call. Between the escape and the
registration, the compiler could in principle prove ptr is dead. The
KeepAlive makes sure the object is still alive at the moment the weak
handle is installed. This is the first of the runtime’s documented weak
pointer invariants:
Strong-to-weak conversions must ensure the strong pointer remains live until the weak handle is installed. This ensures that creating a weak pointer cannot fail.
What is actually stored
Pointer[T].u is an unsafe.Pointer, but it does not point at the
target object. It points at a handle — a heap cell holding the target’s
address as a uintptr:
// src/runtime/mheap.go:2391
// specialWeakHandle is allocated from non-GC'd memory, so any heap
// pointers must be specially handled.
type specialWeakHandle struct {
_ sys.NotInHeap
special special
// handle is a reference to the actual weak pointer.
// It is always heap-allocated and must be explicitly kept
// live so long as this special exists.
handle *atomic.Uintptr
}
//go:linkname internal_weak_runtime_registerWeakPointer weak.runtime_registerWeakPointer
func internal_weak_runtime_registerWeakPointer(p unsafe.Pointer) unsafe.Pointer {
return unsafe.Pointer(getOrAddWeakHandle(p))
}
//go:linkname internal_weak_runtime_makeStrongFromWeak weak.runtime_makeStrongFromWeak
func internal_weak_runtime_makeStrongFromWeak(u unsafe.Pointer) unsafe.Pointer {
handle := (*atomic.Uintptr)(u)The address lives inside an atomic.Uintptr, which is a uintptr — an
integer. The garbage collector does not treat integers as references. That is
the whole trick. The weak pointer looks like a pointer to the GC, and is
not one.
The specialWeakHandle struct itself is marked sys.NotInHeap, and it is
allocated out of a fixalloc arena, i.e. outside the garbage-collected
heap. That means the GC will not find it by scanning, so the runtime has to
scan it by hand. You will see that below.
Note also the two //go:linkname declarations: the weak package reaches
into the runtime for registerWeakPointer and makeStrongFromWeak. This is
the escape hatch that article four is about, and it is the sanctioned way to
do it — the runtime pushes the symbols, the linker checks the pull.
Registering the handle
// src/runtime/mheap.go:2508
// Retrieves or creates a weak pointer handle for the object p.
func getOrAddWeakHandle(p unsafe.Pointer) *atomic.Uintptr {
if debug.sbrk != 0 {
// debug.sbrk never frees memory, so it'll never go nil. However, we do still
// need a weak handle that's specific to p. Use the immortal weak handle map.
// Keep p alive across the call to getOrAdd defensively, though it doesn't
// really matter in this particular case.
handle := mheap_.immortalWeakHandles.getOrAdd(uintptr(p))
KeepAlive(p)
return handle
}
// First try to retrieve without allocating.
if handle := getWeakHandle(p); handle != nil {
// Keep p alive for the duration of the function to ensure
// that it cannot die while we're trying to do this.
KeepAlive(p)
return handle
}
lock(&mheap_.speciallock)
s := (*specialWeakHandle)(mheap_.specialWeakHandleAlloc.alloc())
unlock(&mheap_.speciallock)The lookup path is getWeakHandle, which searches the span’s specials list
for an existing record at the right byte offset:
// src/runtime/mheap.go:2594
func getWeakHandle(p unsafe.Pointer) *atomic.Uintptr {
span := spanOfHeap(uintptr(p))
if span == nil {
if isGoPointerWithoutSpan(p) {
return mheap_.immortalWeakHandles.getOrAdd(uintptr(p))
}
throw("getWeakHandle on invalid pointer")
}
// Ensure that the span is swept.
// Sweeping accesses the specials list w/o locks, so we have
// to synchronize with it. And it's just much safer.
mp := acquirem()
span.ensureSwept()
offset := uintptr(p) - span.base()
lock(&span.speciallock)
// Find the existing record and return the handle if one exists.
var handle *atomic.Uintptr
iter, exists := span.specialFindSplicePoint(offset, _KindSpecialWeakHandle)
if exists {
handle = ((*specialWeakHandle)(unsafe.Pointer(*iter))).handle
}
unlock(&span.speciallock)
releasem(mp)
// Keep p alive for the duration of the function to ensure
// that it cannot die while we're trying to do this.
KeepAlive(p)
return handle
}Two very important facts fall out of this.
One. The special is attached at a byte offset into the object, not to
the object as a whole. offset := uintptr(p) - span.base(). So a weak
pointer to the third field of a struct gets its own handle, distinct from a
weak pointer to the first field. That matches the documented behaviour:
Two Pointer values compare equal if and only if the pointers from which they were created compare equal. … If multiple weak pointers are made to different offsets within the same object (for example, pointers to different fields of the same struct), those pointers will not compare equal. In other words, weak pointers map to objects and offsets within those objects, not plain addresses.
So interior pointers are supported. What is not supported is a pointer
that is not inside a heap allocation at all: spanOfHeap returns nil for
a stack address or a random integer, isGoPointerWithoutSpan only rescues
globals and the zero-sized zerobase, and everything else gets a
throw("getWeakHandle on invalid pointer").
Two. Weak handles are canonical per (object, offset). Two calls to
weak.Make(&x) return the same handle. The runtime guarantees this so that
the same object does not accumulate hundreds of special records:
Weak handles are unique and canonical for each byte offset into an object that a strong pointer may point to, until an object becomes unreachable.
Now the padding comment, which is a small masterpiece of defensive engineering:
// src/runtime/mheap.go:2532
// N.B. Pad the weak handle to ensure it doesn't share a tiny
// block with any other allocations. This can lead to leaks, such
// as in go.dev/issue/76007. As an alternative, we could consider
// using the currently-unused 8-byte noscan size class.
type weakHandleBox struct {
h atomic.Uintptr
_ [maxTinySize - unsafe.Sizeof(atomic.Uintptr{})]byte
}
handle := &(new(weakHandleBox).h)
s.special.kind = _KindSpecialWeakHandle
s.handle = handle
handle.Store(uintptr(p))
if addspecial(p, &s.special, false) {The Go runtime has a “tiny allocator” that packs several small pointer-free
objects into one 16-byte block. If the weak handle were allocated with
new(atomic.Uintptr), it would be 8 bytes and could be packed with other
objects. Then when the handle is swept, the whole block would be freed —
including unrelated objects that merely happened to share it. Worse, the
handle might never be freed, because a live neighbour keeps the block
alive, and the weak pointer would never become nil. The padding forces the
handle into its own allocation.
This is exactly the hazard the weak package documents:
because [Pointer.Value] is not guaranteed to eventually return nil, even after an object is no longer referenced, the runtime is allowed to perform a space-saving optimization that batches objects together in a single allocation slot. The weak pointer for an unreferenced object in such an allocation may never become nil if it always exists in the same batch as a referenced object.
The special kinds
Weak pointers get their own entry in the runtime’s list of span specials:
// src/runtime/mheap.go:1935
const (
// _KindSpecialTinyBlock indicates that a given allocation is a tiny block.
// Ordered before KindSpecialFinalizer and KindSpecialCleanup so that it
// always appears first in the specials list.
// Used only if debug.checkfinalizers != 0.
_KindSpecialTinyBlock = 1
// _KindSpecialFinalizer is for tracking finalizers.
_KindSpecialFinalizer = 2
// _KindSpecialWeakHandle is used for creating weak pointers.
_KindSpecialWeakHandle = 3
// _KindSpecialProfile is for memory profiling.
_KindSpecialProfile = 4
// _KindSpecialReachable is a special used for tracking
// reachability during testing.
_KindSpecialReachable = 5
// _KindSpecialPinCounter is a special used for objects that are pinned
// multiple times
_KindSpecialPinCounter = 6
// _KindSpecialCleanup is for tracking cleanups.
_KindSpecialCleanup = 7
// _KindSpecialCheckFinalizer adds additional context to a finalizer or cleanup.
// Used only if debug.checkfinalizers != 0.
_KindSpecialCheckFinalizer = 8
// _KindSpecialBubble is used to associate objects with synctest bubbles.
_KindSpecialBubble = 9
// _KindSpecialSecret is a special used to mark an object
// as needing zeroing immediately upon freeing.
_KindSpecialSecret = 10
)A “special” is a small record hanging off a span, describing something extra about one object in that span: it has a finalizer, it has a cleanup, it is being profiled, or — in our case — it has a weak handle.
// src/runtime/mheap.go:1965
type special struct {
_ sys.NotInHeap
next *special // linked list in span
offset uintptr // span offset of object
kind byte // kind of special
}Note _ sys.NotInHeap: the specials list lives outside the GC heap, in
memory the collector never scans. That is why every kind of special needs
its own explicit handling in the mark phase.
How the GC treats weak references
There are exactly two places the collector touches weak handles.
Marking: the handle is a root, the target is not
// src/runtime/mgcmark.go:404
// Objects with weak handles have only one invariant related
// to this function: weak handle specials (which are not in the
// garbage collected heap) are roots. In practice, this means
// the handle field must be scanned. Note that the value the
// handle pointer referenced does *not* need to be scanned. See
// the definition of specialWeakHandle for details.and the code that does it:
// src/runtime/mgcmark.go:453
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)Read the comment carefully: “the value the handle pointer referenced does
not need to be scanned”. The scanblock call marks the handle field
itself — that is, the *atomic.Uintptr allocation — so that the handle
cell stays alive. It does not mark the object the handle points at. The
target object is free to die.
This is the difference between a weak pointer and a strong one, expressed in one line of code: the pointer to the handle is scanned; the pointer inside the handle is not.
Sweeping: the handle is zeroed
When the sweeper finds an unmarked object, it walks the object’s specials and frees them. For a weak handle, “freeing” means storing zero:
// src/runtime/mheap.go:2779
func freeSpecial(s *special, p unsafe.Pointer, size uintptr) {
switch s.kind {
case _KindSpecialFinalizer:
sf := (*specialfinalizer)(unsafe.Pointer(s))
queuefinalizer(p, sf.fn, sf.nret, sf.fint, sf.ot)
lock(&mheap_.speciallock)
mheap_.specialfinalizeralloc.free(unsafe.Pointer(sf))
unlock(&mheap_.speciallock)
case _KindSpecialWeakHandle:
sw := (*specialWeakHandle)(unsafe.Pointer(s))
sw.handle.Store(0)
lock(&mheap_.speciallock)
mheap_.specialWeakHandleAlloc.free(unsafe.Pointer(s))
unlock(&mheap_.speciallock)sw.handle.Store(0) — the address is erased. From that moment on, every
weak pointer to that object sees a zero handle.
The sweeper does this even for objects that are being finalised:
// src/runtime/mgcsweep.go:593
if hasFinAndRevived {
// Pass 2: queue all finalizers and clear any weak handles. Weak handles are cleared
// before finalization as specified by the weak package. See the documentation
// for that package for more details.
for siter.valid() && siter.s.offset < endOffset {
// Find the exact byte for which the special was setup
// (as opposed to object beginning).
special := siter.s
p := s.base() + special.offset
if special.kind == _KindSpecialFinalizer || special.kind == _KindSpecialWeakHandle {
siter.unlinkAndNext()
freeSpecial(special, unsafe.Pointer(p), size)
} else {
// All other specials only apply when an object is freed,
// so just keep the special record.
siter.next()
}
}So the ordering is: weak handles are cleared first, then the finalizer is queued. A weak pointer never observes a finalised-but-resurrected object. The runtime states this as an invariant:
Weak handles contain nil as soon as an object becomes unreachable the first time, before a finalizer makes it reachable again. New weak handles created after resurrection are newly unique.
Converting back to a strong pointer
Value() is not a simple dereference. It has to handle the race where the
GC is running right now:
// src/runtime/mheap.go:2407
//go:linkname internal_weak_runtime_makeStrongFromWeak weak.runtime_makeStrongFromWeak
func internal_weak_runtime_makeStrongFromWeak(u unsafe.Pointer) unsafe.Pointer {
handle := (*atomic.Uintptr)(u)
// Prevent preemption. We want to make sure that another GC cycle can't start
// and that work.strongFromWeak.block can't change out from under us.
mp := acquirem()
// Yield to the GC if necessary.
if work.strongFromWeak.block {
releasem(mp)
// Try to park and wait for mark termination.
// N.B. gcParkStrongFromWeak calls acquirem before returning.
mp = gcParkStrongFromWeak()
}
p := handle.Load()
if p == 0 {
releasem(mp)
return nil
}The work.strongFromWeak.block mechanism exists because converting a weak
pointer to a strong one during the mark phase could resurrect an object that
the collector has already decided is garbage. If the mark phase is still
running, the conversion parks the goroutine until mark termination completes,
at which point the answer is unambiguous.
// src/runtime/mheap.go:2446
// Ensure the span is swept.
span.ensureSwept()
// Now we can trust whatever we get from handle, so make a strong pointer.
//
// Even if we just swept some random span that doesn't contain this object, because
// this object is long dead and its memory has since been reused, we'll just observe nil.
ptr := unsafe.Pointer(handle.Load())
// This is responsible for maintaining the same GC-related
// invariants as the Yuasa part of the write barrier. During
// the mark phase, it's possible that we just created the only
// valid pointer to the object pointed to by ptr. If it's only
// ever referenced from our stack, and our stack is blackened
// already, we could fail to mark it. So, mark it now.
if gcphase != _GCoff {
shade(uintptr(ptr))
}
releasem(mp)That shade call is the subtle bit. By materialising a strong pointer to the
object during the mark phase, we might have created the only reference to
it, and if the stack frame that holds it has already been blackened, the
object would never be marked and would be swept out from under us. Shading
it fixes that. It is the same invariant the Yuasa write barrier maintains.
Trade-offs
What weak pointers buy you:
- Caches that shrink. A
map[K]weak.Pointer[V]does not pinV. - Canonicalisation tables. The
uniquepackage is built on them. - No resurrection semantics. Unlike finalizers, weak pointers never resurrect anything, so they cannot be used to resurrect objects by accident.
- Cheap. A weak pointer is one word. A weak handle is one 16-byte allocation plus one special record.
What they cost:
- One special record per weak-referenced object. Specials live in a per-span linked list, and a span full of weak pointers has a long list.
- A handle allocation per object. The handle is padded to 16 bytes so it cannot be packed into a tiny block with unrelated objects.
- Weak-to-strong conversion can block. During mark termination, a
Value()call parks the goroutine. Value()is not guaranteed to returnnil. If the object was batched with a live neighbour, or the program exits first, the weak pointer may never observe the death of its target.
Caveats
Value() returning nil does not mean the object was freed. It means
the object is no longer guaranteed to be alive. The runtime may keep the
memory around; you simply may not use it.
Value() returning non-nil does not mean the object will stay alive.
The moment Value() returns, the strong pointer it gave you is on your
stack and the object is alive. But if you store that pointer somewhere and
drop your last other reference, it can still die. Use runtime.KeepAlive
if you need the object to survive across a call.
You cannot make a weak pointer to a stack variable. weak.Make forces
the target to the heap, but if you hand it a pointer into a stack frame, the
runtime throws getWeakHandle on invalid pointer.
A weak pointer to a global works, but via a different path. Globals have
no span, so they go into the immortalWeakHandleMap, a lock-free append-only
hash trie, and never become nil.
Weak pointers and finalizers interact in a specific order. The weak
handle is cleared before the finalizer runs. If you were expecting to read a
finalised object’s contents through a weak pointer, you will get nil.
Do not confuse weak.Pointer with runtime.SetFinalizer. Finalizers
keep the object alive for one extra GC cycle and then resurrect it.
Weak pointers do neither.
Wrapping up
The one thing to remember: a weak pointer stores the target’s address in
an atomic.Uintptr — an integer, not a pointer — so the garbage collector
never treats it as a reference. The GC scans the handle, not the target.
The part people get wrong: weak.Pointer.Value() is allowed to return
non-nil forever. The runtime batches tiny pointer-free objects together to
save space, and a batch containing a live object keeps its dead neighbours
weakly reachable. unique copes with this; hand-rolled weak-map code
usually does not.
What to go do about it: if you are building a cache with map[K]V and
worrying that it grows without bound, that is exactly the shape weak
pointers were made for. Store weak.Pointer[V] in the map, check
Value() on lookup, and delete the entry when it comes back nil.
Further reading
src/weak/pointer.gosrc/weak/doc.gosrc/runtime/mheap.gosrc/runtime/mgcmark.gosrc/runtime/mgcsweep.gosrc/runtime/mfinal.gosrc/internal/abi/escape.gosrc/unique/canonmap.go