Skip to Content
Course content

162: sync.Pool for Object Reuse

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this exact bug pop up in production more times than I'd like to admit. It usually happens when a team is trying to optimize a high-throughput service. They notice the garbage collector (GC) is working overtime, so they implement a sync.Pool to reuse objects and stop the constant allocation cycle. Everything looks great in the benchmarks, but then the logs start looking... weird.

Take a look at this snippet. It's a simple system designed to format request logs into a byte slice before sending them to a writer.

type LogFormatter struct {
    pool sync.Pool
}

func NewLogFormatter() *LogFormatter {
    return &LogFormatter{
        pool: sync.Pool{
            New: func() any {
                return new(bytes.Buffer)
            },
        },
    }
}

func (lf *LogFormatter) Format(msg string) []byte {
    buf := lf.pool.Get().(*bytes.Buffer)
    buf.WriteString("LOG: ")
    buf.WriteString(msg)
    
    result := buf.Bytes()
    lf.pool.Put(buf)
    return result
}

The ghost data problem

At first glance, this looks efficient. We're reusing bytes.Buffer objects to avoid allocating a new one every time Format is called. But if you run this in a loop, you'll notice something terrifying: your logs start growing. The first call returns "LOG: Hello", the second returns "LOG: HelloLOG: World", and by the hundredth call, you have a massive string containing every log message since the program started.

The issue is that sync.Pool doesn't "reset" the object for you. It just gives you back a pointer to whatever was left in the pool. Since we never cleared the buffer, the next user of that buffer just keeps appending to the existing data. I call this "ghost data"—state leaking from one request into another. In a real-world scenario, this could be a catastrophic security leak if you're pooling buffers that hold user session data.

Resetting state before reuse

The fix is simple, but easy to forget: you must ensure the object is in a clean state. You can do this right after you Get() the object, or right before you Put() it back. I generally prefer doing it before the Put() so the object sits in the pool already cleaned and ready for the next consumer.

func (lf *LogFormatter) Format(msg string) []byte {
    buf := lf.pool.Get().(*bytes.Buffer)
    
    buf.WriteString("LOG: ")
    buf.WriteString(msg)
    
    // We must copy the bytes because buf.Bytes() returns a slice 
    // pointing to the buffer's internal memory. If we Put(buf) 
    // and then someone else modifies it, our 'result' changes!
    result := make([]byte, buf.Len())
    copy(result, buf.Bytes())
    
    buf.Reset() // Clear the buffer for the next user
    lf.pool.Put(buf)
    
    return result
}

Notice that I added a copy here as well. This is the second most common mistake with sync.Pool. If you return buf.Bytes() directly and then put the buffer back in the pool, you've created a race condition. Another goroutine could Get() that buffer and start writing to it while your original caller is still reading the result. Always be careful about who "owns" the memory after an object goes back into the pool.

When you actually need a pool

I want to give you a word of caution: don't reach for sync.Pool just because it sounds "fast." It adds complexity and, as we saw, creates opportunities for state-leak bugs.

You should use a pool when:

  • You are allocating large objects frequently (like []byte buffers or complex structs).
  • The GC pauses are becoming a bottleneck in your profiling (use pprof to verify this).
  • The objects are expensive to create but cheap to reset.

Remember that sync.Pool is not a cache. The runtime can clear the pool at any time (usually during a GC cycle), so you can never rely on a specific object staying in the pool. It's purely a performance optimization to reduce allocation pressure.




📋 Practical Task

Implementing a Pooled JSON Encoder

You are building a high-performance metrics exporter. Currently, the app creates a new bytes.Buffer for every single metric exported, which is causing significant GC overhead. Your task is to implement a MetricEncoder that uses a sync.Pool to reuse buffers.

Requirements:

  • Create a MetricEncoder struct with a sync.Pool.
  • Implement a Encode(name string, value float64) []byte method.
  • The output format should be name=value\n.
  • Ensure that buffers are properly reset so that metrics from previous calls do not appear in subsequent calls.
  • Ensure that the returned []byte is a fresh copy of the data, so that reusing the buffer in the pool doesn't overwrite the result being used by the caller.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.