How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?
Escape analysis moves a value to the heap when the compiler cannot prove its lifetime ends with the frame that created it, and go build -gcflags=-m prints every decision. Which of those allocations matter is a measurement question, answered with a benchmark, an alloc_space profile and benchstat.
What the interviewer is scoring
- Does the candidate frame escape analysis as a proof about lifetime rather than a rule about pointers
- Whether they know how to ask the compiler what it decided, and can read the output it prints
- That interface conversion is named as an escape cause, not just returning a pointer
- Whether the workflow starts with measurement and ends with a comparison, rather than a list of idioms
- Does the candidate distinguish alloc_space from inuse_space and say which question each answers
Answer
What escape analysis is deciding
Go has no new versus stack distinction in the source. You write &Thing{} and the
compiler decides where it lives, and the decision rule is a proof obligation: if the
compiler can prove that no reference to the value survives the function that created
it, the value goes in that function's stack frame and costs nothing to reclaim. If it
cannot prove that, the value goes on the heap, where the garbage collector becomes
responsible for it.
Two things follow that people get backwards. Taking a pointer does not force a heap
allocation — p := &Thing{} used only locally stays on the stack quite happily. And not
taking a pointer does not guarantee a stack allocation, because a value can be copied
into something that escapes, or converted to an interface. The stack itself is not fixed
either: a goroutine starts small and the runtime grows it by copying, so "it might not
fit" is rarely the reason something escapes. The reasons are almost always about
lifetime.
Ask the compiler rather than guessing
The analysis is not a mystery you reason about from first principles; it prints its conclusions.
go build -gcflags='-m' ./... # one line per decision
go build -gcflags='-m -m' ./... # plus why, including inlining reasoning
package geo
import "fmt"
type point struct{ x, y int }
func sum() int {
p := point{1, 2} // stays in the frame: no reference outlives sum
return p.x + p.y
}
func origin() *point {
p := point{0, 0} // "moved to heap: p" — the pointer is returned
return &p
}
func show(n int) {
fmt.Println(n) // "n escapes to heap" — converted to any for a variadic ...any
}
Build that and the compiler tells you moved to heap: p for origin and
n escapes to heap for show, and says nothing about sum. Being able to run this
in an interview, or describe its output precisely, is the difference between having
read about escape analysis and having used it.
show is the instructive case. Nothing about n looks like it leaves the function.
But fmt.Println takes ...any, so n is converted into an interface value, the
compiler cannot see through fmt's reflection to prove the interface does not outlive
the call, and an int therefore becomes a heap allocation. Every interface conversion
is a candidate escape for exactly this reason, which is why hot paths in Go tend to
avoid any in their signatures and why structured loggers that take typed fields
allocate less than ones taking ...any.
The other systematic causes: storing a pointer into something already on the heap (including a slice or map that escapes), capture by a closure that itself escapes, sending a pointer on a channel, allocations whose size is not a compile-time constant, and objects the compiler judges too large to place in a frame.
Removing allocations, in order of payoff
The highest-value fix is almost always preallocation, because it removes a sequence of
allocations rather than one. make([]T, 0, n) when you know n, strings.Builder
with Grow, and a map with a size hint each convert repeated grow-and-copy into a
single allocation.
Next is eliminating the formatting and conversion layer, which is where most surprising allocation volume lives.
package cache
import (
"fmt"
"strconv"
)
// Two costs: the returned string, and boxing both arguments into ...any.
func keyFmt(userID int, kind string) string {
return fmt.Sprintf("u:%d:%s", userID, kind)
}
// No interface conversion and no intermediate string. The caller supplies the
// buffer and reuses it, so the steady-state cost is zero allocations.
func appendKey(buf []byte, userID int, kind string) []byte {
buf = append(buf, "u:"...)
buf = strconv.AppendInt(buf, int64(userID), 10)
buf = append(buf, ':')
return append(buf, kind...)
}
Then buffer reuse. sync.Pool is the standard tool and easy to misuse: it pays only for
buffers large enough that the allocation is meaningful, it requires you to reset the
object before returning it, and pooled entries can be dropped at any garbage collection,
so it is a cache rather than a free list. Last, avoid []byte to string conversions
mid-pipeline — the compiler elides some of them, such as a string(b) used directly as
a map key, but one whose result is stored allocates.
A profiling workflow that finds the real cost
None of the above should be applied by reading code. This is the workflow to describe, because it is what an interviewer is checking for when they ask the question at all.
Start with a benchmark that resembles the work. -benchmem gives you B/op and
allocs/op, which is the number you will report at the end.
go test -run='^$' -bench=BenchmarkHandle -benchmem -count=10 \
-cpuprofile=cpu.out -memprofile=mem.out ./internal/handler
Read the allocation profile by total bytes allocated, not by what is live.
go tool pprof -http=:8080 -sample_index=alloc_space mem.out
alloc_space and alloc_objects count everything ever allocated during the run,
which is what drives garbage-collector work. inuse_space and inuse_objects count
what was still live when the profile was taken, which is what you want when hunting a
leak. Asking the wrong one of those two questions is the most common way to waste an
afternoon. Note also that heap profiling samples — roughly one allocation per 512 KB
by default — so add -memprofilerate=1 when you need exact counts from a small
benchmark.
Confirm the cost in the CPU profile. Garbage-collection work shows up as
runtime.mallocgc, runtime.gcDrain, runtime.gcBgMarkWorker and
runtime.gcAssistAlloc. That last one is the important signal: assist time means your
own goroutines were conscripted into marking because they were allocating faster than
the background collector could keep up. If gcAssistAlloc is invisible in the
profile, allocation reduction is not your bottleneck.
Fix one thing, then compare properly. Re-run the benchmark with -count=10 into a
second file and use benchstat to say whether the difference is real rather than
noise. A single -count=1 before-and-after pair is not evidence.
go test -run='^$' -bench=BenchmarkHandle -benchmem -count=10 ./... > new.txt
benchstat old.txt new.txt
For a live service, profile the live service. Import net/http/pprof on an
internal port and pull profiles from it: /debug/pprof/allocs for the
cumulative allocation profile, /debug/pprof/heap for what is live,
/debug/pprof/profile?seconds=30 for CPU, /debug/pprof/goroutine?debug=1 when you
suspect a leak. Two heap profiles an hour apart, compared with
go tool pprof -base first.pb.gz second.pb.gz, is the standard way to find growth.
GODEBUG=gctrace=1 prints a line per collection cycle if you want to see frequency
before you go further, and go tool trace is the tool when the question is scheduler
latency rather than allocation volume.
Why the top of the profile is often the wrong thing to fix
Two mistakes account for most wasted optimisation effort in Go. The first is optimising
a service that is not allocation-bound: a handler spending its wall-clock time waiting
on Postgres will not get faster because you removed a Sprintf, however prominent that
Sprintf is in the allocation profile. Allocation profiles rank by allocation, and
allocation is not latency.
The second is optimising a benchmark instead of a workload. A microbenchmark with one
warm input and no concurrency lets the compiler and the branch predictor behave in ways
production never will, and it is entirely possible to halve allocs/op while making the
real service slower — a sync.Pool under contention across many cores is the classic
example. Use inputs that match real distributions, and verify the win against tail
latency rather than throughput.
Treat the escape-analysis output the same way: information, not a task list. A value escaping in a function called once at startup does not matter at all; the same escape inside a loop running a million times per second is the whole problem.
Escape analysis answers "where does this live", and it will tell you if you ask with
-gcflags=-m. It cannot tell you which of its decisions is expensive — that comes from a benchmark, analloc_spaceprofile, and abenchstatrun proving the change did something.
Likely follow-ups
- Why does passing a value to fmt.Println move it to the heap when passing it to your own function does not?
- When does sync.Pool make things worse?
- You reduced allocs/op by half and p99 latency did not move. What do you conclude?
- How would you diff two heap profiles taken an hour apart on a running service?
Related questions
- Memory keeps climbing in a Go service under load. How do you find out why, and what can you actually tune?hardAlso on garbage-collection and pprof5 min
- A long-running Python service keeps climbing in memory and never comes back down. How do you work out where the memory went?hardAlso on garbage-collection and profiling5 min
- How do goroutines leak, and how would you find a leak in a running service?mediumAlso on pprof5 min
- A service dies with OutOfMemoryError in production. Walk me through diagnosing it.hardAlso on garbage-collection5 min
- How does the .NET garbage collector decide what to collect, and how would you make a hot path allocate less?hardAlso on garbage-collection6 min
- A hot path allocates heavily and garbage collection is showing up in your profile. What does Span give you that a substring does not?hardAlso on garbage-collection4 min
- What happens when you append to a slice, and when do two slices stop sharing memory?mediumAlso on garbage-collection5 min
- Your p99 latency jumped tenfold after a deploy while p50 is unchanged. Diagnose it out loud.hardAlso on garbage-collection6 min