Skip to Content
Course content

219: Using sync.Pool for Allocation Reduction

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

I've seen this happen in countless code reviews: a developer tries to implement a "cache" using sync.Pool to store expensive-to-create objects, only to spend three days wondering why their "cache" is randomly emptying itself every few minutes. They treat it like a map with a mutex, expecting the objects to stay there until they manually remove them.

The Myth: sync.Pool is a Persistent Object Cache

If you think of sync.Pool as a way to ensure an object is always available or to limit the number of instances of a resource (like a database connection pool), you're in for a surprise. The Go runtime is allowed to clear a sync.Pool at any time—usually during a garbage collection cycle. I can't tell you how many times I've had to explain that the runtime views the pool as a collection of "disposable" items. If the GC decides it needs memory, everything in your pool is fair game for deletion.

If you need a guarantee that an object persists, a sync.Pool is the wrong tool. Using it as a cache will lead to unpredictable performance spikes because your "cached" items will vanish, forcing the pool to call your New function repeatedly under load.

The Reality: Reducing GC Pressure for Transient Objects

The real purpose of sync.Pool is to reduce the frequency of allocations for objects that are used very briefly and then thrown away. Think of it as a way to reuse "scratch space." When you use a sync.Pool, you aren't trying to save the object; you're trying to save the allocation cost and the subsequent work the garbage collector has to do to clean up thousands of short-lived objects.

A classic example is a bytes.Buffer. If you're formatting a log message or building a string in a high-throughput loop, allocating a new buffer every single time creates a mountain of garbage for the GC to climb. Instead, you can pull a buffer from a pool, use it, reset it, and put it back.

package main

import (
	"bytes"
	"fmt"
	"sync"
)

// We create a pool for bytes.Buffers. 
// The 'New' function is called when the pool is empty.
var bufferPool = sync.Pool{
	New: func() any {
		fmt.Println("Allocating new buffer...")
		return new(bytes.Buffer)
	},
}

func formatMessage(id int, msg string) string {
	// Get a buffer from the pool. It comes back as 'any', so we cast it.
	buf := bufferPool.Get().(*bytes.Buffer)
	
	// CRITICAL: Always reset the buffer before using it.
	// The buffer might contain data from a previous user.
	buf.Reset()

	buf.WriteString("ID: ")
	buf.WriteString(fmt.Sprintf("%d", id))
	buf.WriteString(" - Msg: ")
	buf.WriteString(msg)

	result := buf.String()

	// Put it back for the next goroutine to use.
	bufferPool.Put(buf)
	
	return result
}

func main() {
	for i := 0; i < 5; i++ {
		fmt.Println(formatMessage(i, "Hello Go!"))
	}
}

Notice the buf.Reset() call. This is where most people trip up. Because the pool gives you a used object, that object still contains whatever the previous goroutine wrote to it. If you forget to reset, your data will bleed across requests, which is a nightmare to debug.

  • Get(): Retrieves an item. If the pool is empty, it calls New.
  • Put(): Returns an item to the pool.
  • The Trade-off: You're trading a bit of complexity and the risk of state contamination (if you forget to reset) for a significant drop in CPU usage spent on garbage collection.

I usually suggest sync.Pool only after you've run a pprof heap profile and confirmed that a specific, short-lived object is causing a bottleneck. Don't optimize prematurely; just keep this tool in your back pocket for when the GC starts stealing too many of your CPU cycles.




📋 Practical Task

Exercise: High-Throughput JSON Fragment Builder

You are building a system that generates small JSON fragments for a telemetry stream. Currently, the system allocates a new bytes.Buffer for every single fragment, causing high GC overhead during peak traffic.

Your Task: Implement a FragmentPool using sync.Pool to reuse buffers. Your solution must:

  1. Define a sync.Pool with a New function that returns a pointer to a bytes.Buffer.
  2. Implement a function BuildFragment(sensorID string, value float64) string that:
    • Retrieves a buffer from the pool.
    • Ensures the buffer is cleared of old data.
    • Writes a JSON string in the format: {"id": "sensor_1", "val": 23.5}.
    • Returns the final string and returns the buffer to the pool.
  3. Ensure that there are no data leaks between calls (i.e., the second call to BuildFragment should not contain any data from the first call).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.