Skip to content

Day 157 — sync.Pool & optimization

Month 6 · Week 3 · ⬅ Day 156 · Day 158 ➡ · Journal index

🎯 Learning Objective

Use sync.Pool to recycle short-lived hot-path objects, understand its caveats (not zeroed, GC may drain it), and confirm the win with -benchmem before adopting it.

📚 Topics

  • sync.Pool New/Get/Put; reuse vs the GC
  • Reset-after-Get discipline; safe vs unsafe pooled returns
  • Benchmark-driven optimization; the optimization checklist

📖 Reading / Sources

📝 Notes

  • sync.Pool is a free-list of temporary objects, scoped per-P (per logical processor) so Get/Put are mostly lock-free. Get returns a pooled object or calls New if empty; Put offers one back for reuse → [[sync-pool]].
  • It is an optimization, not a cache: the GC may drain the pool. Since Go 1.13 a victim cache keeps objects across one GC, but never rely on an object surviving — New must always be able to recreate one cheaply.
  • A pooled object is NOT zeroed. Get hands back whatever the last user left in it. Always Reset() (for *bytes.Buffer) or overwrite before use, or you leak stale data into the next request → a real security/correctness bug → [[pool-reset]].
  • Pool pointers, not values. Put takes any; putting a non-pointer boxes it and allocates, defeating the purpose. Store *bytes.Buffer, *[]byte, etc.
  • Don't keep a reference after Put. Once returned, another goroutine may Get the same object concurrently. Putting then continuing to write is a data race.
  • Beware unbounded growth from outliers. If you pool sized buffers, a rare huge buffer Put back gets retained and reused. Drop oversized objects before Put (if cap(b) > max { return }), as fmt/http do internally.
  • The right object: short-lived, frequently allocated, on a measured hot path (per-request buffers, JSON encoders, gzip writers, scratch slices). The wrong object: long-lived state, connections (use a real pool), or anything where correctness depends on it persisting.
  • Optimization order: profile first ([[day-155]]) → cut allocations via escape-analysis fixes ([[day-156]]) → only then pool what you can't eliminate. Re-measure with go test -bench=. -benchmem and compare allocs/op/B/op (use benchstat across runs).

💻 Code Examples

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func render(name string) string {
    b := bufPool.Get().(*bytes.Buffer)
    b.Reset()                 // CRITICAL: pooled objects keep old contents
    defer bufPool.Put(b)
    b.WriteString("Hello, ")
    b.WriteString(name)
    return b.String()         // String() copies, so reuse after Put is safe
}

Full code: examples/month-06/syncpool/main.go · Run: go run ./examples/month-06/syncpool

🏋️ Exercises / Practice

Exercise Status Link
Allocs: pooled vs fresh 64 KiB buffer examples/month-06/syncpool
Safe sync.Pool wrapper (Reset, race-clean) exercises/month-06/week-3/bufpool

🐛 Mistakes Made

  • Forgot b.Reset() after Get → the previous request's bytes leaked into the next response. Reset (or overwrite) is mandatory.
  • Put-ed a bytes.Buffer (value) instead of *bytes.Buffer → boxing allocated on every Put, erasing the benefit.

❓ Open Questions

  • How aggressively does the GC drain pools under memory pressure, and how does GOGC/GOMEMLIMIT interact with pool retention?

🧠 Active Recall (answer without looking)

  1. Q: Why must you Reset/overwrite an object returned by pool.Get()?
    A

sync.Pool does not zero objects; Get returns one exactly as the previous user left it. Without resetting, stale data (another request's bytes) leaks into the new use — a correctness and potential security bug. 2. Q: Why is sync.Pool described as "not a cache," and what does that imply for New?

A

The garbage collector may drain the pool at any time, so you can't depend on an object still being there. Therefore New must always be able to construct a fresh object cheaply — the pool only opportunistically reduces allocations, it doesn't guarantee them.

🪶 Feynman Reflection

sync.Pool is a bin of clean-ish rags by the workbench: instead of buying a new rag every time (allocating) and tossing it (GC), you grab one from the bin, use it, and drop it back. But the bin isn't sterile — you must wipe a rag before trusting it (Reset), the janitor (GC) may empty the bin overnight, and you must let go of a rag the moment you return it.

🕳️ Knowledge Gaps

  • benchstat workflow for statistically comparing before/after optimizations.
  • Per-P internals and how pool behavior scales with GOMAXPROCS.

✅ Summary

I can recycle hot-path objects with sync.Pool safely — pointers only, Reset after Get, drop outliers, never reference after Put — and I treat it as the last optimization step after profiling and allocation-cutting, validated with -benchmem.

⏭️ Next Steps / Prep for Tomorrow

  • Day 158: prove the optimizations under real traffic — load testing with k6 and vegeta.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: docs(journal): sync.Pool & optimization (day 157)