37.6 Reducing Allocations: sync.Pool, Value Types, and Preallocating Slices
Right, let’s talk about allocations. In the world of Go, allocations are like trips to the garbage can: you have to do them, but if you’re running back and forth every five seconds, you’re not getting any real work done. The garbage collector is incredibly smart, but it’s not clairvoyant. Every time you escape to the heap, you’re giving it more work to do later, which means eventually, it will have to stop your world (or at least a big part of it) to clean up your mess.
Our goal isn’t to eliminate allocations entirely—that’s a fool’s errand. Our goal is to be smart about it. To reduce the churn, to reuse what we can, and to avoid unnecessary work. We’re going to focus on three main weapons in this fight: sync.Pool, leaning on value types, and preallocating slices. Master these, and you’ll write Go that feels fast and smooth, not janky and stuttery.
The Magical, Misunderstood sync.Pool
First, let’s demystify sync.Pool. It’s not a cache. Say it with me: It. Is. Not. A. Cache. The contents in a Pool can be magically disappear at any time, evaporated by the GC. Trying to use it as a cache is a one-way ticket to confusing bug city.
So what is it? It’s a temporary holding pen for allocated objects that you’re likely to need again. You Put them back when you’re done, and you Get them when you need a new one. The beauty is that if there’s nothing in the Pool, Get will just give you a new one, and if there is something, you’ve saved yourself an allocation. It’s perfect for scenarios where you’re constantly creating and destroying the same type of object, like in a tight loop or when serving HTTP requests.
Here’s the classic example: buffered writers.
// A pool of bytes.Buffer pointers. We use pointers here
// because we want to avoid copying the (potentially large) Buffer.
var bufPool = sync.Pool{
New: func() interface{} {
// The New function is called when there's nothing fresh in the pool.
return new(bytes.Buffer)
},
}
func processRequest(data []byte) {
b := bufPool.Get().(*bytes.Buffer) // Type assertion is a necessary evil.
b.Reset() // CRITICAL: Don't let your dirty laundry mix with the clean!
defer bufPool.Put(b) // Defer the Put so we don't forget.
// Use the buffer. This avoids an allocation for every call.
b.Write(data)
// ... do work with b ...
// The buffer is Put back into the pool by the defer.
}
The two golden rules of sync.Pool:
- Always
Resetyour object after youGetit and before you use it. You have no idea what grimy state the previous user left it in. - Never assume the object you
Getwill have any specific state. It might be fresh fromNew, or it might be a used one. Your code must work with both.
Embrace the Value, Shun the Pointer
Go programmers, especially those from Java or C# backgrounds, have a weird reflex to reach for pointers. Stop it. A pointer forces an allocation onto the heap (except for some brilliant escape analysis miracles). A value type often lives a happy, allocation-free life on the stack.
This is most impactful with slices of structs. Ask yourself: does this slice really need to be a slice of pointers? Usually, the answer is no.
// Don't do this unless you have a VERY good reason (like needing to satisfy an interface).
type BadList []*MyStruct
// Do this. It's almost always better.
type GoodList []MyStruct
Why is GoodList better? When you create []MyStruct, the Go runtime allocates one contiguous block of memory for all your structs. When you create []*MyStruct, it first allocates the slice header (which contains the pointer to the underlying array), then it allocates the array of pointers, and then for each element, it allocates the individual MyStruct on the heap. You’ve traded one allocation for N+1 allocations. The GC now has to track all those individual objects. It’s a nightmare.
Use a slice of values. Your garbage collector will send you a thank-you note.
Preallocating Slices: A Lesson in Clairvoyance
The append function in Go is brilliant. But it’s also a bit of a drama queen. If it doesn’t have enough capacity in the underlying array, it has to go through the whole song and dance of allocating a new, bigger array, copying all the old data over, and then doing its job. This is expensive.
But you, the brilliant programmer, often know exactly how big your slice needs to be. So why are you making append guess? Tell the runtime your plans upfront using make with a length and a capacity.
// The naive way (slow and allocation-happy):
func getUsersNaive(userIDs []int) (users []User) {
for _, id := range userIDs {
user := fetchUser(id) // some expensive operation
users = append(users, user) // may cause multiple allocations & copies
}
return
}
// The enlightened way (fast and efficient):
func getUsersSmart(userIDs []int) (users []User) {
// We know the exact final size. Let's preallocate.
users = make([]User, 0, len(userIDs)) // length 0, capacity len(userIDs)
for _, id := range userIDs {
user := fetchUser(id)
users = append(users, user) // Now this will never reallocate. Zero drama.
}
return
}
The key here is the third argument to make: the capacity. We’re creating a slice with a length of zero (so it’s empty) but with an underlying array that’s already big enough to hold every single item we’ll ever need to append. This eliminates every single possible reallocation during the loop. It’s one of the simplest and most effective performance wins in all of Go. If you know the size, preallocate. It’s that simple.