Memory, Escape Analysis and the Collector

Article · 14:00

Go has a garbage collector and pointers, and no new-and-delete distinction to worry about: you take an address and the compiler works out whether the value can live on the stack. Escape analysis, the slice that holds a megabyte alive, and when a pointer earns its place.

Memory, Escape Analysis and the Collector

Go has a garbage collector and pointers, and no new/delete distinction to worry about: you take an address and the compiler works out whether the value can live on the stack or must go on the heap. That decision is called escape analysis, and it is the one piece of Go memory behaviour worth understanding.

package main

import "fmt"

type Point struct{ X, Y int }

// Does not escape: the value is copied into the return, so it can live in the
// frame and disappear when the function returns.
func stackPoint() Point {
	p := Point{1, 2}
	return p
}

// Escapes: the caller gets a pointer, so the value must outlive this frame.
func heapPoint() *Point {
	p := Point{1, 2}
	return &p            // legal in Go, unlike C - the compiler moves it to the heap
}

func main() {
	a := stackPoint()
	b := heapPoint()
	fmt.Println(a, *b)

	// `go build -gcflags=-m` prints the decisions:
	//   ./main.go:14:9: &p escapes to heap
	// which is how you find an allocation you did not expect.
	fmt.Println("build with -gcflags=-m to see what escapes")
}

Returning a pointer to a local is safe in Go - the compiler notices and allocates accordingly. That removes a whole class of C bug, at the cost of an allocation you might not have intended.

What keeps memory alive

An object lives while something can reach it. Go's characteristic leaks are a slice that holds a much larger array, and a goroutine that never finishes.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	var stats runtime.MemStats

	// A small slice of a big array keeps the WHOLE array alive.
	big := make([]byte, 10_000_000)
	for i := range big {
		big[i] = byte(i)
	}
	small := big[:10]
	big = nil                          // the variable is gone; the array is not

	runtime.GC()
	runtime.ReadMemStats(&stats)
	fmt.Printf("holding %d bytes through a 10-byte slice: heap %dMB\n",
		cap(small), stats.HeapAlloc/1_000_000)

	// copy() breaks the link, and the 10MB becomes collectable.
	independent := make([]byte, len(small))
	copy(independent, small)
	small = nil

	runtime.GC()
	runtime.ReadMemStats(&stats)
	fmt.Printf("after copying out: heap %dMB\n", stats.HeapAlloc/1_000_000)
	fmt.Println(len(independent))
}

The other one - a goroutine blocked forever on a channel - was the last lesson's subject, and it is worth repeating here: a leaked goroutine holds its stack and everything its closure references.

Value or pointer: the decision you make constantly

package main

import "fmt"

type Small struct{ A, B int }
type Large struct{ Data [1024]int }

// A small struct is cheaper to copy than to chase a pointer for.
func sumSmall(s Small) int { return s.A + s.B }

// A large one is not - and a pointer also avoids a copy per call.
func sumLarge(l *Large) int {
	total := 0
	for _, v := range l.Data {
		total += v
	}
	return total
}

func main() {
	small := Small{1, 2}
	large := &Large{}
	large.Data[0] = 42

	fmt.Println(sumSmall(small), sumLarge(large))

	// The rules of thumb, in order of importance:
	//   1. Use a pointer if the method must modify the receiver.
	//   2. Use a pointer if the struct contains a sync.Mutex or similar - a
	//      copy of a mutex is a different mutex, and a silent bug.
	//   3. Use a pointer for large structs; a value for small immutable ones.
	//   4. Be consistent within a type: mixing receivers confuses everyone.
	fmt.Println("copying a struct with a mutex is the one that bites silently")
}

The collector, and what it asks of you

Go's collector is concurrent and non-generational: it runs alongside your program and aims for sub-millisecond pauses, at the cost of doing more total work than a generational collector would.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	var before, after runtime.MemStats
	runtime.ReadMemStats(&before)

	// Allocating in a loop. This is the cheap case - the collector is built
	// for it - but it is not free, and it is the first thing to look at when
	// a Go program is slower than it should be.
	total := 0
	for i := 0; i < 1_000_000; i++ {
		s := fmt.Sprintf("item-%d", i)     // one allocation per iteration
		total += len(s)
	}

	runtime.ReadMemStats(&after)
	fmt.Println("total length", total)
	fmt.Printf("allocations: %d, total allocated: %dMB, GC cycles: %d\n",
		after.Mallocs-before.Mallocs,
		(after.TotalAlloc-before.TotalAlloc)/1_000_000,
		after.NumGC-before.NumGC)

	fmt.Println("GOGC (default 100) trades heap size against CPU;")
	fmt.Println("GOMEMLIMIT (1.19+) puts a soft ceiling on the heap.")
}

Do not call runtime.GC() in application code - it is a hint at best and a pause you asked for at worst. The two knobs that matter in production are GOGC and GOMEMLIMIT, and both belong in deployment configuration rather than in the program.

sync.Pool, for the allocation you genuinely cannot avoid

package main

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

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

func render(name string) string {
	buf := buffers.Get().(*bytes.Buffer)
	defer func() {
		buf.Reset()            // always reset before putting it back
		buffers.Put(buf)
	}()

	buf.WriteString("hello, ")
	buf.WriteString(name)
	return buf.String()
}

func main() {
	var wg sync.WaitGroup
	results := make([]string, 4)
	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results[i] = render(fmt.Sprintf("worker %d", i))
		}()
	}
	wg.Wait()

	for _, r := range results {
		fmt.Println(r)
	}
	fmt.Println("a pool is for hot paths with measurable allocation - not a default")
}

What to take away

  • Escape analysis decides stack or heap; returning a pointer to a local is safe here.
  • go build -gcflags=-m tells you what escaped, and why.
  • A slice of a large array keeps the whole array alive - copy breaks the link.
  • Pointer receiver to modify, for large structs, and always when a mutex is involved.
  • The collector is concurrent and non-generational; tune with GOGC/GOMEMLIMIT, not runtime.GC().
  • sync.Pool is for a measured hot path, and always Reset before Put.