Memory Reuse with sync.Pool and GC-Induced Evictions

The sync package provides a type-safe object pool that aims to reduce pressure on the garbage collector by reusing allocated instances. Measuring the actual benefit requires careful benchmarking, because the pool's internal behavior can unexpectedly degrade performance when GC cycles are involved.

A minimal pool definition looks like this:

type Item struct {
    value int
}

var itemPool = sync.Pool{
    New: func() interface{} { return new(Item) },
}

//go:noinline
func modify(item *Item) { item.value++ }

Two benchmarks compare allocating fresh objects against reusing pool entries inside a tight loop:

func BenchmarkAlloc(b *testing.B) {
    var it *Item
    for i := 0; i < b.N; i++ {
        for j := 0; j < 10000; j++ {
            it = &Item{value: 1}
            b.StopTimer(); modify(it); b.StartTimer()
        }
    }
}

func BenchmarkPool(b *testing.B) {
    var it *Item
    for i := 0; i < b.N; i++ {
        for j := 0; j < 10000; j++ {
            it = itemPool.Get().(*Item)
            it.value = 1
            b.StopTimer(); modify(it); b.StartTimer()
            itemPool.Put(it)
        }
    }
}

Without any GC intervention, the pool version reduces allocations drastically because the same instance is recycled across iterations:

name         time/op        alloc/op        allocs/op
Alloc-8      3.02ms ± 1%    160kB ± 0%      1.05kB ± 1%
Pool-8       1.36ms ± 6%   1.05kB ± 0%        3.00 ± 0%

Here the pooled path shows only three allocations despite 10,000 loop iterations. The non-pooled variant allocates 10,000 structs on the heap. At first glance the pool is clearly faster and consumes less memory.

Realistic workloads, however, tend to create many intermediate allocations that trigger garbage collection. Forcing a GC cycle inside the benchmark with runtime.GC() reveals a different picture:

name         time/op        alloc/op        allocs/op
Alloc-8      993ms ± 1%    249kB ± 2%      10.9k ± 0%
Pool-8       1.03s ± 4%    10.6MB ± 0%     31.0k ± 0%

The pooled variant becomes slower and allocates far more memory. This stems from the pool’s internal design: objects stored in a sync.Pool can be removed at any time, and the runtime explicitly clears all pools before each GC cycle.

During initialization, a cleanup function is registered:

func init() {
    runtime_registerPoolCleanup(poolCleanup)
}

Inside runtime/mgc.go, the gcStart function invokes clearpools before the mark phase:

func gcStart(trigger gcTrigger) {
    // ...
    clearpools()
}

This explains the performance regression: every GC cycle empties the pool, forcing fresh allocations when objects are needed again. The documentation explicitly warns that items held in a pool may be silently evicted.

Under the hood, each sync.Pool maintains a per-P cache called poolLocal. Each local cache has a private field that only its owning processor can access without locking, and a shared queue that can be accessed by other processors and requires synchronization. Thus the pool is not a simple per-goroutine cache—it can be accessed concurrently from anywhere in the program. Go 1.13 introduced a victim cache and improvements to shared acess that mitigate the tension between pool reuse and GC evictions.

Tags: Go sync.Pool garbage collection Performance Memory Management

Posted on Fri, 08 May 2026 08:05:31 +0000 by stomlin