Escape Analysis in Go: Why Your Local Variable Escaped
Go's escape analysis builds a weighted graph of variables and assignments, then solves it to decide which locals can live on the stack and which must be heap-allocated.
TL;DR: The Go compiler refuses to put a stack pointer in the heap, so every variable that could outlive its frame has to be heap-allocated. Escape analysis works out which ones those are by building a weighted graph of variables and assignments, walking it looking for paths where an address outlives its owner, and stamping each variable
EscNone(stack) orEscHeap(heap). A closure that captures a variable by reference is enough to do it.
Everyone has seen the message:
./main.go:14:14: variable x escapes to heapIt shows up when you were expecting a stack allocation and got a heap one instead. It is also, annoyingly, the first thing you check when a benchmark shows 40 nanoseconds of allocator overhead per iteration.
The interesting part is what the compiler is actually doing when it prints that. It is not “I saw a pointer, so I gave up”. The Go escape analysis is a real data-flow analysis, and understanding it tells you exactly how to write code that stays on the stack.
This is a walk through how it works, based on reading the Go 1.28
development tree (src/cmd/compile/internal/escape/).
The two invariants
Everything in the escape analyzer is in service of exactly two rules. From
the top of escape.go:
// src/cmd/compile/internal/escape/escape.go:23
// Escape analysis.
//
// Here we analyze functions to determine which Go variables
// (including implicit allocations such as calls to "new" or "make",
// composite literals, etc.) can be allocated on the stack. The two
// key invariants we have to ensure are: (1) pointers to stack objects
// cannot be stored in the heap, and (2) pointers to a stack object
// cannot outlive that object (e.g., because the declaring function
// returned and destroyed the object's stack frame, or its space is
// reused across loop iterations for logically distinct variables).
//
// We implement this with a static data-flow analysis of the AST.
// First, we construct a directed weighted graph where vertices
// (termed "locations") represent variables allocated by statements
// and expressions, and edges represent assignments between variables
// (with weights representing addressing/dereference counts).
//
// Next we walk the graph looking for assignment paths that might
// violate the invariants stated above. If a variable v's address is
// stored in the heap or elsewhere that may outlive it, then v is
// marked as requiring heap allocation.
//
// To support interprocedural analysis, we also record data-flow from
// each function's parameters to the heap and to its result
// parameters. This information is summarized as "parameter tags",
// which are used at static call sites to improve escape analysis of
// function arguments.Read that third paragraph carefully, because it is the whole design. It is a directed weighted graph, where:
- Vertices are locations — one per variable declaration, and one per
allocating expression (
new,make, a composite literal, a closure). - Edges are assignments, and the weight is a dereference count.
The weight is the trick. Every assignment in Go is one of four shapes, and the analyzer records the difference between address-of operations and dereference operations:
p = &q // -1
p = q // 0
p = *q // 1
p = **q // 2A negative weight means “an address flows along this edge”. A positive weight means “the pointed-to value flows along this edge”. The escape analysis is then a search for a path with a negative weight that reaches the heap — because a negative-weight path to the heap means “somebody stored the address of this variable into memory the GC can reach”, which is invariant (1) violated.
The whole graph, drawn:
[heap] (attrEscapes)
^
| edge, derefs = -1 (the address flows here)
|
+-------+-------+
| |
[loc x] [loc y] <- locations (vertices)
| |
| edge derefs=0 | edge derefs=1 (the value flows here)
v v
[loc p] [loc *q]
loop depth and closure nesting decide whether the root
outlives the source locationLocations, holes, and edges
The graph has three node types. First the location, one per variable:
// src/cmd/compile/internal/escape/graph.go:41
// A location represents an abstract location that stores a Go
// variable.
type location struct {
n ir.Node // represented variable or expression, if any
curfn *ir.Func // enclosing function
edges []edge // incoming edges
loopDepth int // loopDepth at declaration
// resultIndex records the tuple index (starting at 1) for
// PPARAMOUT variables within their function's result type.
// For non-PPARAMOUT variables it's 0.
resultIndex int
// derefs and walkgen are used during walk to track the
// minimal dereferences from the walk root.
derefs int // >= -1
walkgen uint32
// queuedWalkAll is used by walkAll to track whether this location is
// in its work queue.
queuedWalkAll bool
// queuedWalk is used by walk to track whether this location is
// in its work queue. The value is the walkgen when this location was
// last queued for walk, or 0 if it's not currently queued.
queuedWalk uint32
// attrs is a bitset of location attributes.
attrs locAttr
// paramEsc records the represented parameter's leak set.
paramEsc leaks
captured bool // has a closure captured this variable?
reassigned bool // has this variable been reassigned?
addrtaken bool // has this variable's address been taken?
param bool // is this variable a parameter (ONAME of class ir.PPARAM)?
paramOut bool // is this variable an out parameter (ONAME of class ir.PPARAMOUT)?
}Three fields in there do most of the interesting work later: addrtaken,
reassigned, and captured decide whether a closure can capture a variable
by value; resultIndex decides whether a leak goes to “the heap” or “result
number 2”.
The edge carries the weight:
// src/cmd/compile/internal/escape/graph.go:107
// An edge represents an assignment edge between two Go variables.
type edge struct {
src *location
derefs int // >= -1
notes *note
}And the hole is the analyzer’s way of saying “a value is flowing into this location, N dereferences deep”:
// src/cmd/compile/internal/escape/graph.go:140
// A hole represents a context for evaluation of a Go
// expression. E.g., when evaluating p in "x = **p", we'd have a hole
// with dst==x and derefs==2.
type hole struct {
dst *location
derefs int // >= -1
notes *note
// addrtaken indicates whether this context is taking the address of
// the expression, independent of whether the address will actually
// be stored into a variable.
addrtaken bool
}hole.addr() shifts the deref count down by one (an address-of), and
hole.deref() shifts it up by one (a dereference). The analyzer is
essentially threading a single integer through the entire program.
Where the edges come from
The analyzer walks the AST and turns every assignment into an edge. The
interesting one is flow, which is where the “address escapes” decision
actually gets made:
// src/cmd/compile/internal/escape/graph.go:193
func (b *batch) flow(k hole, src *location) {
if k.addrtaken {
src.addrtaken = true
}
dst := k.dst
if dst == &b.blankLoc {
return
}
if dst == src && k.derefs >= 0 { // dst = dst, dst = *dst, ...
return
}
if dst.hasAttr(attrEscapes) && k.derefs < 0 { // dst = &src
if base.Flag.LowerM >= 2 || logopt.Enabled() {
pos := base.FmtPos(src.n.Pos())
if base.Flag.LowerM >= 2 {
fmt.Printf("%s: %v escapes to heap in %v:\n", pos, src.n, ir.FuncName(src.curfn))
}
explanation := b.explainFlow(pos, dst, src, k.derefs, k.notes, []*logopt.LoggedOpt{})
if logopt.Enabled() {
var e_curfn *ir.Func // TODO(mdempsky): Fix.
logopt.LogOpt(src.n.Pos(), "escapes", "escape", ir.FuncName(e_curfn), fmt.Sprintf("%v escapes to heap", src.n), explanation)
}
}
src.attrs |= attrEscapes | attrPersists | attrMutates | attrCalls
return
}
// TODO(mdempsky): Deduplicate edges?
dst.edges = append(dst.edges, edge{src: src, derefs: k.derefs, notes: k.notes})
}Read the condition dst.hasAttr(attrEscapes) && k.derefs < 0. That means:
“the destination is the heap, and what is flowing into it is an address”.
In that case the source variable escapes, immediately and irrevocably.
Otherwise the assignment just becomes an edge and the analysis continues.
There are four synthetic locations set up at the start of every batch:
// src/cmd/compile/internal/escape/escape.go:145
func Batch(fns []*ir.Func, reassignOracles map[*ir.Func]*ir.ReassignOracle) {
var b batch
b.heapLoc.attrs = attrEscapes | attrPersists | attrMutates | attrCalls
b.mutatorLoc.attrs = attrMutates
b.calleeLoc.attrs = attrCalls
b.reassignOracles = reassignOraclesThe heap location has attrEscapes, which is precisely the flag that
flow above checks. The mutator and callee locations exist so the analyzer
can distinguish “this pointer gets written through” (which is a mutation
of the pointee, not an escape of the pointer) from “this pointer is stored
somewhere long-lived”.
Solving it: the walk
Once the graph is built, walkAll solves it. The solver is a Bellman-Ford
variant, because the graph has negative edges:
// src/cmd/compile/internal/escape/solve.go:135
// walk computes the minimal number of dereferences from roots that were
// scheduled with state s to all other locations. A root's live attributes may
// have grown since it was scheduled; the resulting new state is walked in a
// later round.
func (b *batch) walk(s walkState, roots []*location, walkgen uint32, enqueue func(*location), todo *queue) {
// The data flow graph has negative edges (from addressing
// operations), so we use the Bellman-Ford algorithm. However,
// we don't have to worry about infinite negative cycles since
// we bound intermediate dereference counts to 0.
diagnose := base.Flag.LowerM >= 2 || logopt.Enabled()And the actual escape decision is inside the loop:
// src/cmd/compile/internal/escape/solve.go:175
// If l.derefs < 0, then l's address flows to root.
addressOf := derefs < 0
if addressOf {
// For a flow path like "root = &l; l = x",
// l's address flows to root, but x's does
// not. We recognize this by lower bounding
// derefs at 0.
derefs = 0
// If l's address flows somewhere that
// outlives it, then l needs to be heap
// allocated.
if s.outlives(b, l) {
if !l.hasAttr(attrEscapes) && diagnose {
if base.Flag.LowerM >= 2 {
fmt.Printf("%s: %v escapes to heap in %v:\n", base.FmtPos(l.n.Pos()), l.n, ir.FuncName(l.curfn))
}
root := walkRoot(l, paths)
explanation := b.explainPath(root, l, paths[l])
if logopt.Enabled() {
var e_curfn *ir.Func // TODO(mdempsky): Fix.
logopt.LogOpt(l.n.Pos(), "escape", "escape", ir.FuncName(e_curfn), fmt.Sprintf("%v escapes to heap", l.n), explanation)
}
}
newAttrs |= attrEscapes | attrPersists | attrMutates | attrCalls
} else
// If l's address flows to a persistent location, then l needs
// to persist too.
if s.hasAttr(attrPersists) {
newAttrs |= attrPersists
}
}So the rule reduces to: if the address of l reaches a root that outlives
l, then l escapes.
“Outlives” is the interesting question
What outlives what? That is the outlives function, and it is where most of
the subtlety lives:
// src/cmd/compile/internal/escape/solve.go:366
// outlives reports whether values stored in roots with state s may survive
// beyond other's lifetime if stack allocated.
func (s walkState) outlives(b *batch, other *location) bool {
// The heap outlives everything.
if s.hasAttr(attrEscapes) {
return true
}
// Pseudo-locations that don't really exist.
if s.sink == &b.mutatorLoc || s.sink == &b.calleeLoc {
return false
}
// We don't know what callers do with returned values, so
// pessimistically we need to assume they flow to the heap and
// outlive everything too.
if s.sink != nil && s.sink.paramOut {
// Exception: Closures can return locations allocated outside of
// them without forcing them to the heap, if we can statically
// identify all call sites. For example:
//
// var u int // okay to stack allocate
// fn := func() *int { return &u }()
// *fn() = 42
if ir.ContainsClosure(other.curfn, s.curfn) && !s.curfn.ClosureResultsLost() {
return false
}
return true
}
// If root and other are within the same function, then root
// outlives other if it was declared outside other's loop
// scope. For example:
//
// var l *int
// for {
// l = new(int) // must heap allocate: outlives for loop
// }
if s.curfn == other.curfn && s.loopDepth < other.loopDepth {
return true
}
// If other is declared within a child closure of where root is
// declared, then root outlives it. For example:
//
// var l *int
// func() {
// l = new(int) // must heap allocate: outlives call frame (if not inlined)
// }()
if ir.ContainsClosure(s.curfn, other.curfn) {
return true
}
return false
}Four cases:
- The heap outlives everything. Obvious.
- The mutator and callee sinks outlive nothing — writing through a pointer is not the same as storing the pointer.
- A result parameter outlives everything, because the caller might stash the returned pointer somewhere. The one exception is a closure whose call sites the compiler can see.
- Loop depth and closure nesting decide the rest. A variable declared outside a loop outlives one declared inside it (the loop’s space gets reused). A variable declared in an enclosing function outlives one declared in a closure.
That last rule is why this code allocates on the heap:
var l *int
for {
l = new(int) // must heap allocate: outlives for loop
}The new(int) result is assigned to l, which lives outside the loop. On
the second iteration, the int from the first iteration is dead, so its
memory could be reused — but l still points at it. Heap allocation is the
only safe answer.
The tags: EscNone, EscHeap, EscNever
When the analysis is done, finish stamps every node with its verdict.
The flags live in ir/node.go and are deliberately tiny:
// src/cmd/compile/internal/ir/node.go:573
const (
EscUnknown = iota
EscNone // Does not escape to heap, result, or parameters.
EscHeap // Reachable from the heap
EscNever // By construction will not escape.
)EscNever is the interesting one: it means “this variable cannot possibly
escape, by construction of the program, not by analysis”. The escape
analyzer uses it for the defer record, which is a compiler-synthesised
variable:
// src/cmd/compile/internal/escape/call.go:238
func (e *escape) goDeferStmt(n *ir.GoDeferStmt) {
k := e.heapHole()
if n.Op() == ir.ODEFER && e.loopDepth == 1 && n.DeferAt == nil {
// Top-level defer arguments don't escape to the heap,
// but they do need to last until they're invoked.
k = e.later(e.discardHole())
// force stack allocation of defer record, unless
// open-coded defers are used (see ssa.go)
n.SetEsc(ir.EscNever)
}Note that this enum has exactly four values. There is no “escapes to
result” state here — that direction is carried separately, in the leaks
set described below, so that a parameter can be recorded as leaking to
result 0 without being recorded as leaking to the heap.
And here is the actual stamping, with the diagnostic messages:
// src/cmd/compile/internal/escape/escape.go:464
if loc.hasAttr(attrEscapes) {
if n.Op() == ir.ONAME {
if base.Flag.CompilingRuntime {
base.ErrorfAt(n.Pos(), 0, "%v escapes to heap, not allowed in runtime", n)
}
if base.Flag.LowerM != 0 {
base.WarnfAt(n.Pos(), "moved to heap: %v", n)
}
} else {
if base.Flag.LowerM != 0 && !goDeferWrapper {
if n.Op() == ir.OAPPEND {
base.WarnfAt(n.Pos(), "append escapes to heap")
} else {
base.WarnfAt(n.Pos(), "%v escapes to heap", n)
}
}
if logopt.Enabled() {
var e_curfn *ir.Func // TODO(mdempsky): Fix.
logopt.LogOpt(n.Pos(), "escape", "escape", ir.FuncName(e_curfn))
}
}
n.SetEsc(ir.EscHeap)
} else {So the compiler’s -m output distinguishes two cases that people usually
conflate:
moved to heap: x—xis a variable (ONAME) that got promoted.x escapes to heap—xis an expression (anew, a composite literal, anappend) whose value lives on the heap.
Interprocedural: parameter tags
The analyzer does not stop at one function. If it can see a callee’s definition, it can compute what that callee does with its parameters, and bake that into the callee’s export data as a “leak set”:
// src/cmd/compile/internal/escape/leaks.go:13
// A leaks represents a set of assignment flows from a parameter to
// the heap, mutator, callee, or to any of its function's (first
// numEscResults) result parameters.
type leaks [8]uint8
const (
leakHeap = iota
leakMutator
leakCallee
leakResult0
)
const numEscResults = len(leaks{}) - leakResult0Eight bytes: one for the heap, one for the mutator, one for the callee, and up to five result parameters. Each entry is a minimum dereference count, stored biased by one so that zero means “no flow”. The encoding is deliberately compact because it goes into every package’s export data:
// src/cmd/compile/internal/escape/leaks.go:97
// Encode converts l into a binary string for export data.
func (l leaks) Encode() string {
if l.Heap() == 0 {
// Space optimization: empty string encodes more
// efficiently in export data.
return ""
}
if s, ok := leakTagCache[l]; ok {
return s
}
n := len(l)
for n > 0 && l[n-1] == 0 {
n--
}
s := "esc:" + string(l[:n])
leakTagCache[l] = s
return s
}That "esc:" prefix plus raw bytes is what you see in export data. The
compiler reads it back at call sites and uses it to decide where an argument
flows:
// src/cmd/compile/internal/escape/call.go:403
// Call to previously tagged function.
var tagKs []hole
esc := parseLeaks(param.Note)
if x := esc.Heap(); x >= 0 {
tagKs = append(tagKs, e.heapHole().shift(x))
}
if x := esc.Mutator(); x >= 0 {
tagKs = append(tagKs, e.mutatorHole().shift(x))
}
if x := esc.Callee(); x >= 0 {
tagKs = append(tagKs, e.calleeHole().shift(x))
}
if ks != nil {
for i := 0; i < numEscResults; i++ {
if x := esc.Result(i); x >= 0 {
tagKs = append(tagKs, ks[i].shift(x))
}
}
}
return e.teeHole(tagKs...)So when you pass a pointer to a function whose body you cannot see (an assembly function, or one in another package compiled earlier), the compiler uses the tag. If the tag says “this parameter leaks to the heap”, your argument escapes. If it says “does not escape”, your argument can stay on the stack.
“Leaks to result” — the rule people misread
The most common source of confusion is the difference between a parameter that leaks to the heap and one that leaks to a result. They are different flows, and they are recorded separately.
// src/cmd/compile/internal/escape/graph.go:118
// leak records that parameter l leaks to sink.
func (l *location) leakTo(sink *location, derefs int) {
// If sink is a result parameter that doesn't escape (#44614)
// and we can fit return bits into the escape analysis tag,
// then record as a result leak.
if !sink.hasAttr(attrEscapes) && sink.isName(ir.PPARAMOUT) && sink.curfn == l.curfn {
ri := sink.resultIndex - 1
if ri < numEscResults {
// Leak to result parameter.
l.paramEsc.AddResult(ri, derefs)
return
}
}
// Otherwise, record as heap leak.
l.paramEsc.AddHeap(derefs)
}A parameter that leaks only to result 0 is not a heap leak. The function returns the pointer, so the caller decides what to do with it. This is what makes the classic “identity function” free:
func id(p *int) *int { return p }p leaks to result 0, not to the heap. The caller’s variable stays on the
stack. But:
func keep(p *int) *int { global = p; return p }leaks to the heap, so the caller’s variable is promoted. The -m output
makes the distinction visible:
// src/cmd/compile/internal/escape/escape.go:670
for i := 0; i < numEscResults; i++ {
if x := esc.Result(i); x >= 0 {
res := sig.Result(i).Nname.Sym().Name
base.WarnfAt(pos, "leaking param: %v to result %v level=%d", name, res, x)
warned = true
}
}leaking param: p to result r level=1 means “the pointer inside p (one
dereference deep) comes back out as result r”. That is a much weaker
statement than leaking param: p.
Closures: why capturing forces the heap
Now the case everybody hits. When a closure captures a variable, the analyzer has to decide: by value or by reference?
// src/cmd/compile/internal/escape/escape.go:262
func (b *batch) flowClosure(k hole, clo *ir.ClosureExpr) {
for _, cv := range clo.Func.ClosureVars {
n := cv.Canonical()
loc := b.oldLoc(cv)
if !loc.captured {
base.FatalfAt(cv.Pos(), "closure variable never captured: %v", cv)
}
// Capture by value for variables <= 128 bytes that are never reassigned.
n.SetByval(!loc.addrtaken && !loc.reassigned && n.Type().Size() <= 128)
if !n.Byval() {
n.SetAddrtaken(true)
if n.Sym().Name == typecheck.LocalDictName {
base.FatalfAt(n.Pos(), "dictionary variable not captured by value")
}
}
}Three conditions, all of which must hold to capture by value:
- The variable’s address was never taken (
!loc.addrtaken). - The variable was never reassigned (
!loc.reassigned). - The variable is at most 128 bytes.
If any of those fails, the variable is captured by reference. Then the address of the variable is stored in the closure record. And if the closure record itself can escape, the address of the variable has reached the heap, which is exactly invariant (1).
Note the “128 bytes” limit. That is not arbitrary — it is the size at which copying the value into the closure record becomes as expensive as heap-allocating it. Larger captures go by reference so the closure does not carry a fat copy of a big struct around.
Here is the closure case in expr.go, where the closure expression itself is
spilled into the graph:
// src/cmd/compile/internal/escape/expr.go:230
case ir.OCLOSURE:
n := n.(*ir.ClosureExpr)
k = e.spill(k, n)
e.closures = append(e.closures, closure{k, n})
if fn := n.Func; fn.IsClosure() {
for _, cv := range fn.ClosureVars {
if loc := e.oldLoc(cv); !loc.captured {
loc.captured = true
// Ignore reassignments to the variable in straightline code
// preceding the first capture by a closure.
if loc.loopDepth == e.loopDepth {
loc.reassigned = false
}
}
}And spill is the generic “this expression allocates storage” operation:
// src/cmd/compile/internal/escape/expr.go:336
// spill allocates a new location associated with expression n, flows
// its address to k, and returns a hole that flows values to it. It's
// intended for use with most expressions that allocate storage.
func (e *escape) spill(k hole, n ir.Node) hole {
loc := e.newLoc(n, false)
e.flow(k.addr(n, "spill"), loc)
return loc.asHole()
}A spill creates a location and flows its address into the destination.
If the destination is the heap, the expression escapes.
Some things cannot be stack allocated at all
Even if nothing escapes, some things are too big or too aligned. That check is separate from the graph analysis:
// src/cmd/compile/internal/escape/utils.go:186
if n.Type().Size() > ir.MaxStackVarSize {
return "too large for stack"
}
if n.Type().Alignment() > int64(types.PtrSize) {
return "too aligned for stack"
}
if (n.Op() == ir.ONEW || n.Op() == ir.OPTRLIT) && n.Type().Elem().Size() > ir.MaxImplicitStackVarSize {
return "too large for stack"
}
if (n.Op() == ir.ONEW || n.Op() == ir.OPTRLIT) && n.Type().Elem().Alignment() > int64(types.PtrSize) {
return "too aligned for stack"
}And the limits themselves:
// src/cmd/compile/internal/ir/cfg.go:8
// MaxStackVarSize is the maximum size variable which we will allocate on the stack.
// This limit is for explicit variable declarations like "var x T" or "x := ...".
// Note: the flag smallframes can update this value.
MaxStackVarSize = int64(128 * 1024)
// MaxImplicitStackVarSize is the maximum size of implicit variables that we will allocate on the stack.
// p := new(T) allocating T on the stack
// p := &T{} allocating T on the stack
// s := make([]T, n) allocating [n]T on the stack
// s := []byte("...") allocating [n]byte on the stack
// Note: the flag smallframes can update this value.
MaxImplicitStackVarSize = int64(64 * 1024)128 KiB for named variables, 64 KiB for implicit ones like new(T). Go’s
stacks grow by copying, and a giant stack frame makes every stack growth
expensive, so the compiler draws a line.
From EscHeap to runtime.newobject
The escape verdict becomes code in the SSA backend. The SSA builder asks
the IR what the verdict is, and if it is EscHeap, it allocates:
// src/cmd/compile/internal/ssagen/ssa.go:1789
case ir.ODCL:
n := n.(*ir.Decl)
if v := n.X; v.Esc() == ir.EscHeap {
s.newHeapaddr(v)
}And newHeapaddr is where the actual runtime.newobject call comes from:
// src/cmd/compile/internal/ssagen/ssa.go:724
// newHeapaddr allocates heap memory for n and sets its heap address.
func (s *state) newHeapaddr(n *ir.Name) {
size := allocSize(n.Type())
if n.Type().HasPointers() || size >= maxAggregatedHeapAllocation || size == 0 {
s.setHeapaddr(n.Pos(), n, s.newObject(n.Type()))
return
}And newObject is the runtime.newobject call site:
// src/cmd/compile/internal/ssagen/ssa.go:871
// newObject returns an SSA value denoting new(typ).
func (s *state) newObject(typ *types.Type) *ssa.Value {
if typ.Size() == 0 {
return s.newValue1A(ssaop.OpAddr, types.NewPtr(typ), ir.Syms.Zerobase, s.sb)
}
rtype := s.reflectType(typ)
if specialMallocSym := s.specializedMallocSym(typ.Size(), typ.HasPointers()); specialMallocSym != nil {
return s.rtcall(specialMallocSym, true, []*types.Type{types.NewPtr(typ)},
s.constInt(types.Types[types.TUINTPTR], typ.Size()),
rtype,
s.constBool(true),
)[0]
}
return s.rtcall(ir.Syms.Newobject, true, []*types.Type{types.NewPtr(typ)}, rtype)[0]
}If the variable does not escape, none of this runs. The variable becomes
a stack slot, assigned a frame offset, and lives in the function’s frame.
The decision point is the ir.Name.OnStack predicate, which is a one-liner
over the escape flag:
// src/cmd/compile/internal/ir/name.go:235
// OnStack reports whether variable n may reside on the stack.
func (n *Name) OnStack() bool {
if n.Op() == ONAME {
switch n.Class {
case PPARAM, PPARAMOUT, PAUTO:
return n.Esc() != EscHeap
case PEXTERN, PAUTOHEAP:
return false
}
}
// Note: fmt.go:dumpNodeHeader calls all "func() bool"-typed
// methods, but it can only recover from panics, not Fatalf.
panic(fmt.Sprintf("%v: not a variable: %v", base.FmtPos(n.Pos()), n))
}Reading the diagnostics
The tool for this is go build -gcflags=-m. Two levels matter:
-mprints the decisions:moved to heap: x,x escapes to heap,leaking param: p,p does not escape.-m=2prints the paths: the chain of flows that led to the decision.
./main.go:10:30: leaking param: p to result ~r0 level=1
./main.go:10:30: p does not escape
./main.go:14:14: moved to heap: x
./main.go:14:14: leaking param: content to result ~r0 level=1That last pair is the interesting one: content is a field of x, and it
escapes even though x itself did not need to move. That happens when the
struct field is a pointer that gets stored somewhere.
For JSON logging of the same decisions, the compiler supports -json=...
style optimization logs through the logopt package, which is what
gopls and benchstat-style tooling read.
Trade-offs
What escape analysis buys you: a local variable that does not escape
costs zero allocations. No runtime.mallocgc, no GC pressure, no cache
misses on a fresh span. In a hot loop this is often the difference between
5 ns and 50 ns per iteration.
What it costs you: compile time. The analyzer is a whole-program-ish data-flow analysis over every function in the package, with a Bellman-Ford solver and interprocedural tag propagation across package boundaries. For a big package this is measurable.
Why it is conservative in specific places: several flows are deliberately pessimistic.
- A pointer returned from a function is assumed to outlive the frame, because the compiler cannot see all the callers.
- A pointer passed to an unknown function is assumed to leak to the heap,
unless the function is annotated
//go:noescape. - A pointer stored in a struct field is treated as escaping if any field of that struct escapes, because the analyzer does not distinguish fields within a compound variable.
Why the “leaks to result” distinction is worth caring about: it is the difference between a function that can be used in a zero-allocation data structure and one that cannot. If every parameter of your accessor leaked to the heap, every call site would allocate.
Caveats
Escape analysis is per-package, not per-program. When you compile a package, the escape tags of the exported functions of its dependencies are available, but functions in the same package are analyzed together and can be inlined into one another. A change in one file can move a variable in another file.
The 128-byte closure capture limit is a hard cut, not a heuristic. A 129-byte struct captured by a closure goes by reference, and therefore usually to the heap. There is no “small enough to be worth copying” tier below it.
//go:noinline and //go:nosplit do not affect escape analysis. They
are separate pragmas. //go:noinline stops the inliner; //go:nosplit
stops the function from being split across a stack growth. The only pragmas
that influence escaping are //go:noescape (on a function declaration,
meaning “my pointer parameters do not escape”) and //go:uintptrescapes
(the opposite, for syscall-style functions).
The analyzer does not distinguish fields inside a struct. The header
comment in escape.go says it plainly: x.f = u[0] is modelled as
x = *u, without regard for which field. So a struct with one escaping
pointer field can drag the whole struct to the heap.
Zero-sized and very large types behave oddly. A zero-sized type gets
runtime.zerobase instead of an allocation. A type bigger than 64 KiB
allocated implicitly is always heap-allocated, no matter what the analysis
says.
append is a special case. The analyzer models append as possibly
reallocating, so the result of append can be on the heap even when the
input slice is on the stack. s = append(s, x) where s has spare
capacity usually stays on the stack; s = append(s, x...) usually does not.
Wrapping up
The one thing to remember: escape analysis asks one question about every variable — “can the address of this thing reach memory that outlives my stack frame?” — and if the answer might be yes, the variable is heap allocated. It is not “pointers are slow”; it is a lifetime question.
The part people get wrong: “the function does not store the pointer
anywhere” is not sufficient. A pointer that is returned, or captured by a
closure that might outlive the frame, or assigned to a variable declared
outside a loop, escapes. The -m output distinguishes these cases:
leaking param: p to result ~r0 is much friendlier than leaking param: p.
What to go do about it: compile with -gcflags=-m=2 and read the flow
paths for the variables you expected to stay on the stack. If a local
escapes, the reason is almost always one of four things: it is captured by
a closure, it is stored into a longer-lived variable, it is passed to a
function the compiler cannot see into, or it is returned. Fix the one that
applies and the allocation disappears.
Further reading
src/cmd/compile/internal/escape/escape.go— the pass driver:Batch,flowClosure,finish,paramTag,reportLeaks.src/cmd/compile/internal/escape/graph.go—location,edge,hole, the location attributes, andflow, where the escape decision is made.src/cmd/compile/internal/escape/solve.go— the solver:walkAll, the Bellman-Fordwalk, andoutlives.src/cmd/compile/internal/escape/leaks.go— the parameter leak sets and their compact export-data encoding.src/cmd/compile/internal/escape/expr.go— how each expression kind contributes edges, includingspilland the closure case.src/cmd/compile/internal/escape/call.go— call sites: parameter tags,mergedTagHole, and theappendmodel.src/cmd/compile/internal/escape/utils.go—HeapAllocReason, the size and alignment rules.src/cmd/compile/internal/ir/node.go— theEscNone/EscHeap/EscNeverflags.src/cmd/compile/internal/ir/name.go—Name.OnStack, which turns the escape verdict into a stack-or-heap decision.src/cmd/compile/internal/ssagen/ssa.go—newHeapaddrandnewObject, where the verdict becomes aruntime.newobjectcall.src/cmd/compile/internal/ir/cfg.go—MaxStackVarSizeandMaxImplicitStackVarSize.src/cmd/compile/internal/gc/main.go— whereescape.Funcsis called in the compilation pipeline, and why it has to run before closures are transformed.