// Package escapelab contains the runnable experiments for Chapter 1: // Escape Analysis — The Compiler's Fragile Decision. // // See the escape decisions the compiler makes: // // go build -gcflags='-m=2' ./... // // Run the allocation benchmarks: // // go test -bench=. -benchmem -count=6 . // // Every exported function below is referenced by a micro-claim in the chapter. // The //go:noinline directives keep call boundaries intact so that the escape // decision we observe is the function's own, not an artifact of inlining. // (Inlining and the way it *changes* escape results is Chapter 4.) package escapelab import ( "fmt" "io" ) // Point is a small struct (16 bytes on a 64-bit platform). We use it instead // of a bare int so the heap allocation, when it happens, is visible and // concrete — but it is still small enough that size effects (Chapter 2) do not // muddy the escape lesson. type Point struct { X, Y int } // --------------------------------------------------------------------------- // Claim 1 — Returning a pointer to a local escapes it; returning the value // does not. // --------------------------------------------------------------------------- // NewPointPtr returns the address of a local. The pointer must remain valid // after the frame is gone, so the compiler is forced to move p to the heap. // //go:noinline func NewPointPtr(x, y int) *Point { p := Point{x, y} return &p // p's address outlives this frame -> heap } // NewPointVal returns a copy. Nothing of p's lifetime leaks past the return, // so p can live entirely in the caller's frame. // //go:noinline func NewPointVal(x, y int) Point { p := Point{x, y} return p // copied into the caller -> stack } // LocalAddr builds a Point behind a pointer but never lets the pointer leave // the frame. The compiler proves "&Point{...} does not escape" — taking an // address is not what causes a heap allocation; *outliving the frame* is. // //go:noinline func LocalAddr(x, y int) int { p := &Point{x, y} return p.X + p.Y } // --------------------------------------------------------------------------- // Claim 2 — Passing a value to a variadic `any` (fmt.Println / fmt.Fprint) // forces it to escape: the compiler cannot see what the callee does // with the interface, so it must assume the worst. // --------------------------------------------------------------------------- // SquareQuiet performs the computation with nothing escaping. // //go:noinline func SquareQuiet(n int) int { return n * n } // SquareLogged performs the same computation but boxes the result into `any` // via fmt.Fprint. We write to io.Discard so the benchmark does no real I/O — // the allocation we measure is the interface boxing, not the print. // //go:noinline func SquareLogged(n int) int { x := n * n fmt.Fprint(io.Discard, x) // x is boxed into any -> escapes to heap return x } //go:noinline func BoxLocal(n int) int { var x any = n _ = x return n } // --------------------------------------------------------------------------- // Claim 3 — A closure that escapes drags its captured variables onto the heap; // a closure that stays local does not. // --------------------------------------------------------------------------- // MakeCounter returns a closure. Because the closure escapes (it is returned), // the captured variable n must live on the heap. // //go:noinline func MakeCounter() func() int { n := 0 return func() int { // func literal escapes; n moved to heap n++ return n } } // SumLocally builds a closure but calls it only within this frame, so neither // the closure nor its captured variable escapes. // //go:noinline func SumLocally(nums []int) int { total := 0 add := func(v int) { total += v } // inlined away — never leaves the frame for _, v := range nums { add(v) } return total } // --------------------------------------------------------------------------- // Claim 5 — Assigning a local to a package-level variable escapes it. // (Claim 4 — escape decisions drift across Go releases — is a // two-toolchain experiment; see the chapter.) // --------------------------------------------------------------------------- // Retained is a package-level pointer. Anything assigned to it outlives every // function, so the compiler must heap-allocate it. var Retained *Point // StashGlobal moves a local onto the heap purely by assigning its address to a // package-level variable. // //go:noinline func StashGlobal(x, y int) { p := Point{x, y} Retained = &p // lifetime now exceeds the function -> heap } // --------------------------------------------------------------------------- // Boundary experiment — Why escape analysis is intraprocedural. A concrete // call is transparent to the compiler (devirtualized and inlined, nothing // escapes); an interface call is opaque, so the receiver parameter "leaks". // This is the seed of Chapter 9 (Interface Dispatch); here it only shows the // boundary that defeats the analysis. // --------------------------------------------------------------------------- // Transformer is satisfied by Doubler. type Transformer interface { Transform(int) int } // Doubler is a concrete implementation with a small, inlinable method. type Doubler struct{} func (Doubler) Transform(x int) int { return x * 2 } // UseConcrete calls Transform through an interface variable whose concrete // type is statically visible. The compiler sees exactly which function runs, // so it devirtualizes and inlines the call. // //go:noinline func UseConcrete(x int) int { var t Transformer = Doubler{} return t.Transform(x) } // UseInterface calls Transform through an interface. The concrete type is // hidden, so the call is an opaque boundary and the receiver t "leaks". // //go:noinline func UseInterface(t Transformer, x int) int { return t.Transform(x) }