Allocation and Escape Analysis

Why a value is allocated on the heap instead of the stack, and how go build -gcflags=-m tells you which happened.

Syntaxgo build -gcflags='-m' ./... 2>&1 | grep escapes

Every function call gets a small region of memory called a stack frame, which holds that call's local variables and disappears the moment the call returns. Reclaiming it costs nothing. The heap is the other region: memory that outlives the call that created it, and which the garbage collector must later find and free. Heap allocation is not slow in itself, but a lot of it means a lot of collector work.

What escape analysis is

The compiler decides which of the two each value goes in. It traces where every value can be reached from, and if a value can still be reached after its function returns, it must live on the heap. That is called escaping. You never ask for it; the compiler works it out and the behaviour of your program is identical either way.

What makes a value escape

  • A pointer to a local is returned, stored in a field that outlives the call, or sent to a channel.
  • A value is stored in an interface, which is why arguments to fmt.Println escape.
  • A local is captured by a closure that runs in a new goroutine.
  • A slice or array is made with a size the compiler cannot bound at compile time.

Seeing the decision

Build with -gcflags=-m and the compiler prints its reasoning: moved to heap: p, &x escapes to heap, parameter b does not escape. Add a second -m for more detail, and -l to stop inlining confusing the line numbers.

Example

Example · go
package main

import "fmt"

type point struct{ x, y int }

// stays returns a copy. Nothing outside can reach the local, so it can
// live on the stack: the compiler says "p does not escape".
func stays() point {
	p := point{x: 1, y: 2}
	return p
}

// escapes returns a pointer to its local. The caller holds that pointer after
// the frame is gone, so the value must move: "moved to heap: p".
func escapes() *point {
	p := point{x: 3, y: 4}
	return &p
}

// The compiler cannot bound n, so the backing array is allocated on the heap:
// "make([]int, n) escapes to heap". With a constant size it would not.
func grow(n int) []int {
	return make([]int, n)
}

func main() {
	a := stays()
	b := escapes()
	c := grow(4)
	// Both forms below allocate a point; which region it lands in is decided
	// by escape analysis, not by the spelling. new(point) and &point{} differ
	// only in that the literal can set fields.
	d := new(point)
	e := &point{x: 5}
	fmt.Println(a.x+a.y, b.x+b.y, len(c), cap(c), d.x, e.x)
}

// Output:
// 3 7 4 4 0 5

When to use it

  • Explain why a function that touches no global state still shows up as an allocation hotspot in a heap profile.
  • Choose between a pointer field and a value field for a small struct in a hot request path.
  • Check that a rewritten encoding function really stopped allocating per call.
  • Keep a per-request buffer on the stack by giving it a constant size.

More examples

Reading the compiler's own report

This is the whole workflow: build, read, change one thing, build again. Note the last pair of lines: the values handed to fmt.Println escape because printing stores them in interface values, which is why a function looks worse under -m as soon as you add a print to it. Use this before optimising, so you are removing allocations that exist.

Example · bash
# -m prints each decision. -l turns off inlining so the lines line up with
# the functions you wrote rather than with where they were pasted in.
go build -gcflags='-m -l' . 2>&1 | sort

# Typical lines for the example above:
#   ./main.go:17:2: moved to heap: p
#   ./main.go:24:9: make([]int, n) escapes to heap
#   ./main.go:31:7: new(point) does not escape
#   ./main.go:33:10: ... argument does not escape
#   ./main.go:33:10: a.x + a.y escapes to heap

# Two -m flags explain the chain of reasoning, which is noisy but conclusive.
go build -gcflags='-m -m -l' . 2>&1 | grep -A2 'moved to heap'

An escape caused by storing a pointer

The two versions do the same job and differ only in whether the field is a pointer. The pointer version allocates once per call, forever, in a hot path. For a small struct with no need for nil, storing the value removes the allocation entirely. Reach for this reading when -m shows a hot function allocating and you cannot see why.

Example · go
type cache struct {
	last *point // a field that outlives any single call
}

// remember makes the local escape: the struct keeps the pointer after the
// call returns, so the value cannot die with the frame.
func (c *cache) remember(x, y int) {
	p := point{x: x, y: y}
	c.last = &p // "moved to heap: p"
}

// rememberValue keeps a copy instead. Nothing outlives the call, so nothing
// escapes, and the assignment costs two machine words.
type valueCache struct {
	last point
}

func (c *valueCache) remember(x, y int) {
	c.last = point{x: x, y: y}
}

A closure in a goroutine capturing a local

Anything a goroutine's closure touches has to survive after the loop iteration ends, so it escapes. That is correct and unavoidable, which is why the fix is to start fewer goroutines rather than to fight the compiler. The batched version is the shape to use when you find a loop spawning one goroutine per tiny item. min is a builtin, added in Go 1.21.

Example · go
func process(items []string) {
	var wg sync.WaitGroup
	for _, item := range items {
		// item is captured by the closure, and the closure outlives this
		// iteration, so item and the closure both escape to the heap.
		wg.Add(1)
		go func() {
			defer wg.Done()
			handle(item)
		}()
	}
	wg.Wait()
}

// The same work with one goroutine per batch: one escaping closure per batch
// instead of one per item.
func processBatched(items []string, size int) {
	var wg sync.WaitGroup
	for start := 0; start < len(items); start += size {
		end := min(start+size, len(items))
		batch := items[start:end]
		wg.Add(1)
		go func() {
			defer wg.Done()
			for _, item := range batch {
				handle(item)
			}
		}()
	}
	wg.Wait()
}

Discussion

  • Be the first to comment on this lesson.