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.Pool is 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:

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

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

go
// 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 single any that only this P touches. No atomics, no locks, no CAS. This is the fast path: Put into an empty private is one pointer store.
  • shared — a poolChain, 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.

text
   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 line

Put and Get

Both are short, and both are built around pinning the goroutine to its P:

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

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

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

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

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

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

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

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

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

text
   poolChain
     head (producer)                    tail (consumers)
        |                                   |
        v                                   v
   +----------+   next   +----------+   +----------+
   | 64 slots | -------> | 32 slots |   |  8 slots |
   +----------+          +----------+   +----------+
        ^
     popHead here                              ^
                                             popTail here

The head/tail trick

The core data structure is poolDequeue, a single-producer, multi-consumer queue with no locks and one atomic word of state:

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

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

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

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

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

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

  1. 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 popTail exists: the tail is the end of the queue that the owner is least likely to want, so it is the safest thing to take.
  2. 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.
  3. If that fails too, the victim cache is marked empty and Get returns nil (or calls New).

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:

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

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

go
// 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 — the Pool and poolLocal structs, Put, Get, getSlow, pin, pinSlow, poolCleanup
  • src/sync/poolqueue.go — poolDequeue, pushHead, popHead, popTail, poolChain
  • src/runtime/proc.go — procPin, procUnpin, and goyield
  • src/runtime/mgc.go — clearpools, called at the start of every GC
  • src/runtime/runtime2.go — the m.locks counter that pinning uses to disable preemption
  • src/sync/pool.go — the runtime_procPin/runtime_procUnpin linknames the pool uses to pin itself to a P

GoInternalsConcurrencyMemory