module 01 · memory models · Part II · the languages

Chapter 11 — Go: escape analysis and the concurrent GC

The 30-second version. Go bets on millions of tiny goroutines, and that bet forces everything else. Tiny goroutines mean tiny stacks (2 KB); tiny stacks must grow; Go grows them by copying — so a pointer into a stack cannot outlive its frame. The compiler decides, at build time, which values stay in a frame and which must move somewhere whose address never changes: that decision is escape analysis, and it is why Go lets you write &x as freely as C (ch08) while rarely paying malloc. The heap is reclaimed by a concurrent tricolor mark-and-sweep collector behind a hybrid write barrier — no compaction — paced by GOGC and GOMEMLIMIT to hold stop-the-world pauses to tens of microseconds.

The bet: a million goroutines, and what it forces

Derive the GC; don't accept it. Go's 2009 target: servers holding millions of connections, each with its own flow of control.

Step 1 — threads are too heavy. An OS thread costs a kernel stack (Linux default: 8 MB virtual, tens of KB committed) and kernel-mode context switches; a million of them is gigabytes of stack and scheduler meltdown. So Go put the scheduler in userspace: goroutines.

Step 2 — goroutine stacks must start tiny, so they must grow. A million goroutines cannot each get a big fixed stack — same math, one level down. Go starts a goroutine at 2 KB (since Go 1.4; 8 KB before) — since Go 1.19 adaptive, from historical average stack use, still single-digit KB. Deep recursion overflows any fixed small stack. Pre-1.3 Go chained 4 KB segments; Go 1.3 switched to contiguous stacks: allocate a 2x stack, copy the old one in, adjust every pointer into it.

Step 3 — copying stacks breaks the C memory model. In C (ch08), &local is stable because the stack never moves. In Go, a stack can be copied at any call that finds SP below the guard, so a stack address is valid only while its frame lives and no growth happened. Go refuses to make you track that: &x is legal, pointers cross channels, closures capture locals by reference. Values whose addresses might outlive a frame must live somewhere that never moves — the heap — and escape analysis is the gatekeeper for that line.

Step 4 — a shared heap needs automatic reclamation. Pointers cross goroutines freely, so no static rule says who frees what: manual free is ch08's use-after-free at program scale, and refcounting (ch14) costs an update per assignment and leaks cycles. What remains is a tracing collector. The chain: goroutines forced tiny stacks, tiny stacks forced copying growth, copying forced a stable-address heap, a shared heap forced a GC.

Step 5 — latency over throughput. Go 1.8 (2017) drew the line: stop-the-world pauses dropped to typically under 100 µs, often ~10 µs, via GC pacing (redesigned 1.18): trigger early enough to finish marking on ~25% of one GOMAXPROCS of CPU. The bill: more GC CPU and a bigger peak heap than a compacting collector — the opposite trade of the JVM (ch12). Go also never compacts; we justify that below.

Escape analysis: the load-bearing wall

Every local has a candidate home: its frame (ch04) — free, cache-hot, gone at RET — or the heap (ch05) — allocated, GC-tracked, address-stable. The compiler picks per-value at build time by simulating how addresses flow. Watch it decide:

package escape

type Point struct{ X, Y int }

// stay: borrows a pointer, touches it, returns an int.
func stay(p *Point) int {
	p.X++
	return p.X
}

// leak: the address of a local leaves the function.
func leak() *Point {
	p := Point{X: 1, Y: 2}
	return &p
}

// box: 16 bytes cannot fit an interface's single data word.
func box() interface{} {
	p := Point{X: 1, Y: 2}
	return p
}

// counter: the closure outlives the frame and mutates n.
func counter() func() int {
	n := 0
	return func() int { n++; return n }
}

go build -gcflags='-m' . on this file (Go 1.21; wording drifts slightly across versions, the verbs do not):

./escape.go:6:10: p does not escape
./escape.go:13:2: moved to heap: p
./escape.go:19:2: p escapes to heap
./escape.go:25:2: moved to heap: n
./escape.go:26:9: func literal escapes to heap

The verdicts:

Rules of thumb:

  1. Returning a pointer — or storing an address into anything that outlives the frame (global, map, heap object, channel send) — escapes.
  2. Interface boxing often escapes: a value moved into an escaping interface is copied to the heap; the data word must be a stable pointer.
  3. Closures capturing by reference escape: mutation forces capture-by-pointer, and an escaping closure drags its captures along.

The GC only manages what reaches the heap — escape analysis is what keeps most of your &x off it, which is why idiomatic Go profiles are quiet. -m -m prints the flow chains behind each verdict; the labs find the exceptions in minutes.

Values, fat pointers, and the three-word slice

Assignment copies. a := b copies every byte of b — a 16-byte struct copies 16 bytes; no implicit &, no pointer arithmetic (&x is a real pointer, ch08, but p+1 is a compile error). The "reference feel" of slices, maps, and channels comes from the copied bytes containing pointers.

An interface value is a 2-word fat pointer — ch06's tagged-union/vtable trick, and Rust's &dyn Trait (ch10):

 var i Shape = p            (p is a 16-byte Point)
 i (16 bytes)
 +----------------+----------------+
 | itab ptr (8 B) | data ptr (8 B) |    <- the fat pointer
 +----------------+----------------+
        |                       |
        v                       v
   itab: type +           heap copy of p
   method table,          +--------+--------+
   size, GC bitmap        | X (8B) | Y (8B) |
                          +--------+--------+

The data word is always one pointer. A pointer value is stored directly; a multi-word struct like p must be copied somewhere stable first — the heap, because the compiler cannot prove the original stack slot outlives the interface: Go has no borrow checker (that was ch10's path). That is why box() prints p escapes to heap. "Usually" heap: pointer-shaped values skip the copy; a few tiny scalars hit static runtime tables (insiders below).

A slice is a 3-word header over a separate array — a length-tagged pointer with a twist (ch06):

 s := make([]byte, 4, 8)
 slice value, 24 bytes                   backing array (heap, 8 B)
 +----------+---------+---------+       0   1   2   3   4   5   6   7
 | ptr      | len = 4 | cap = 8 │ ──►  [a] [b] [c] [d] [ ] [ ] [ ] [ ]
 +----------+---------+---------+
   8 B each            append writes in here (len < cap) without allocating

Two slice values sharing one backing array is the aliasing trap. append writes in place whenever len < cap and only reallocates on overflow:

func aliasBug() {
	base := []int{0, 1, 2, 3, 4, 5, 6, 7} // one backing array A, cap 8

	tail := base[4:6] // {&A[4], 2, 4}: tail "owns" nothing

	mid := base[2:5] // {&A[2], 3, 6}: cap 6, A[2..7] writable
	mid = append(mid, 99) // len 3 < cap 6: writes A[5] IN PLACE

	fmt.Println(base) // [0 1 2 3 4 99 6 7] -- 5 silently became 99
	fmt.Println(tail) // [4 99]             -- the "unrelated" view changed

	mid = append(mid, 100, 200, 300, 400) // needs 7 > cap 6: NEW array,
	// copied, detached -- base and tail never move again
}

Diagnosis: the write landed at ptr + len = &A[5], which base and tail both observe. Fix: the three-index slice base[2:5:5] (cap forced to 3), which makes the next append copy — zero headroom, like handing out &[T] in Rust. Growth on reallocation: capacity doubles while small, then ~1.25x at large sizes — doubling below 1024 elements through Go 1.17; since Go 1.18 the knee is 256 elements with a smoother formula (~1.25–1.3x), rounded to a size class, so observed ratios jump.

Strings are immutable (ptr, len) — 16 bytes. Immutability makes them safe to alias and forces []byte(s) and string(b) to copy the payload each way — in a loop, those copies are the allocation profile. Two conversions are compiler-special-cased to skip the copy: m[string(b)] and range string(b). The zero-cast, when the bytes provably won't change, borrows instead:

b := []byte("hello, world")
s := unsafe.String(unsafe.SliceData(b), len(b)) // Go 1.20+: no copy
// pre-1.20 folklore: *(*string)(unsafe.Pointer(&b)) -- same aliasing
b[0] = 'H' // s[0] now reads 'H': the immutability rule is your problem

A real alias, like the slice views above — ch08's world of invariants you maintain personally.

Stacks that move

   BEFORE (2 KB)                       AFTER copy to 4 KB

hi +----------------+              hi +----------------+
   | main() frame   |                 | main() frame   |
   |  p = &x        |                 |  p = &x'       |  same offset from
   +----------------+                 +----------------+  the stack base,
   | h() frame      |                 | h() frame      |  new absolute
   |  x = 7         |                 |  x = 7         |  address
   +----------------+ SP              +----------------+ SP
   | frame F        |                 | frame F        |
   +----------------+                 +----------------+
   | stackguard     | SP hits guard   | 2 KB of fresh  |
   |                | -> morestack()  | headroom ...   |
lo +----------------+                 | stackguard     |
                                     lo +----------------+
   old 2 KB block freed; every pointer into it (like p) is
   rewritten to the same offset in the new stack

The runtime can fix p because the compiler ships stack maps — per-safe-point metadata marking which stack words hold pointers. C never had this: ch04/ch07 frames are fixed once the prologue runs.

The precise escape connection: a stack slot's address is a temporary identity — invalid after the frame returns and after any growth. Whenever the compiler cannot prove an address dies with the frame — returned, boxed, captured by an escaping closure, shipped to another goroutine — the pointed-to value must be heap-allocated. That is the whole rule, with a cost signature: a pointer to a stack slot prevents escape, pushing the object into the garbage stream. &x on a 4-byte int32 passed to another goroutine trades 4 stack bytes for a heap object plus GC tracking — such small escapes are "allocation noise".

The collector: tricolor, barriers, and never compacting

Reclamation is a concurrent tricolor mark-and-sweep: every object wears a color.

            ROOTS (goroutine stacks, globals, registers)
                              |
        +---------------------+---------------------+
        |                                           |
   +----v----+     edge      +-------+    edge  +---+----+
   |  BLACK  | -----------> | GREY  | --------> | WHITE |
   +---------+              +-------+           +--------+

   BLACK = scanned, known live, done
   GREY  = known live, waiting to be scanned (the worklist / frontier)
   WHITE = presumed dead until proven otherwise

   mark step: pop a GREY object -> make it BLACK -> GREY every WHITE
              object it points to
   sweep:     worklist empty => remaining WHITE objects were unreachable
              -> their memory is reclaimed

The invariant that makes concurrency safe: a black object must never point directly at a white one — otherwise marking could "finish" while a live object hides behind a scanned object mutated mid-cycle. Mutators keep mutating, so Go installs a write barrier (Go 1.8+): on every heap pointer store, shade both the old value (Yuasa's deletion barrier — anything overwritten may be the last reference the start-of-cycle snapshot saw) and the new value (Dijkstra's insertion barrier — anything newly published is reachable). The hybrid lets goroutine stacks stay black all cycle: pre-1.8 Go re-scanned every stack inside a stop-the-world phase — the multi-millisecond pause; with the hybrid, the two remaining STW phases (sweep termination, mark termination) are each typically tens of microseconds.

The cycle: sweep termination (STW #1); concurrent mark — ~25% CPU on dedicated workers plus mark assist: allocating mutators get drafted, because allocation is the tax base; mark termination (STW #2); concurrent sweep. Nothing is ever moved: no compaction, ever.

Why Go survives that, when the JVM (ch12) treats compaction as essential: Java bump-allocates inside compacted regions and would fragment to death. Go's allocator descends from tcmalloc (ch19): ~68 size classes up to 32 KiB, so any object is interchangeable with any free slot of its class and fragmentation is structurally bounded:

 [P0] mcache --\       per-P, lock-free: the hot path
 [P1] mcache ---\     miss on size class k
 [P2] mcache ----+-----------------> mcentral[k]  (per class)
 [..]            /                        |  span out of free slots?
                                          v
              mheap: page allocator over 64 MB arenas (64-bit)
              objects > 32 KiB ("large"): skip mcache/mcentral,
              dedicated span straight from mheap

Spans (8 KB pages grouped by class) are freed only when fully empty; the scavenger returns empty pages to the OS via madvise (MADV_DONTNEED default on Linux since Go 1.16). The price of never moving: fragmentation rattles inside spans, and RSS can sit far above live heap. The payoff: a write barrier suffices (moving collectors need read barriers), cgo and unsafe pointer arithmetic stay valid, and there is no "pinned object" concept to poison a compactor. Go chose address stability — the property step 3 demanded.

Pacing knobs, pools, and escape hatches

GOGC (default 100) is the heap-growth dial: the next GC triggers when the heap reaches live + (live + stacks + globals) * GOGC/100 — at 100, roughly double the live heap; GOGC=50 reclaims at 1.5x live. GOMEMLIMIT (Go 1.19+) is the second knob: a soft ceiling on runtime-managed memory (heap, stacks, runtime structures — not cgo's C malloc). Near the limit the collector runs earlier and the scavenger works harder; failing that, GC CPU is capped at ~50% to avoid a death spiral — the limit can be exceeded rather than stall the program, unlike the JVM's hard -Xmx (ch12). Container recipe: GOGC=off GOMEMLIMIT=....

Watch pacing with GODEBUG=gctrace=1. One real-format line (Go 1.21), field by field:

gc 12 @0.483s 1%: 0.031+0.61+0.023 ms clock, 0.25+0.32/1.2/0.062+0.18 ms cpu, 7->8->4 MB, 8 MB goal, 0 MB stacks, 1 MB globals, 8 P

sync.Pool is GC-aware scratch on per-P local caches. It exists because the amortization pattern below is otherwise unwritable in a GC language — a global free list would be a leak, and per-request allocation is the noise you fight:

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

func render(w io.Writer, rows []Row) {
	buf := bufPool.Get().(*bytes.Buffer) // reuse, or New() on a miss
	defer func() {
		buf.Reset()  // required: you get back what you put in
		bufPool.Put(buf)
	}()
	for _, r := range rows {
		fmt.Fprintf(buf, "%v\n", r)
	}
	w.Write(buf.Bytes())
}

Contract: a pool is a cache, not an owner. Every GC cycle drops its contents (the victim cache added in Go 1.13 lets one generation survive, softening post-GC misses); never Put an object you still depend on — a GC may legally take it.

Off-heap escapes. unsafe puts you in charge of aliasing, but the GC still sees unsafe.Pointer values and keeps their targets alive. cgo memory (C.malloc) is invisible to the collector — Go's pointer-passing rules let C hold a Go pointer only for the duration of a call, because the GC will not trace or pin it. Manual arenas had a moment: proposal golang/go #51317 shipped as GOEXPERIMENT=arenas in Go 1.20 and 1.21 — a narrow arena.New API with compiler use-after-free detection. Honest status: shelved by the Go team in early 2024 (proving safety for arbitrary types cost more than the win); as of Go 1.23 the sanctioned pattern is chunked []T slabs with 32-bit index handles, or a sync.Pool per type.

What insiders know

See it yourself

Lab 1 — escape analysis on one file. Save the escape.go from earlier plus a main that calls all four functions, then:

$ touch escape.go                                # defeat the build cache
$ go build -gcflags='-m' . 2>&1 | head -20
$ go build -gcflags='-m -m' . 2>&1 | grep -A2 'moved to heap'

Notice which declarations stay silent (the stack verdicts) and that -m -m shows the flow: chains — the compiler's proof. Then change return &p to return &Point{X: 1, Y: 2} and watch the message change shape: a literal escaping instead of a variable moving.

Lab 2 — the pacer in your terminal.

// gcwatch.go
package main

import (
	"fmt"
	"runtime"
)

func main() {
	var sink []*byte
	for i := 0; i < 3<<20; i++ {
		b := make([]byte, 64) // 64 B of garbage per iteration
		if i%1024 == 0 {
			sink = append(sink, &b[0]) // keep ~1/1024 alive
		}
		if i%(1<<20) == 0 {
			var m runtime.MemStats
			runtime.ReadMemStats(&m) // brief STW; fine once per million
			fmt.Printf("HeapAlloc=%dMB NextGC=%dMB NumGC=%d TotalAlloc=%dMB\n",
				m.HeapAlloc>>20, m.NextGC>>20, m.NumGC, m.TotalAlloc>>20)
		}
	}
}
$ GODEBUG=gctrace=1 go run gcwatch.go
gc 1 @0.019s 2%: 0.028+0.61+0.015 ms clock, 0.22+0.35/1.5/0.08+0.14 ms cpu, 4->5->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 P

Watch HeapAlloc climb toward NextGC, NumGC tick over at each crossing, and TotalAlloc dwarf HeapAlloc (cumulative garbage vs live). Rerun with GOGC=50 GODEBUG=gctrace=1 go run gcwatch.go: goals shrink, cycles multiply. Also worth printing: HeapObjects, PauseNs (the last 256 STW pauses), PauseTotalNs.

Lab 3 — find garbage producers with pprof.

$ go test -run=xxx -bench=. -memprofile=mem.out ./...
$ go tool pprof -sample_index=alloc_space mem.out
(pprof) top10      # who allocated the most, ever -- the GC's workload
(pprof) list render

alloc_space (all allocation, dead or alive) finds GC pressure; inuse_space (the default) finds what is live now. Sampling is 512 KiB per sample (runtime.MemProfileRate), so numbers are estimates. With a server: import _ "net/http/pprof", then go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap.

How to use this knowledge

Connections

Forward links — where the rest of the program picks this chapter up:

Quiz

  1. (concept) A colleague: "Go has a GC because Google engineers didn't trust programmers with free()." Derive the deeper chain this chapter argues, from goroutine design to GC, in four steps.
  1. (concept) Go 1.8's write barrier shades two values per heap pointer store. Name both, name the two classic barriers they combine, and state the pause-related property the combination buys.
  1. (predict) For each function: where does v/z live (stack or heap), and which -gcflags='-m' line appears?
   func a(v Vec) float64   { return v.X + v.Y }                            // (a)
   func b() *Vec           { v := Vec{1, 2, 3}; return &v }                // (b)
   func c() interface{}    { v := Vec{1, 2, 3}; return v }                 // (c)
   func d() func() float64 { z := 0.0; return func() float64 { z++; return z } } // (d)
  1. (predict) A gctrace line ends 7->8->4 MB, 8 MB goal. The program reruns with GOGC=50 (same live heap; ignore stacks/globals). What is the next goal, and what does GOGC=50 trade?
  1. (predict) What does this print, and what are len/cap of mid at the end?
   base := []int{0, 1, 2, 3, 4, 5, 6, 7}
   mid := base[2:5]
   mid = append(mid, 99)
   mid = append(mid, 100, 200, 300, 400)
   fmt.Println(base, mid)
  1. (bug-diagnosis) A teammate swears the function below "only writes to preview", yet data[10] becomes -1 in production. Explain the exact write the append performs, and give the one-token fix.
   data := make([]int, 1000, 1000)
   // ... 1000 rows filled ...
   preview := data[:10]
   preview = append(preview, -1)
  1. (bug-diagnosis) On Go 1.21 this prints 3 3 3; on Go 1.22 it prints a permutation of 0 1 2. Explain both behaviors in terms of what the closure captures, and what -gcflags='-m' reveals.
   for i := 0; i < 3; i++ {
       go func() { fmt.Println(i) }()
   }
  1. (cross-chapter) Rust's &dyn Trait and Go's interface value are both 2-word fat pointers (ch06, ch10). Explain why assigning a 24-byte struct to a Go interface copies it to the heap, while coercing &my_struct to &dyn MyTrait in Rust points directly at the (possibly stack-allocated) struct.
Answers
  1. The chain, not the trust story. (1) Millions of concurrent flows are impossible with OS threads — MBs of stack address space, tens of KB committed each — so goroutines with KB-scale stacks. (2) A fixed 2 KB stack can't hold arbitrary recursion, so stacks grow by copying (Go 1.3+), invalidating every address into them. (3) &x and cross-goroutine pointers must stay ergonomic, so escaped values need stable addresses: the heap, gated by escape analysis. (4) Free-flowing pointers have no static owner — manual free is use-after-free at scale (ch08), refcounting costs per assignment and leaks cycles (ch14) — so a tracing GC is forced.
  1. It shades the old (overwritten) value — Yuasa's deletion barrier, snapshot semantics — and the new value — Dijkstra's insertion barrier. Together, anything reachable at cycle start or published mid-cycle is shaded grey, so a mutator can never hide a live object behind a black one. Payoff: stacks are never re-scanned at mark termination — pre-1.8 Go's stack-rescan STW was the multi-millisecond pause; since 1.8 the two STW phases are tens of microseconds.
  1. (a) Stack, and silence — a value parameter, no address taken. (b) Heap: moved to heap: v (the address flows to the return). (c) Heap: v escapes to heap — 24 bytes don't fit the interface's single data word, so the value is copied there. (d) Heap: moved to heap: z plus func literal escapes to heap — the returned closure mutates z, forcing capture by reference.
  1. Goal = live + live*(GOGC/100) = 4 + 2 = 6 MB. GOGC=50 trades memory for GC frequency: the heap only grows to 1.5x live between cycles — more CPU per byte allocated, smaller peak — the knob for memory-tight containers, and the one GOMEMLIMIT (1.19+) supersedes.
  1. First append: mid = {&A[2], 4, 6}; 99 written in place at &A[5], so base = [0 1 2 3 4 99 6 7]. The second needs 8 > cap 6, so it allocates a fresh array — capacity doubles 6 → 12 (small slices double; the 256/1.25x knee is Go 1.18+) — copies [2 3 4 99], appends the rest: mid = [2 3 4 99 100 200 300 400], len = 8, cap = 12, now detached from base.
  1. preview is {&data[0], 10, 1000} — cap is the distance to the backing array's end, not the view's end. With len < cap, append writes in place at ptr + len = &data[10] — no new array, no error. Fix: the three-index slice data[:10:10] (cap = 10) forces a copy on the next append. Rule: cap, not len, bounds append's blast radius.
  1. Go ≤ 1.21: one i for the whole loop; the closure captures it by reference (mutated by i++) and the go statement makes it escape — -m shows moved to heap: i and func literal escapes to heap. All three goroutines watch the same memory cell; the loop usually finishes first, so each reads the final value 3. Go 1.22 gave each iteration a fresh i, so each closure captures its own copy — 0, 1, 2 in scheduler order. The fix was literally a per-iteration allocation.
  1. Both data words must be a stable pointer to the value. Rust's borrow checker proves the borrow outlives the trait object's use, so the data word can point at the struct wherever it lives — stack included. Go has no lifetime proof: the interface may outlive its frame, that stack may be copied by growth at any moment, and Go refuses to forbid var s Shape = localStruct — so the compiler copies the struct to the heap, the one place whose address never changes. Same 2-word layout; the difference is who proves the lifetime: the type system (ch10) or the collector.