The Runtime Has Knobs: Escape Analysis, the GC, and sync.Pool

How the Go runtime decides stack versus heap, what GOGC and GOMEMLIMIT actually control, why GOMAXPROCS matters in a container, and when sync.Pool earns its keep — measured, not guessed. Compiled and run against Go 1.26.5.

Go hides a lot of machinery behind a simple language, and most of the time you want it hidden. The garbage collector runs on its own, the scheduler spreads your goroutines across cores, and the compiler quietly decides which values live on the stack and which go to the heap. You can build fast, correct programs without ever thinking about any of it. But when a service is slow, or its memory graph climbs until the container gets killed, you need to know what the runtime is doing and which knobs change it.

This chapter is about those knobs: escape analysis, the two garbage-collector settings, GOMAXPROCS, and sync.Pool. The through-line, though, is one rule you should tattoo somewhere: never tune blind. Every setting here changes behavior you cannot see by reading the code, so the only honest way to touch them is to measure first, change one thing, and measure again. The profiling chapter gave you the tools; this chapter gives you the dials. Everything below was compiled and run against Go 1.26.5.

Where allocations come from

An allocation on the stack is nearly free: it vanishes when the function returns, and the garbage collector never sees it. An allocation on the heap costs a GC something to track and eventually reclaim. So the single most useful runtime fact is which of your values land where, and the compiler will tell you if you ask. This is escape analysis: for every value, the compiler proves whether it can safely stay on the stack, or whether it “escapes” and must go to the heap.

Here are two functions that look similar and are decided differently:

// sumLocal keeps its work on the stack: total never outlives the call, and its
// address is never taken, so the compiler can allocate it in the frame.
func sumLocal(nums []int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

// newCounter returns a pointer to a local, so the local must outlive the call.
// The compiler is forced to move it to the heap.
func newCounter() *int {
	c := 0
	return &c
}

Pass -gcflags="-m" to go build and the compiler prints its reasoning:

$ go build -gcflags="-m" .
main.go:7:15: nums does not escape
main.go:18:2: moved to heap: c

nums “does not escape” — the slice header stays on the stack. But c is “moved to heap” because newCounter returns its address, so the variable has to outlive the call that created it. That is the rule in one line: if a pointer to a value survives the function, the value escapes. Returning pointers, storing them in longer-lived structures, and passing them to interfaces are the usual reasons. You do not have to eliminate every escape, and you shouldn’t try. But when a profile shows a hot path allocating hard, -m tells you why, and often the fix is as small as not taking an address you didn’t need.

The GC knobs: GOGC and GOMEMLIMIT

Go’s garbage collector is concurrent and self-pacing, and it exposes exactly two dials worth knowing.

GOGC sets the trade between CPU and memory. Its default is 100, which means: run a collection once the live heap has grown by 100% since the last one — in other words, when it has doubled. Raise it to 200 and the heap is allowed to grow more between collections, so the GC runs less often and burns less CPU, at the cost of a higher memory ceiling. Lower it to 50 and collections come sooner, holding memory down but spending more CPU. You can set it with the GOGC environment variable or from code with debug.SetGCPercent, which returns the previous value.

GOMEMLIMIT (added in 1.19) is the more important of the two for production. It is a soft memory limit: as the heap approaches it, the collector runs more aggressively to stay under it, trading CPU to avoid running out of memory. It does not replace GOGC; it backstops it. The classic setup in a container is a generous GOGC for throughput plus a GOMEMLIMIT set a little below the container’s memory quota, so the GC leans in before the kernel’s OOM killer does.

old := debug.SetGCPercent(100)          // returns the previous GOGC
fmt.Println("GOGC was: ", old)

debug.SetMemoryLimit(256 << 20)         // 256 MiB soft limit
fmt.Println("MemLimit:  ", debug.SetMemoryLimit(-1)) // -1 = query, don't change

debug.SetGCPercent(50)                   // collect at +50% growth
// ... allocate 64 MiB of retained slices ...
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("NumGC: %d\n", m.NumGC)

runtime.ReadMemStats is how you confirm a knob did something. Running the program with the default GOGC, then again with GOGC=200 in the environment, the code reads the change straight back:

$ go run .
GOGC was:  100
MemLimit:   9223372036854775807
MemLimit:   268435456
NumGC:      9 (retained 64 MiB)

$ GOGC=200 go run .
GOGC was:  200

The default limit reads as 9223372036854775807 — that is math.MaxInt64, the runtime’s way of saying “no limit set.” After SetMemoryLimit(256 << 20) it reads back 268435456, exactly 256 MiB. And allocating 64 MiB with GOGC=50 triggered nine collection cycles; at the default of 100 there would have been fewer, because the heap is allowed to grow further between them. The numbers are the point: don’t reason about the GC, read MemStats.

GOMAXPROCS, and the container trap

GOMAXPROCS is the number of OS threads that may execute Go code simultaneously. It defaults to the number of CPUs the runtime detects:

$ go run .
NumCPU:     16
GOMAXPROCS: 16

$ GOMAXPROCS=2 go run .
GOMAXPROCS: 2

The 16/2 above are run on this machine. The part I did not run — flagged honestly, because there is no container here to test it in — is the container story, which is documented behavior worth knowing. For years this had a sharp edge: a pod limited to 2 CPUs, running on a 64-core node, would see 64 and set GOMAXPROCS to 64, spawning far more runnable threads than its CPU quota allowed, which the scheduler then throttled, adding latency. The old fix was to import uber-go/automaxprocs or set the variable by hand from the cgroup limit. As of Go 1.25 the runtime reads the cgroup CPU quota itself and sizes GOMAXPROCS to match, so the default is finally right inside a container. Know the setting exists, and if you are on an older runtime, set it from your CPU limit explicitly.

sync.Pool: reuse instead of reallocate

When a hot path allocates the same short-lived object over and over — a scratch buffer per request, a parser’s working state — the allocations and the GC pressure they create can dominate. sync.Pool is a free list of reusable objects: you Get one (which either reuses a spare or calls your New), use it, and Put it back. The pool is safe for concurrent use, and the GC may empty it between cycles, so it is strictly a cache, never storage.

The way to know whether it helps is a benchmark with -benchmem, which reports allocations per operation. Here is the same work — grow a buffer and write 512 lines into it — done fresh each time versus borrowed from a pool:

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func buildPooled() int {
	buf := bufPool.Get().(*bytes.Buffer)
	buf.Reset()          // reuse the grown backing array
	fill(buf)
	n := buf.Len()
	bufPool.Put(buf)
	return n
}
$ go test -bench=. -benchmem
BenchmarkFresh-16     70880   15469 ns/op   65472 B/op   10 allocs/op
BenchmarkPooled-16   293042    4090 ns/op       0 B/op    0 allocs/op

Ten allocations per operation drop to zero, and the operation runs almost four times faster, because the pooled buffer keeps its grown backing array between uses instead of rebuilding it from nothing. That is a real, measured win — for this workload. sync.Pool is not free: it has coordination cost, and for objects that are cheap to allocate it can be slower than just allocating. This is exactly why the benchmark is not optional. The pool paid off here because the objects were expensive to grow and reused constantly; change either of those and the answer changes.

Final thoughts

The runtime’s defaults are good, and the first rule of tuning them is to suspect you don’t need to. When you do, the order is fixed: profile, then turn one knob, then profile again. The pprof chapter is the first half of that sentence and this chapter is the second. Escape analysis tells you where the heap traffic is born, so you can cut it at the source. GOGC and GOMEMLIMIT set the CPU-versus-memory trade, and MemStats proves the change landed. GOMAXPROCS matches your real CPU budget, which the runtime now does for you in a container. And sync.Pool trades allocation for reuse when — and only when — a -benchmem benchmark says it wins. None of these are things you reason your way to; they are things you measure. A knob turned on a hunch is just a different way to be wrong.

Next: health checks — the endpoints that tell an orchestrator whether to restart you, route to you, or wait for you.

Comments