Skip to content
academia.sh

Lesson 08 / 16

Escape Analysis

Escape analysis is a tool fact: the compiler decides whether to allocate a value on the stack or the heap and reports this without a run. Three of four functions move to the heap, and the same function changes this decision with a small change. Limiting measurement: this decision is not a promise of the language, it is an implementation detail tied to the compiler's version.

Published

In this lesson

  • Reads the compiler's decision on whether to allocate a value on the stack or the heap without a run
  • Demonstrates by running how a small change to the same function changes its decision to move a value to the heap
  • Distinguishes that the escape analysis decision is not a promise of the language but an implementation detail of the compiler
Contents

A profile stated where a value was allocated after the run, as a number. But the decision of whether that value will sit on the stack or the heap is made long before the run — while the source is merely being compiled, not yet executed. This lesson looks at that decision itself: how the compiler reports this decision, and how a small change in the same function’s body changes it.

This is the clearest tool example of the source/tool/run distinction that has held since the course opened. Escape analysis is not written in the source — the word heap or stack appears nowhere; the code only defines a variable and uses it. But the compiler does not run the program to make this decision either; it makes the decision during compilation and reports it on request. This differs from a run fact: a run fact (like the previous lesson’s profile number) only appeared once the program was executed, and it was separately tested for staying the same across runs. A tool fact, by contrast, requires no run — the same source, with the same compiler, gives the same decision every time.

Stack, Heap, and the Decision Between Them

The question of whether a value sits on the stack or the heap is not new in this course: stack and heap were established as part of memory layout in the How Computers Work course (M01/K01), and are not repeated here. That course established this distinction — the stack is a fast, sequential region that opens and closes together with a call frame; the heap is a region independent of the call frame, managed by hand (or, as this lesson will show, by the compiler’s decision). This lesson does not measure what those two regions are, but who decides which one a value goes to.

The decision itself can be summarized with a simple rule: if a value’s lifetime outlasts the lifetime of the call frame that created it, the value must move to the heap — otherwise the value would be lost when the call frame closes. The compiler decides this by tracing the source code itself — following where a pointer goes, which interface it is passed to, which container it is appended to. This tracing step is escape analysis: the compilation step that determines whether a value “escapes” the scope that created it.

The difference between stack and heap is not only “where it sits” but “who manages it.” A value on the stack has its lifetime automatically closed together with the call frame — no separate cleanup step is needed. A value on the heap has a lifetime independent of the call frame, and it is the runtime, not the program, that decides when this value gets removed. This second question — how a value moved to the heap eventually gets cleared out — is the next lesson’s subject; this lesson only looks at whether the value goes to the heap or not.

In the previous lesson, the verbose branch of the format function allocated seven extra values, and in this lesson where those seven allocations come from becomes clear: each of the three fmt.Sprintf calls made exactly the kind of escape decision this lesson will examine while wrapping the string it produced and its arguments into an interface. The profile counted the result of those decisions (how many values); this lesson reads the decision itself.

Reading the Decision on Its Own

The way to read escape analysis’s decision is to call the compiler from inside the program and read only its decision, not its text. The helper function below does this: it writes a given source fragment on its own into a temporary module, runs the compiler on that module, and searches the output for whether a move-to-heap notification exists — the notification’s text is never printed, only this truth value is.

// escape.go — reads the compiler's move-to-heap decision from inside the program, without printing its text
package main

import (
	"os"
	"os/exec"
	"strings"
)

// escapes writes the given body on its own into a package and runs the
// compiler on that package. The returned value is only a truth value;
// the compiler's notification text is never read or printed.
func escapes(body string) bool {
	dir, err := os.MkdirTemp("", "escape")
	if err != nil {
		return false
	}
	defer os.RemoveAll(dir)
	os.WriteFile(dir+"/go.mod", []byte("module escape\n\ngo 1.24\n"), 0o644)
	os.WriteFile(dir+"/value.go", []byte(body), 0o644)
	cmd := exec.Command("go", "build", "-gcflags=-m -l", "-o", os.DevNull, ".")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	output, _ := cmd.CombinedOutput()
	text := string(output)
	return strings.Contains(text, "moved to heap") || strings.Contains(text, "escapes to heap")
}

PM4. escapes never calls the target function — it only defines it. The compiler resolves a function’s body during compilation and reports its decision even when no code calls it; this is why func main() {} can stay empty in escapes’s temporary module. This shows the decision depends on definition, not being called — escape analysis requires no run, because there is nothing running; there is only something being compiled.

Compiling each source on its own, in a separate package, is also a deliberate choice. The compiler can inline some small functions at their call site; when such inlining happens, the escape decision is written not to the function’s own body but to the call site it was inlined into, and it becomes harder to read. Compiling each source only in its own package, without mixing into any other call, removes this complication from the start: where the decision gets written and which function it belongs to never falls into ambiguity.

This isolation has a cost too: in a real program, if a function like returnsAddress gets inlined at its call site, its decision may come out different from what is measured here — if the caller uses the result immediately and discards it, the compiler may still manage to keep the value on the stack after inlining. This lesson does not measure that situation; it measures only the decision the function gives on its own, without being inlined into any call. The decision in a real program can therefore come out more optimistic than what is seen here, never more pessimistic — inlining can only prevent an escape, it never adds a new one.

Four Functions, One Decision Table

The measurement below defines four separate functions, all four using the same Record struct, but all four handling the value in a different way: one only reads the value locally, one returns its address, one gives it to an interface, one appends it to a slice. A fifth source is nearly identical to the first one (staysLocal) — the only difference is that the last line returns an address instead of a value.

// measurement.go — four functions, the compiler's move-to-heap decision, and a small change
package main

import "fmt"

const staysLocal = `package main

type Record struct{ Name string }

func staysLocal() int {
	k := Record{Name: "bolt"}
	return len(k.Name)
}

func main() {}
`

const returnsAddress = `package main

type Record struct{ Name string }

func returnsAddress() *Record {
	k := Record{Name: "nut"}
	return &k
}

func main() {}
`

const givenToInterface = `package main

import "fmt"

type Record struct{ Name string }

func givenToInterface(k Record) {
	fmt.Println(k)
}

func main() {}
`

const appendedToSlice = `package main

type Record struct{ Name string }

func appendedToSlice(target []*Record) []*Record {
	k := Record{Name: "nail"}
	return append(target, &k)
}

func main() {}
`

const staysLocalChanged = `package main

type Record struct{ Name string }

func staysLocalChanged() *string {
	k := Record{Name: "bolt"}
	return &k.Name
}

func main() {}
`

func main() {
	rows := []struct {
		name   string
		source string
	}{
		{"value staying local", staysLocal},
		{"value returning an address", returnsAddress},
		{"value given to an interface", givenToInterface},
		{"value appended to a slice", appendedToSlice},
		{"same function, small change", staysLocalChanged},
	}
	escaped := 0
	fmt.Println("-- compiler's decision to move to heap --")
	for i, s := range rows {
		moved := escapes(s.source)
		if moved && i < 4 {
			escaped++
		}
		fmt.Printf("%-32s moved to heap=%t\n", s.name, moved)
	}
	fmt.Println("moved to heap out of four functions:", escaped, "/ 4")
}
-- compiler's decision to move to heap --
value staying local              moved to heap=false
value returning an address       moved to heap=true
value given to an interface      moved to heap=true
value appended to a slice        moved to heap=true
same function, small change      moved to heap=true
moved to heap out of four functions: 3 / 4

Reading the Numbers

Three of the four functions move to the heap, one does not. staysLocal only reads the value inside its own body (len(k.Name)) and gives nothing outward — the value can close together with the call frame, it stays on the stack. The other three carry the value outward by three separate paths: returnsAddress returns the value’s address, givenToInterface passes the value into an interface parameter (fmt.Println‘s variadic ...any parameter), appendedToSlice appends the value’s address to a slice. All three share one thing in common: the value must outlive the call frame that created it — someone may still access that value after the call ends. The compiler sees this and moves the value to the heap, making it independent of the call frame’s lifetime.

The fifth row shows this more sharply: staysLocalChanged is nearly identical to staysLocal — same struct, same field name, same first line. The only difference is in the last line: return &k.Name instead of return len(k.Name). This single change turns the returned value from an integer into an address, and flips the decision from false to true. The source shows no new pointer type, no explicit memory allocation call — only an & was added. The escape analysis decision looks not at the code’s intent, but at the value’s reachability; taking a field’s address means the struct holding that field can no longer stay inside the call frame.

This asymmetry explains why staying on the stack is counted a gain, not the default. Keeping a value on the stack requires writing nothing special — merely not carrying the value outward is enough. But this “not carrying outward” is fragile: appending to a slice as in appendedToSlice, passing to an interface as in givenToInterface, or taking a single field’s address as in staysLocalChanged — all four push in the same direction: they push the value from the stack to the heap. There is no operation in the reverse direction — a single line is enough to “force” a value to the heap, while rescuing it from the heap requires the code to never give that value outward at all. This is why three of four functions escaping is not a coincidence specific to this example set: every operation that gives outward pushes in the same direction.

The course’s opening split eleven facts into source, tool, and run; this lesson uses only the tool out of those three classes — the escaping value count is neither written in the source nor learned only by running; the compiler derives and reports it without a run.

escapes returning a single truth value hides the fact that the compiler actually gives two separate kinds of notification. In a function like returnsAddress, the notification says a local value was moved; in a function like givenToInterface, the notification says a parameter itself “leaks” into the caller’s scope, because the value is already produced by the caller and passed in as a parameter. Both end up in the same place — the value sits on the heap — but the path differs: one carries a value that forms inside the function, the other carries a value that comes from outside into the function. This lesson’s measurement merges the two under a single true/false because their shared outcome — moving to the heap — is the question this course asks; the detail of the path belongs to the compiler’s internal workings and is not read here.

Limiting Measurement: This Is Not a Promise, It Is a Decision

Someone reading the table above might quickly arrive at a rule like “in Go, a value goes to the heap if its address is taken.” This rule holds true for today’s compiler, but it is not the language’s promise. The language specification nowhere says “a value whose address is taken goes to the heap”; it only guarantees how long a value will live, not where it sits. Escape analysis is a method of fulfilling this guarantee — an implementation decision a particular compiler makes in a particular version.

This has a concrete consequence: a smarter compiler could manage to keep a function like returnsAddress on the stack too — for example, if the caller uses the result immediately and discards it, and the compiler can prove this. This lesson did not see that proof, because none of the four functions in this lesson permit such a proof — all of them genuinely carry the value outside the call frame. But the same source, compiled with a different compiler version, may give a different decision; this lesson therefore presents the decision table not as a fact, but as an observation of this particular compilation.

The same uncertainty is an example of the general distinction between a language’s specification and its implementation. The specification guarantees how long a value will live and which operations are valid; the implementation chooses which memory region and which method holds this guarantee. The same distinction holds for other compiler decisions too — a map’s iteration order, which carries no ordering guarantee, was likewise an implementation choice, not the language’s promise. Escape analysis is this general pattern applied to memory layout: the decision you see is correct, but that decision belongs to the compiler, not the language.

This limit comes down to the same root as the previous lesson’s limit: a tool fact is limited by the tool itself. The profiling tool only saw the path that ran; the escape analysis tool only shows this compiler’s this decision. Neither gives the truth — both give the tool’s answer at that moment.

This has a practical consequence: a comment written in a codebase saying “this value escapes to the heap, fix it” may turn out wrong once the compiler changes. The escape decision is not a fact stored together with the source; it is an output reproduced at compile time. This is why the table in this lesson is not a one-time thing either — the same escapes call re-runs the compiler and asks the decision again every time it executes; no decision is stored from a previous run.

Summary

  • Escape analysis is a tool fact: the compiler decides whether a value will be allocated on the stack or the heap without ever running the program, and reports this without a run.
  • Three of four functions moved to the heap — the one returning the value’s address, the one giving it to an interface, and the one appending it to a slice. The function that keeps the value only in its own body stayed on the stack.
  • A one-line change to the same function (&k.Name instead of len(k.Name)) flipped the decision from false to true — escape analysis looks at the value’s reachability, not the code’s intent.
  • This decision is not a promise of the language, it is an implementation detail tied to the compiler’s version; a different compiler version may give a different decision for the same source.
  • The concepts of stack and heap were not re-established here; this lesson only measured the compiler’s decision between the two.
  • The escape decision works in only one direction: every new path that carries a value outward and pushes it to the heap increases escaping; no operation alone calls a value back from the heap.

Next Step

This lesson asked whether a value moved to the heap or not — a one-time decision made at compile time. But in a program’s real running process, this decision alone is not enough: every value moved to the heap must eventually be cleared out of it too, and this clearing happens not at compile time but during a run. The next lesson looks at this second half — how much the “moves to the heap” decision built in this lesson adds up to in total, how certain techniques lower that total, and why a number going down does not always mean an improvement.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close