| name | go-perf-sync-pool |
| description | Guides sync.Pool as a GC-pressure reliever — per-P local pools with a lock-free fast path, the victim cache (a pooled item survives one GC), why items may vanish anytime so a Pool holds only fungible scratch, the Reset discipline, the grown-buffer retention leak (cap-check before Put), and putting a pointer not a value to avoid boxing into any. Auto-invokes on "use a sync.Pool", "pool these buffers", "reduce allocations with pooling", "is sync.Pool worth it". Routes Pool copylock to go-sync-primitives, allocator cost to go-perf-allocator-internals. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Performance: sync.Pool
"Pool's purpose is to cache allocated but unused items for later reuse, relieving pressure on the garbage collector."
— pkg.go.dev/sync#Pool
"Any item stored in the Pool may be removed automatically at any time without notification."
— pkg.go.dev/sync#Pool
A sync.Pool is a free list the runtime owns: it recycles short-lived allocations so a hot path stops minting garbage the GC then has to scan and reclaim. The correctness contract — items are transient, the zero value is ready, and A Pool must not be copied after first use (src/sync/pool.go:44) so go vet's copylocks fires on a by-value copy — is owned by go-sync-primitives; read it for the rules. This skill owns the performance engineering: the per-P internal structure that makes the fast path lock-free, the victim cache that decides whether a pooled item survives a GC, the pointer-vs-value Put, the grown-buffer retention leak, and the measurement that tells you whether pooling pays off at all. The allocator cost a Pool relieves is go-perf-allocator-internals; the GC it takes pressure off is go-perf-gc-tuning; deciding whether to do this by measurement is go-perf-methodology.
All Go below builds clean under gofmt, go vet, and go test on Go 1.25/1.26.
1. What a Pool Is For — and the One Thing It Must Hold
A Pool exists for exactly one job: "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" (sync#Pool). The canonical, correct use is fungible scratch space in a hot path — reusable *bytes.Buffer or *[]byte — and the standard library does exactly this: "An example of good use of a Pool is in the fmt package, which maintains a dynamically-sized store of temporary output buffers" (src/sync/pool.go:34-37).
The hard constraint that decides what a Pool may hold is its disappearance rule: "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" (src/sync/pool.go:18-20). A Pool is therefore a cache, never storage. The items must be fungible — any one is as good as any other, because Get "selects an arbitrary item" and "Callers should not assume any relation between values passed to Pool.Put and the values returned by Get" (src/sync/pool.go:124-128).
- Never pool anything that needs a
Close() or owns a resource (a connection, a file, a lock). The runtime drops items silently, so any cleanup you attached never runs. Connection pooling needs a real pool (e.g. database/sql), not sync.Pool.
- Never use a Pool as a cache of distinct keyed values. Every item is interchangeable scratch; there is no "get this one."
2. The Internal Structure — Why the Fast Path Is Lock-Free
A naive shared free list is a mutex-guarded slice, and under load that lock is the bottleneck a Pool exists to avoid. The runtime sidesteps it with a per-P structure (one slot per scheduler processor). The Pool struct holds local — a "local fixed-size per-P pool, actual type is [P]poolLocal" (src/sync/pool.go:55) — and each poolLocal is a private any "Can be used only by the respective P" plus a shared poolChain where the "Local P can pushHead/popHead; any P can popTail" (src/sync/pool.go:68-71).
That layout makes the common case lock-free. Get pins the goroutine to its P (runtime_procPin, which disables preemption — so no other goroutine touches this P's slot), then takes private with no synchronization at all; only if that is empty does it popHead the local shared chain, and only if that is empty does it getSlow and try to steal from other Ps' chains via popTail (src/sync/pool.go:132-147, 161-171). Put is the mirror: it fills private, else pushHeads the local chain (src/sync/pool.go:112-117). This is why "A Pool is safe for use by multiple goroutines simultaneously" (src/sync/pool.go:22) without you holding any lock — the contention is sharded across Ps by construction.
Two consequences for how you use it:
- A Pool only pays for itself under genuine concurrency and churn. The per-P machinery (procPin, the shared chain, work-stealing) is overhead a single-goroutine or low-traffic path will never amortize (§7).
- The runtime already cache-line-pads each
poolLocal — pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte, "Prevents false sharing" (src/sync/pool.go:73-78) — so you do not pad the Pool yourself; that battle is fought for you. (General false-sharing → go-perf-false-sharing.)
3. The Victim Cache — Surviving Exactly One GC
The single most important runtime fact for using a Pool well is what a GC does to it. Before Go 1.13 every GC emptied the Pool completely, so a heavy user saw an allocation spike on every cycle. Go 1.13 added the victim cache: "Pool no longer needs to be completely repopulated after every GC. It now retains some objects across GCs, as opposed to releasing all objects, reducing load spikes for heavy users of Pool" (Go 1.13 release notes).
The mechanism is poolCleanup, which "is called with the world stopped, at the beginning of a garbage collection" (src/sync/pool.go:258-259). It does two moves per GC (src/sync/pool.go:265-281):
- Drop the victim caches from the previous cycle (
p.victim = nil).
- Promote each primary cache to the victim cache (
p.victim = p.local; p.local = nil).
So an item you Put and then leave untouched lives in local; at the next GC it moves to victim; at the GC after that it is dropped. A pooled item survives exactly one GC. The practical rule: a Get/Put cycle whose churn outpaces the GC keeps the pool warm, because items are reclaimed from local into reuse before they age into victim and out. The victim is the cold tier — getSlow reaches it only "after attempting to steal from all primary caches because we want objects in the victim cache to age out if at all possible" (src/sync/pool.go:173-175).
This is why a Pool is not a memory leak you control and not a place to stash anything you expect to find later: across two quiet GCs, everything in it is gone. It is also why setting GOGC very low (frequent GCs) can defeat a Pool — items age out before they are reused (GC interaction → go-perf-gc-tuning).
4. New, and the Reset Discipline
Supply New so Get never returns nil: it "optionally specifies a function to generate a value when Get would otherwise return nil" (src/sync/pool.go:61-64). Without it, every caller must nil-check and construct by hand.
Get returns an item in whatever state the previous user left it — it is reused memory, not a fresh object. So you must reset it, and the discipline question is where:
- Reset on Get (right after acquiring) is the safest default: the user always starts clean regardless of how the item was returned.
bytes.Buffer resets in O(1) via buf.Reset().
- Reset on Put keeps the item small in the pool and means a borrower never sees stale bytes — but only if every return path calls
Put with a reset item.
Forgetting the reset is a correctness bug that leaks one request's data into the next (owned as a contract by go-sync-primitives); here it is also the lever for §6.
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func render(w io.Writer, data []byte) error {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
buf.Write(data)
_, err := w.Write(buf.Bytes())
return err
}
5. Put a Pointer, Not a Value — Boxing Into any Allocates
Both Put(x any) and New func() any traffic in any. Storing a non-pointer value (a struct, a slice header, a [N]byte array) into an interface forces the runtime to allocate a heap copy to back the interface word — which re-introduces the very allocation the Pool was meant to eliminate, on every Put. Storing a pointer does not: the pointer fits in the interface directly, so no boxing allocation occurs. Staticcheck encodes this as a dedicated check: SA6002, "Storing non-pointer values in sync.Pool allocates memory" (staticcheck SA6002).
The standard library always pools a pointer: fmt pools *pp (src/fmt/print.go), and log pools *[]byte — note the pointer to the slice, not the slice (src/log/log.go:168-172).
var bad = sync.Pool{New: func() any { return make([]byte, 0, 4096) }}
func badGet() []byte { return bad.Get().([]byte) }
func badPut(b []byte) { bad.Put(b[:0]) }
var good = sync.Pool{New: func() any { b := make([]byte, 0, 4096); return &b }}
func goodGet() *[]byte { return good.Get().(*[]byte) }
func goodPut(p *[]byte) { *p = (*p)[:0]; good.Put(p) }
Confirm the win with -benchmem: a value-typed Put shows a stubborn 1 allocs/op the pointer version drops to 0 (benchmark method → go-perf-benchmarking-statistics).
6. The Grown-Buffer Retention Leak — Cap-Check Before Put
The subtlest Pool bug inflates live heap instead of reducing it. If a borrower grows a pooled *bytes.Buffer to hold one 50 MB request and then Puts it back, that 50 MB capacity is now pinned in the pool — and worse, the per-P sharding means a handful of unlucky large requests can leave a giant buffer parked on several Ps, each held until two GCs age it out. The pool's job (steady, low memory) inverts.
This is not folklore — it is a bug the Go team fixed in the standard library and documented in the source. fmt's pooled printer caps its buffer before returning it: "Proper usage of a sync.Pool requires each entry to have approximately the same memory cost. To obtain this property when the stored type contains a variably-sized buffer, we add a hard limit on the maximum buffer to place back in the pool. If the buffer is larger than the limit, we drop the buffer and recycle just the printer" — if cap(p.buf) > 64*1024 { p.buf = nil } (src/fmt/print.go:156-164, fixing go.dev/issue/23199). log does the identical thing: if cap(*p) > 64<<10 { *p = nil } (src/log/log.go:174-183).
The rule: a pooled buffer is only fungible if every entry has roughly the same cost. Cap-check on the way in.
const maxPooled = 64 << 10
func putBuf(buf *bytes.Buffer) {
if buf.Cap() > maxPooled {
return
}
buf.Reset()
bufPool.Put(buf)
}
7. When a Pool Pays Off — and When It Costs You
A Pool is an optimization with a real downside, so it is gated on measurement, not reflex (the "should I" judgment is go-perf-methodology). It wins only when the relieved allocation churn outweighs the per-P machinery and the retained memory:
- It pays off on a high-throughput, concurrent path that allocates the same fungible scratch over and over — exactly
fmt's "store scales under load (when many goroutines are actively printing) and shrinks when quiescent" (src/sync/pool.go:35-37). The profile shows that object dominating alloc_objects/alloc_space.
- It costs you on a low-churn or low-concurrency path: the
Get/Reset/Put plumbing and the procPin overhead can be slower than just letting the escape analyzer or a fresh make handle a rarely-allocated object — and the pool now holds memory the program would otherwise have freed. The docs say it directly: "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" (src/sync/pool.go:39-42).
- A reused per-goroutine or per-request buffer is often simpler and faster than a Pool when the lifetime is already scoped — no sharing, no GC-timing surprises. Reach for a Pool only when the scratch genuinely crosses independent concurrent callers.
Prove it both ways: -benchmem for allocs/op and a benchstat A/B for ns/op under -cpu parallelism. If pooling shows ~ against the no-pool baseline, you bought complexity and retained memory for nothing.
8. Don't
- Don't pool anything that owns a resource or needs cleanup — items "may be removed automatically at any time without notification" (
src/sync/pool.go:18), so a Close() you rely on never runs. Use a real pool for connections.
- Don't
Put a non-pointer value — boxing it into any allocates, re-creating the garbage you were eliminating (staticcheck SA6002). Pool a *T.
- Don't
Put a buffer back without a cap-check — an over-grown buffer pins its capacity in the pool and inflates live heap (go.dev/issue/23199; src/fmt/print.go:156-164).
- Don't trust pooled state —
Get returns the previous user's leftovers; Reset before use (§4).
- Don't assume items persist — across two quiet GCs the victim cache is dropped and the Pool is empty (
src/sync/pool.go:265-281).
- Don't add a Pool on a low-churn path — "the overhead does not amortize well" (
src/sync/pool.go:39-42); measure first (go-perf-methodology).
- Don't copy a Pool —
copylocks correctness, owned by go-sync-primitives (src/sync/pool.go:44).
9. Routing to Related Skills
Correctness / idiom (sibling golang marketplace):
go-sync-primitives — the Pool correctness contract: items are transient, the zero value is ready, copylocks/no-copy, the Reset rule as a data-safety contract, and sync internals. Read it first; this skill is its performance depth.
This plugin (go-perf-* depth):
go-perf-allocator-internals — the allocator cost a Pool relieves: size classes, the tiny allocator, scan-vs-no-scan spans, what "1 alloc" really costs.
go-perf-gc-tuning — the GC a Pool takes pressure off; GOGC/GOMEMLIMIT, and how a low GOGC ages pooled items out via §3's victim cache.
go-perf-methodology — the "is pooling worth it here" judgment: profile the allocation churn first, then A/B the change.
go-perf-benchmarking-statistics — -benchmem for allocs/op and benchstat for the pointer-vs-value and pool-vs-no-pool A/B.
go-perf-false-sharing — general cache-line padding (the Pool already pads its own per-P slots, §2).
go-perf-strings-bytes-zerocopy — pooled bytes.Buffer/*[]byte as the backbone of zero-allocation string and byte work.
10. Reference Files
High-frequency sync.Pool performance anti-patterns in LLM-generated Go, each with wrong/right code and citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml