17.7 sync.Pool: Reusing Allocated Objects
Right, sync.Pool. This is where we graduate from “safe concurrency” to “performant concurrency.” You use a Mutex to protect access; you use a Pool to avoid the access in the first place. It’s a free-list of allocated objects that you can dip into to drastically reduce pressure on the garbage collector. The key insight is that sometimes, the most efficient way to manage memory is to not let the runtime manage it at all for certain, high-churn objects.
Think of a web server. Every request might need to decode JSON into a bytes.Buffer or a complex struct. Allocating and garbage-collecting a million of these per second is a fantastic way to keep your GC warm and your application slow. A sync.Pool lets you stash those objects away after you’re done with them and grab a used one next time you need it. It’s like having a box of used cardboard boxes in the attic instead of driving to the store every time you need to mail a package.
How It Works: Get and Put
The API is laughably simple, which is classic Go. A Pool has exactly two methods: Get() and Put().
package main
import (
"bytes"
"fmt"
"sync"
)
// A global pool for our byte buffers.
var bufferPool = sync.Pool{
New: func() interface{} {
// This function is called by pool.Get() when it has nothing to give you.
// It's your backup allocator.
return new(bytes.Buffer)
},
}
func main() {
// Need a buffer? Get one from the pool.
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf) // Put it back when you're done. Seriously, ALWAYS do this.
// Use the buffer exactly as you normally would.
buf.WriteString("This is a recycled, high-performance string!")
fmt.Println(buf.String())
// CRITICAL: Reset the object before re-use. The pool gives you used underwear.
// You wouldn't wear it without washing it, would you?
buf.Reset()
}
The New field is your insurance policy. If the pool is empty, Get() calls New to spawn a fresh object. Otherwise, it gives you a previously Put one. The type-assertion (e.g., .(*bytes.Buffer)) is your responsibility, and it’s a classic foot-gun if you get it wrong.
The Big, Honking Caveat: It’s Not a Cache
This is the most important thing to internalize, so I’m going to yell it: A SYNC.POOL IS EMPTIED ACCORDING TO THE WHIMS OF THE GARBAGE COLLECTOR.
The objects you Put in can be magically disappear at any time, for no apparent reason. The runtime does this to keep the heap size in check. You must treat the Get method as if it might always call your New function. This makes it perfect for things that are truly ephemeral and have a high allocation cost, but utterly useless for things you need to keep around, like a connection pool (despite the confusingly similar name). Relying on it for anything stateful is a one-way ticket to debugging-hell.
Why You Must Reset Your Objects
I mentioned this in the code, but it’s worth its own section. The pool gives you back an object in whatever state it was in when someone Put it. This is a beautiful disaster waiting to happen.
// DON'T DO THIS. This is how you introduce a heisenbug.
func badExample() {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
// Wait, what's already in 'buf'? Who knows! It's from a previous life!
buf.WriteString("New Data") // You might be appending to "Old Data" from a previous request!
fmt.Println(buf.String())
}
Always, always reset your objects to a pristine state immediately after Get-ting them. For a bytes.Buffer, that’s .Reset(). For a slice, that’s s = s[:0]. For a struct, that might be resetting all its fields or doing a whole new allocation if it’s complex. The pool manages memory, not object state. That job is still yours.
Performance and Pitfalls
The performance win can be staggering, often turning allocation-heavy bottlenecks into near-zero allocation zones. But the pitfalls are just as dramatic.
- Benchmark Relentlessly: Don’t just assume a Pool is faster. The overhead of
Get/Putand resetting can sometimes be worse than just allocating anew, especially for small, simple objects. Profile before and after. - No Configuration: The designers gave you zero knobs to turn. You can’t set a size limit or an expiry time. It’s a black box. This is either brilliantly simple or infuriatingly simplistic, depending on the problem you’re solving.
- The Empty Pool Gotcha: After a GC cycle, your pool is empty. The next series of
Get()calls will all hitNew, causing a spike of allocations. Your performance profile should look like a steady, low line, not a regular series of heart attacks. If it’s the latter, a Pool might not be the right tool.
Use it when you’ve measured a problem with GC pressure from short-lived, expensive-to-allocate objects. For everything else, just let the garbage collector do its job. It’s pretty good at it.