sync.Pool: Per-P Caches, a Victim Generation and Why Items Vanish
sync.Pool keeps one shard of free objects per P, each shard a lock-free ring of ring buffers, and moves everything to a victim cache at every GC so that items survive exactly one collection cycle.
TL;DR:
sync.Poolis not a pool of objects you can count on. It is one cache per P, each holding a private slot plus a lock-free chain of ring buffers, and every garbage collection moves the whole thing into a “victim” cache and empties it. An object therefore survives at most two GC cycles, and if nobody asks for it in between, it is gone.
sync.Pool has a reputation for being one of the more confusing pieces of
the standard library. The documentation is famously unhelpful (“A Pool is a
set of temporary objects that may be individually saved and retrieved”), and
the behaviour people trip over is that objects disappear.
Here is what it actually is, from the Go 1.28 tree (commit c0be4674865b).
The contract first
Read this before reading the code, because every design decision follows from it:
// src/sync/pool.go:15
// A Pool is a set of temporary objects that may be individually saved and
// retrieved.
//
// Any item stored in the Pool may be removed automatically at any time without
// notification. If the Pool holds the only reference when this happens, the
// item might be deallocated.
//
// A Pool is safe for use by multiple goroutines simultaneously.
//
// Pool's purpose is to cache allocated but unused items for later reuse,
// relieving pressure on the garbage collector. That is, it makes it easy to
// build efficient, thread-safe free lists. However, it is not suitable for all
// free lists.
//
// An appropriate use of a Pool is to manage a group of temporary items
// silently shared among and potentially reused by concurrent independent
// clients of a package. Pool provides a way to amortize allocation overhead
// across many clients.
//
// An example of good use of a Pool is in the fmt package, which maintains a
// dynamically-sized store of temporary output buffers. The store scales under
// load (when many goroutines are actively printing) and shrinks when
// quiescent.
//
// On the other hand, a free list maintained as part of a short-lived object is
// not a suitable use for a Pool, since the overhead does not amortize well in
// that scenario. It is more efficient to have such objects implement their own
// free list.
//
// A Pool must not be copied after first use.“Any item stored in the Pool may be removed automatically at any time
without notification.” That is the whole promise. sync.Pool is a
best-effort cache, not a container. If your code needs the object to
still be there, it needs to own it.
The struct
// src/sync/pool.go:52
type Pool struct {
noCopy noCopy
local unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocal
localSize uintptr // size of the local array
victim unsafe.Pointer // local from previous cycle
victimSize uintptr // size of victims array
// New optionally specifies a function to generate
// a value when Get would otherwise return nil.
// It may not be changed concurrently with calls to Get.
New func() any
}Two generations of per-P arrays: local is the current one, victim is
the previous one. local is an unsafe.Pointer rather than a slice
because the array is reallocated whenever GOMAXPROCS changes, and the
whole design avoids a pointer-to-slice-header indirection on the hot
path.
The per-P entry is this:
// src/sync/pool.go:67
// Local per-P Pool appendix.
type poolLocalInternal struct {
private any // Can be used only by the respective P.
shared poolChain // Local P can pushHead/popHead; any P can popTail.
}
type poolLocal struct {
poolLocalInternal
// Prevents false sharing on widespread platforms with
// 128 mod (cache line size) = 0 .
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}Two storage slots per P:
private— a singleanythat only this P touches. No atomics, no locks, no CAS. This is the fast path:Putinto an emptyprivateis one pointer store.shared— apoolChain, a lock-free structure that this P pushes to and any P may steal from.
And the pad field: each poolLocal is padded out to a multiple of 128
bytes so that two different P’s shards never share a cache line. Without
it, every P would false-share its private slot with its neighbour’s and
the cache line would ping-pong between cores.
Pool.local -> [P]poolLocal
P0: [private | shared(chain) | pad .... 128 bytes]
P1: [private | shared(chain) | pad .... 128 bytes]
P2: [private | shared(chain) | pad .... 128 bytes]
^ each on its own cache linePut and Get
Both are short, and both are built around pinning the goroutine to its P:
// src/sync/pool.go:100
func (p *Pool) Put(x any) {
if x == nil {
return
}
if race.Enabled {
if runtime_randn(4) == 0 {
// Randomly drop x on floor.
return
}
race.ReleaseMerge(poolRaceAddr(x))
race.Disable()
}
l, _ := p.pin()
if l.private == nil {
l.private = x
} else {
l.shared.pushHead(x)
}
runtime_procUnpin()
if race.Enabled {
race.Enable()
}
}Note the very first line: Put(nil) is a no-op, because nil is the
sentinel the pool uses for “empty slot”.
The logic is a one-line heuristic: if the private slot is free, use it; otherwise push onto the shared chain. The private slot exists because the overwhelmingly common case is “one goroutine puts, the same goroutine gets”, and that needs no synchronisation at all.
Get is the mirror image:
// src/sync/pool.go:132
func (p *Pool) Get() any {
if race.Enabled {
race.Disable()
}
l, pid := p.pin()
x := l.private
if x != nil {
l.private = nil
} else {
// Try to pop the head of the local shard. We prefer
// the head over the tail for temporal locality of
// reuse.
x, _ = l.shared.popHead()
if x == nil {
x = p.getSlow(pid)
}
}
runtime_procUnpin()
if race.Enabled {
race.Enable()
if x != nil {
race.Acquire(poolRaceAddr(x))
}
}
if x == nil && p.New != nil {
x = p.New()
}
return x
}The order of attempts is: private slot, then the head of the local shared chain, then the slow path. The head is preferred over the tail “for temporal locality of reuse” — objects put recently are likely to be put again soon, so they stay hot in cache.
And the New fallback at the end: if the pool came up empty, New is
called to manufacture a fresh object. That is how sync.Pool is normally
used:
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}New is also the escape hatch that makes Pool usable without
pre-warming: the first few calls allocate, and later calls reuse.
Pinning: why items do not migrate between Ps
The pin method is the mechanism that makes the per-P design safe:
// src/sync/pool.go:201
// pin pins the current goroutine to P, disables preemption and
// returns poolLocal pool for the P and the P's id.
// Caller must call runtime_procUnpin() when done with the pool.
func (p *Pool) pin() (*poolLocal, int) {
// Check whether p is nil to get a panic.
// Otherwise the nil dereference happens while the m is pinned,
// causing a fatal error rather than a panic.
if p == nil {
panic("nil Pool")
}
pid := runtime_procPin()
// In pinSlow we store to local and then to localSize, here we load in opposite order.
// Since we've disabled preemption, GC cannot happen in between.
// Thus here we must observe local at least as large localSize.
// We can observe a newer/larger local, it is fine (we must observe its zero-initialized-ness).
s := rtatomic.LoadAcquintptr(&p.localSize) // load-acquire
l := p.local // load-consume
if uintptr(pid) < s {
return indexLocal(l, pid), pid
}
return p.pinSlow()
}runtime_procPin is astonishingly simple:
// src/runtime/proc.go:7961
func procPin() int {
gp := getg()
mp := gp.m
mp.locks++
return int(mp.p.ptr().id)
}It bumps m.locks, which disables preemption for this goroutine, and
returns the id of the P the goroutine is currently running on. Because
preemption is disabled, the goroutine cannot be moved to another P between
the pin and the unpin, so it is guaranteed that l.private really is
private to it.
And procUnpin:
// src/runtime/proc.go:7981
func procUnpin() {
gp := getg()
gp.m.locks--
}That is the entire pinning mechanism: increment a counter to forbid preemption, use the current P’s id as an index, decrement the counter afterwards. No locks, no atomics.
If the pool’s per-P array has not been allocated yet (or GOMAXPROCS grew
since the last allocation), pinSlow handles it under a mutex:
// src/sync/pool.go:225
func (p *Pool) pinSlow() (*poolLocal, int) {
// Retry under the mutex.
// Can not lock the mutex while pinned.
runtime_procUnpin()
allPoolsMu.Lock()
defer allPoolsMu.Unlock()
pid := runtime_procPin()
// poolCleanup won't be called while we are pinned.
s := p.localSize
l := p.local
if uintptr(pid) < s {
return indexLocal(l, pid), pid
}
if p.local == nil {
allPools = append(allPools, p)
}
// If GOMAXPROCS changes between GCs, we re-allocate the array and lose the old one.
size := runtime.GOMAXPROCS(0)
local := make([]poolLocal, size)
atomic.StorePointer(&p.local, unsafe.Pointer(&local[0])) // store-release
rtatomic.StoreReluintptr(&p.localSize, uintptr(size)) // store-release
return &local[pid], pid
}Note the comment: “If GOMAXPROCS changes between GCs, we re-allocate the
array and lose the old one.” Changing GOMAXPROCS at runtime therefore
discards the pool’s contents. So does calling runtime.GC() twice.
The lock-free chain
The shared slot is a poolChain:
// src/sync/poolqueue.go:194
type poolChain struct {
// head is the poolDequeue to push to. This is only accessed
// by the producer, so doesn't need to be synchronized.
head *poolChainElt
// tail is the poolDequeue to popTail from. This is accessed
// by consumers, so reads and writes must be atomic.
tail atomic.Pointer[poolChainElt]
}
type poolChainElt struct {
poolDequeue
// next and prev link to the adjacent poolChainElts in this
// poolChain.
//
// next is written atomically by the producer and read
// atomically by the consumer. It only transitions from nil to
// non-nil.
//
// prev is written atomically by the consumer and read
// atomically by the producer. It only transitions from
// non-nil to nil.
next, prev atomic.Pointer[poolChainElt]
}It is a doubly-linked list of poolDequeue rings. The producer (the
owning P) pushes onto the head element; consumers pop from the tail
element. When a ring fills up, a new, twice-as-large ring is appended; when
a ring empties, it is dropped from the chain.
// src/sync/poolqueue.go:220
func (c *poolChain) pushHead(val any) {
d := c.head
if d == nil {
// Initialize the chain.
const initSize = 8 // Must be a power of 2
d = new(poolChainElt)
d.vals = make([]eface, initSize)
c.head = d
c.tail.Store(d)
}
if d.pushHead(val) {
return
}
// The current dequeue is full. Allocate a new one of twice
// the size.
newSize := len(d.vals) * 2
if newSize >= dequeueLimit {
// Can't make it any bigger.
newSize = dequeueLimit
}
d2 := &poolChainElt{}
d2.prev.Store(d)
d2.vals = make([]eface, newSize)
c.head = d2
d.next.Store(d2)
d2.pushHead(val)
}So the chain grows 8, 16, 32, 64 … slots. The producer only ever pushes to the newest ring, and consumers pop from the oldest.
poolChain
head (producer) tail (consumers)
| |
v v
+----------+ next +----------+ +----------+
| 64 slots | -------> | 32 slots | | 8 slots |
+----------+ +----------+ +----------+
^
popHead here ^
popTail hereThe head/tail trick
The core data structure is poolDequeue, a single-producer,
multi-consumer queue with no locks and one atomic word of state:
// src/sync/poolqueue.go:19
type poolDequeue struct {
// headTail packs together a 32-bit head index and a 32-bit
// tail index. Both are indexes into vals modulo len(vals)-1.
//
// tail = index of oldest data in queue
// head = index of next slot to fill
//
// Slots in the range [tail, head) are owned by consumers.
// A consumer continues to own a slot outside this range until
// it nils the slot, at which point ownership passes to the
// producer.
//
// The head index is stored in the most-significant bits so
// that we can atomically add to it and the overflow is
// harmless.
headTail atomic.Uint64
// vals is a ring buffer of interface{} values stored in this
// dequeue. The size of this must be a power of 2.
//
// vals[i].typ is nil if the slot is empty and non-nil
// otherwise. A slot is still in use until *both* the tail
// index has moved beyond it and typ has been set to nil. This
// is set to nil atomically by the consumer and read
// atomically by the producer.
vals []eface
}One 64-bit word holds both indices: head in the top 32 bits, tail in
the bottom 32. Packing them means a single atomic operation can move
either one, and head is in the high bits specifically so that incrementing
it (which is += 1 << 32) cannot overflow into tail.
The ownership protocol is the clever part:
- Slots in
[tail, head)are owned by consumers. - A consumer that pops a slot still owns it until it nils the slot out.
- Only then does ownership pass back to the producer, which may reuse the slot.
The producer therefore cannot overwrite a slot that a consumer is still reading. That is what makes the queue lock-free but safe.
Pushing
// src/sync/poolqueue.go:80
func (d *poolDequeue) pushHead(val any) bool {
ptrs := d.headTail.Load()
head, tail := d.unpack(ptrs)
if (tail+uint32(len(d.vals)))&(1<<dequeueBits-1) == head {
// Queue is full.
return false
}
slot := &d.vals[head&uint32(len(d.vals)-1)]
// Check if the head slot has been released by popTail.
typ := atomic.LoadPointer(&slot.typ)
if typ != nil {
// Another goroutine is still cleaning up the tail, so
// the queue is actually still full.
return false
}
// The head slot is free, so we own it.
if val == nil {
val = dequeueNil(nil)
}
*(*any)(unsafe.Pointer(slot)) = val
// Increment head. This passes ownership of slot to popTail
// and acts as a store barrier for writing the slot.
d.headTail.Add(1 << dequeueBits)
return true
}Two fullness checks: the index arithmetic says “full”, and the typ
check says “the consumer has not finished with the tail slot yet, so it
is really full”. The typ check is the extra step that the plain ring
buffer does not need, because the producer and consumer here are different
goroutines.
Note the dequeueNil sentinel: nil is used to mean “empty slot”, so
nil values need a different representation:
// src/sync/poolqueue.go:60
// dequeueNil is used in poolDequeue to represent interface{}(nil).
// Since we use nil to represent empty slots, we need a sentinel value
// to represent nil.
type dequeueNil *struct{}Popping from the head
Only the owning P does this, so it does not need a CAS loop with a racing consumer… but it still uses one, because the tail can move underneath it:
// src/sync/poolqueue.go:112
func (d *poolDequeue) popHead() (any, bool) {
var slot *eface
for {
ptrs := d.headTail.Load()
head, tail := d.unpack(ptrs)
if tail == head {
// Queue is empty.
return nil, false
}
// Confirm tail and decrement head. We do this before
// reading the value to take back ownership of this
// slot.
head--
ptrs2 := d.pack(head, tail)
if d.headTail.CompareAndSwap(ptrs, ptrs2) {
// We successfully took back slot.
slot = &d.vals[head&uint32(len(d.vals)-1)]
break
}
}
val := *(*any)(unsafe.Pointer(slot))
if val == dequeueNil(nil) {
val = nil
}
// Zero the slot. Unlike popTail, this isn't racing with
// pushHead, so we don't need to be careful here.
*slot = eface{}
return val, true
}The comment “We do this before reading the value to take back ownership of
this slot” is the ordering that matters: the CAS decrements head first,
which claims the slot, and only then is the value read. A concurrent
pushHead cannot touch the slot until head has moved, and it cannot
move head until our CAS succeeds.
Popping from the tail
Any P may do this — this is the steal path:
// src/sync/poolqueue.go:147
func (d *poolDequeue) popTail() (any, bool) {
var slot *eface
for {
ptrs := d.headTail.Load()
head, tail := d.unpack(ptrs)
if tail == head {
// Queue is empty.
return nil, false
}
// Confirm head and tail (for our speculative check
// above) and increment tail. If this succeeds, then
// we own the slot at tail.
ptrs2 := d.pack(head, tail+1)
if d.headTail.CompareAndSwap(ptrs, ptrs2) {
// Success.
slot = &d.vals[tail&uint32(len(d.vals)-1)]
break
}
}
// We now own slot.
val := *(*any)(unsafe.Pointer(slot))
if val == dequeueNil(nil) {
val = nil
}
// Tell pushHead that we're done with this slot. Zeroing the
// slot is also important so we don't leave behind references
// that could keep this object live longer than necessary.
//
// We write to val first and then publish that we're done with
// this slot by atomically writing to typ.
slot.val = nil
atomic.StorePointer(&slot.typ, nil)
// At this point pushHead owns the slot.
return val, true
}The last three lines are the hand-off: clear the value so the GC can
collect it if nobody wants it, then atomically clear typ to tell the
producer “you may reuse this slot now”. The ordering (val first, then
typ) matters — the producer reads typ to decide whether it can write.
The slow path: stealing and the victim cache
When the local shard is empty, getSlow goes hunting:
// src/sync/pool.go:162
func (p *Pool) getSlow(pid int) any {
// See the comment in pin regarding ordering of the loads.
size := rtatomic.LoadAcquintptr(&p.localSize) // load-acquire
locals := p.local // load-consume
// Try to steal one element from other procs.
for i := 0; i < int(size); i++ {
l := indexLocal(locals, (pid+i+1)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
// Try the victim cache. We do this after attempting to steal
// from all primary caches because we want objects in the
// victim cache to age out if at all possible.
size = atomic.LoadUintptr(&p.victimSize)
if uintptr(pid) >= size {
return nil
}
locals = p.victim
l := indexLocal(locals, pid)
if x := l.private; x != nil {
l.private = nil
return x
}
for i := 0; i < int(size); i++ {
l := indexLocal(locals, (pid+i)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
// Mark the victim cache as empty for future gets don't bother
// with it.
atomic.StoreUintptr(&p.victimSize, 0)
return nil
}Three stages:
- Steal from every other P’s shared chain, starting with the next P
and wrapping around. This is the work-stealing pattern, and it is why
popTailexists: the tail is the end of the queue that the owner is least likely to want, so it is the safest thing to take. - The victim cache, but only after trying every primary cache. The comment gives the reason: “we want objects in the victim cache to age out if at all possible”. Objects that have been sitting for a whole GC cycle should be reclaimed before objects that were just put back.
- If that fails too, the victim cache is marked empty and
Getreturnsnil(or callsNew).
The two-generation rule
Here is the part that makes items vanish. At the start of every garbage collection, with the world stopped, the runtime calls the pool cleanup hook:
// src/sync/pool.go:258
//go:linkname poolCleanup
func poolCleanup() {
// This function is called with the world stopped, at the beginning of a garbage collection.
// It must not allocate and probably should not call any runtime functions.
// Because the world is stopped, no pool user can be in a
// pinned section (in effect, this has all Ps pinned).
// Drop victim caches from all pools.
for _, p := range oldPools {
p.victim = nil
p.victimSize = 0
}
// Move primary cache to victim cache.
for _, p := range allPools {
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
// The pools with non-empty primary caches now have non-empty
// victim caches and no pools have primary caches.
oldPools, allPools = allPools, nil
}The rule is right there in the code:
- Old victim caches are dropped. Anything that was in the victim generation at the start of this GC is thrown away. It has now survived two collections without being used.
- The primary cache becomes the victim cache, and the primary cache is emptied.
So the lifecycle of an object is:
Put()
|
v
[ primary ] -- GC #1 --> [ victim ] -- GC #2 --> gone
| |
| Get() here | Get() here
v v
reused reused (one last chance)An object that is put and never fetched survives one garbage collection. If it is still sitting in the victim cache at the next collection, it is dropped and the garbage collector reclaims it.
The runtime calls this from the garbage collector:
// src/runtime/mgc.go:2175
func clearpools() {
// clear sync.Pools
if poolcleanup != nil {
poolcleanup()
}
// clear boringcrypto caches
for _, p := range boringCaches {
atomicstorep(p, nil)
}
// Clear central sudog cache.
// Leave per-P caches alone, they have strictly bounded size.
// Disconnect cached list before dropping it on the floor,
// so that a dangling ref to one entry does not pin all of them.
lock(&sched.sudoglock)Note that the GC clears all pools in the program, not just the ones you know about, and it does so with the world stopped, which is why the cleanup function “must not allocate and probably should not call any runtime functions”.
Why a victim cache at all?
It seems wasteful to throw everything away at every GC. Why not just keep one generation?
Because the victim cache solves a specific problem: a burst of activity that ends just before a GC. Suppose a server handles a burst of 10 000 requests, each allocating a buffer, and then goes idle. The GC runs. If the pool dropped everything, the next burst would allocate 10 000 fresh buffers from the heap — a latency spike exactly when the system is trying to recover.
With the victim cache, the buffers survive one GC cycle. The next burst finds them waiting, and the GC that follows that reclaims them. So the pool adapts to the workload: an idle pool shrinks to nothing, a pool that is used every few milliseconds keeps its objects warm across the collection that happens in between.
That is the design intent, stated plainly in the doc comment: “The store scales under load (when many goroutines are actively printing) and shrinks when quiescent.”
Trade-offs
What the per-P design buys. Almost no contention. The common case —
Put then Get on the same P — touches one any field with no atomic
operation at all. Cross-P traffic is a single CAS on the shared chain, and
it only happens when a P is empty.
What it costs. Load imbalance. If 90% of the work runs on one P, the
other P’s shards stay empty and the busy P’s shard grows into a long chain
of rings. GOMAXPROCS changes and scheduler decisions affect the hit
rate.
What the private slot buys. A lock-free fast path for the dominant case, at the cost of one wasted slot per P.
What the victim generation buys. Objects survive one GC cycle, which
smooths out the “burst, then GC, then burst again” pattern. The cost is
that the pool can hold roughly twice the memory it needs, and that
runtime.GC() twice in a row empties it completely.
What the lock-free chain buys. No locks in the hot path at all. The
cost is a subtle ownership protocol: a slot is owned by a consumer until
it clears typ, and the producer must check typ before reusing it.
Caveats
Do not store anything you need. The pool can drop an object at any
GC. If losing it would be a bug, it does not belong in a sync.Pool.
Do not use a sync.Pool for a bounded set of objects. If you need
“exactly N buffers, reused”, a pool is the wrong tool: it will happily
grow without bound between collections, and it will shrink under you at
the worst moment. Use a channel-based free list or a fixed-size ring.
Put(nil) does nothing. It is not an error and it does not store
nil. Get will not return nil from the pool itself.
The private slot is not really private. The comment says “Can be
used only by the respective P”, and pinning enforces that in the common
case. But getSlow does read victim’s private slot directly, and
after a GOMAXPROCS change the array is reallocated. Treat private as
an optimisation, not a guarantee.
Changing GOMAXPROCS discards the pool. pinSlow reallocates the
array when the P count grows, and the old array is thrown away — contents
included.
runtime.GC() twice empties the pool. The first collection moves
everything to the victim cache; the second drops the victim cache.
The pool is per-P, so a Pool used by one goroutine only ever touches
one shard. If your program has one goroutine doing all the work, the
other GOMAXPROCS-1 shards are pure overhead — a slice of 128-byte
structs per P.
Do not copy a Pool. The noCopy field is there to make go vet
catch it. A copied pool shares one local pointer, so both copies fight
over the same shards.
Items in the pool are reachable from the Pool, so they are not
collected while the pool holds them. That is the point, but it means a
pool that holds a 10 MB buffer keeps that buffer alive for up to two GC
cycles after you Put it.
Wrapping up
The one thing to remember: sync.Pool is a per-P cache with a
private single-item slot and a lock-free shared chain, and every garbage
collection rotates it into a victim generation. An object survives one GC
cycle, and is dropped at the second.
The part people get wrong: the pool is a cache, not a container.
Get returning nil is legal and expected; the fix is a New function
(or holding your own reference), never assuming the object is still
there.
What to go do about it: if you are using sync.Pool to avoid
allocations, measure with testing.AllocsPerRun and check the allocation
count across a runtime.GC() — if the number of allocations jumps after a
collection, your objects are being dropped and you need to either keep a
reference yourself or accept that the pool is doing its job and the
allocation is the price of the memory.
Further reading
src/sync/pool.go— thePoolandpoolLocalstructs,Put,Get,getSlow,pin,pinSlow,poolCleanupsrc/sync/poolqueue.go—poolDequeue,pushHead,popHead,popTail,poolChainsrc/runtime/proc.go—procPin,procUnpin, andgoyieldsrc/runtime/mgc.go—clearpools, called at the start of every GCsrc/runtime/runtime2.go— them.lockscounter that pinning uses to disable preemptionsrc/sync/pool.go— theruntime_procPin/runtime_procUnpinlinknames the pool uses to pin itself to a P