Day 156 — Escape analysis & allocations¶
Month 6 · Week 3 · ⬅ Day 155 · Day 157 ➡ · Journal index
🎯 Learning Objective¶
Understand how Go's escape analysis decides stack vs heap, read the compiler's reasoning with -gcflags=-m, and measure allocations with benchmarks and testing.AllocsPerRun.
📚 Topics¶
- Stack (free, frame-scoped) vs heap (allocation + GC cost)
- What makes a value escape; reading
go build -gcflags='-m' - Measuring:
go test -bench -benchmem,b.ReportAllocs(),testing.AllocsPerRun
📖 Reading / Sources¶
- Go FAQ — Stack or heap allocation?
-
go build-gcflagsreference -
testing.AllocsPerRun·testing.B.ReportAllocs - Effective Go — allocation discussion
📝 Notes¶
- Go has no
stack/heapkeyword: the compiler decides per value. If it can prove the value's lifetime ends with the function, it stays on the stack (reclaimed for free on return). If it might outlive the frame, it escapes to the heap (one allocation + future GC work) → [[escape-analysis]]. - "Escapes" ≠ "uses a pointer." A pointer to a local is fine on the stack as long as it doesn't leave the frame. Common escape triggers:
- returning a pointer to a local;
- storing a pointer in a heap object / slice / map / channel;
- passing a value to
interface{}/anythe callee may retain — includingfmt.Println(x), whose...anymakesxescape; - capturing a variable by reference in a closure that itself escapes.
- See the decision:
go build -gcflags='-m' ./pkgprints lines like&T{} escapes to heapor... does not escape. Add-m -mfor more detail.-gcflags='-l'disables inlining if it's confusing the output. - Inlining interacts with escape: a small function inlined into its caller can change whether its locals escape. That's why micro-benchmarks must reflect realistic call sites.
- Measure, don't guess:
go test -bench=. -benchmemreportsB/opandallocs/op; inside a benchmark callb.ReportAllocs(). For a quick check outside benchmarks,testing.AllocsPerRun(n, f)returns average mallocs per call (runsfonce to warm up first). - Reduce allocations by (1) appending into a caller buffer (
strconv.AppendInt,Time.AppendFormat) instead of returning fresh strings; (2) pre-sizing slices/maps withmake([]T, 0, n); (3) passing large structs by pointer only when it helps — small structs are often cheaper by value; (4) avoiding needlessanyboxing. - The optimizer can delete an allocation whose result is never observed, so a benchmark must consume the result (assign to a package-level sink) or the measurement lies → [[dead-code-elimination]].
💻 Code Examples¶
// Returning the pointer forces the heap; keeping it local stays on the stack.
func newPoint() *Point { return &Point{1, 2} } // -m: "escapes to heap"
func sumLocal() int {
p := &Point{1, 2} // -m: "does not escape" → stack
return p.X + p.Y
}
// Measure without writing a full benchmark:
allocs := testing.AllocsPerRun(1000, func() { sink = newPoint() })
Full code:
examples/month-06/escape/main.go· Run:go run ./examples/month-06/escape· See the proof:go build -gcflags='-m' ./examples/month-06/escape
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
Stack vs heap, measured with AllocsPerRun |
✅ | examples/month-06/escape |
Zero-alloc append helpers (AppendCSV/AppendInt) |
✅ | exercises/month-06/week-3/allocfree |
🐛 Mistakes Made¶
- Benchmarked a function whose result I discarded; the compiler optimized the allocation away and I "proved" zero allocs falsely. Added a sink.
- Assumed "pointer = heap." A
*Pointthat never leaves the function stayed on the stack —-mconfirmed "does not escape."
❓ Open Questions¶
- When does passing a big struct by value beat by pointer (avoids the escape but copies)? Profile-dependent — measure both.
🧠 Active Recall (answer without looking)¶
- Q: Why does
return &localallocate on the heap but&localused only inside the function does not?A
Returning the pointer means the pointed-to value must survive after the frame is gone, so the compiler moves it to the heap. If the pointer never leaves the frame, escape analysis proves the value dies with the function and keeps it on the stack — no allocation.
2. Q: Why can a benchmark report 0 allocs/op for code that clearly allocates? A
If the result is never used, dead-code elimination removes the allocation entirely. Consume the value (store it in a package-level sink, or pass it to something opaque) so the optimizer can't delete the work.
🪶 Feynman Reflection¶
The stack is a stack of plates that gets cleared the instant a function returns — anything sitting on it vanishes for free. If a value needs to outlive that moment (someone keeps a reference to it), the compiler can't put it on a plate that's about to be cleared, so it rents heap space the GC must later reclaim. Escape analysis is the compiler asking, per value, "does anyone keep this after we leave?"
🕳️ Knowledge Gaps¶
- Reading
-m -moutput fluently on real code with inlining noise. - Interface boxing costs and when devirtualization kicks in.
✅ Summary¶
I can predict and verify stack vs heap with -gcflags=-m, measure allocations with -benchmem/AllocsPerRun, and cut churn using the append-into-buffer pattern — guarding measurements against dead-code elimination.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 157: when reuse beats elimination —
sync.Poolfor unavoidable hot-path allocations.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦🟦⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: docs(journal): escape analysis & allocations (day 156)