Chapter 11 — Go: escape analysis and the concurrent GC
The 30-second version. Go bets on millions of tiny goroutines, and that bet forces everything else. Tiny goroutines mean tiny stacks (2 KB); tiny stacks must grow; Go grows them by copying — so a pointer into a stack cannot outlive its frame. The compiler decides, at build time, which values stay in a frame and which must move somewhere whose address never changes: that decision is escape analysis, and it is why Go lets you write&xas freely as C (ch08) while rarely payingmalloc. The heap is reclaimed by a concurrent tricolor mark-and-sweep collector behind a hybrid write barrier — no compaction — paced byGOGCandGOMEMLIMITto hold stop-the-world pauses to tens of microseconds.
The bet: a million goroutines, and what it forces
Derive the GC; don't accept it. Go's 2009 target: servers holding millions of connections, each with its own flow of control.
Step 1 — threads are too heavy. An OS thread costs a kernel stack (Linux default: 8 MB virtual, tens of KB committed) and kernel-mode context switches; a million of them is gigabytes of stack and scheduler meltdown. So Go put the scheduler in userspace: goroutines.
Step 2 — goroutine stacks must start tiny, so they must grow. A million goroutines cannot each get a big fixed stack — same math, one level down. Go starts a goroutine at 2 KB (since Go 1.4; 8 KB before) — since Go 1.19 adaptive, from historical average stack use, still single-digit KB. Deep recursion overflows any fixed small stack. Pre-1.3 Go chained 4 KB segments; Go 1.3 switched to contiguous stacks: allocate a 2x stack, copy the old one in, adjust every pointer into it.
Step 3 — copying stacks breaks the C memory model. In C (ch08), &local is stable because the stack never moves. In Go, a stack can be copied at any call that finds SP below the guard, so a stack address is valid only while its frame lives and no growth happened. Go refuses to make you track that: &x is legal, pointers cross channels, closures capture locals by reference. Values whose addresses might outlive a frame must live somewhere that never moves — the heap — and escape analysis is the gatekeeper for that line.
Step 4 — a shared heap needs automatic reclamation. Pointers cross goroutines freely, so no static rule says who frees what: manual free is ch08's use-after-free at program scale, and refcounting (ch14) costs an update per assignment and leaks cycles. What remains is a tracing collector. The chain: goroutines forced tiny stacks, tiny stacks forced copying growth, copying forced a stable-address heap, a shared heap forced a GC.
Step 5 — latency over throughput. Go 1.8 (2017) drew the line: stop-the-world pauses dropped to typically under 100 µs, often ~10 µs, via GC pacing (redesigned 1.18): trigger early enough to finish marking on ~25% of one GOMAXPROCS of CPU. The bill: more GC CPU and a bigger peak heap than a compacting collector — the opposite trade of the JVM (ch12). Go also never compacts; we justify that below.
Escape analysis: the load-bearing wall
Every local has a candidate home: its frame (ch04) — free, cache-hot, gone at RET — or the heap (ch05) — allocated, GC-tracked, address-stable. The compiler picks per-value at build time by simulating how addresses flow. Watch it decide:
package escape
type Point struct{ X, Y int }
// stay: borrows a pointer, touches it, returns an int.
func stay(p *Point) int {
p.X++
return p.X
}
// leak: the address of a local leaves the function.
func leak() *Point {
p := Point{X: 1, Y: 2}
return &p
}
// box: 16 bytes cannot fit an interface's single data word.
func box() interface{} {
p := Point{X: 1, Y: 2}
return p
}
// counter: the closure outlives the frame and mutates n.
func counter() func() int {
n := 0
return func() int { n++; return n }
}
go build -gcflags='-m' . on this file (Go 1.21; wording drifts slightly across versions, the verbs do not):
./escape.go:6:10: p does not escape
./escape.go:13:2: moved to heap: p
./escape.go:19:2: p escapes to heap
./escape.go:25:2: moved to heap: n
./escape.go:26:9: func literal escapes to heap
The verdicts:
stay— the parameter is borrowed and forgotten;p does not escape(theescapes=0case) means callers may pass stack addresses. Silence is also a verdict: no note, no heap.leak—return &phands out an address that must outlive the frame: the variable ismoved to heap: p.box— returning a value asinterface{}forces a heap copy: the interface's data word is one pointer, and 16 bytes don't fit (next section).counter— the returned closure is heap-allocated (func literal escapes to heap) and, because it mutatesn, captures it by reference, forcingmoved to heap: n.
Rules of thumb:
- Returning a pointer — or storing an address into anything that outlives the frame (global, map, heap object, channel send) — escapes.
- Interface boxing often escapes: a value moved into an escaping interface is copied to the heap; the data word must be a stable pointer.
- Closures capturing by reference escape: mutation forces capture-by-pointer, and an escaping closure drags its captures along.
The GC only manages what reaches the heap — escape analysis is what keeps most of your &x off it, which is why idiomatic Go profiles are quiet. -m -m prints the flow chains behind each verdict; the labs find the exceptions in minutes.
Values, fat pointers, and the three-word slice
Assignment copies. a := b copies every byte of b — a 16-byte struct copies 16 bytes; no implicit &, no pointer arithmetic (&x is a real pointer, ch08, but p+1 is a compile error). The "reference feel" of slices, maps, and channels comes from the copied bytes containing pointers.
An interface value is a 2-word fat pointer — ch06's tagged-union/vtable trick, and Rust's &dyn Trait (ch10):
var i Shape = p (p is a 16-byte Point)
i (16 bytes)
+----------------+----------------+
| itab ptr (8 B) | data ptr (8 B) | <- the fat pointer
+----------------+----------------+
| |
v v
itab: type + heap copy of p
method table, +--------+--------+
size, GC bitmap | X (8B) | Y (8B) |
+--------+--------+
The data word is always one pointer. A pointer value is stored directly; a multi-word struct like p must be copied somewhere stable first — the heap, because the compiler cannot prove the original stack slot outlives the interface: Go has no borrow checker (that was ch10's path). That is why box() prints p escapes to heap. "Usually" heap: pointer-shaped values skip the copy; a few tiny scalars hit static runtime tables (insiders below).
A slice is a 3-word header over a separate array — a length-tagged pointer with a twist (ch06):
s := make([]byte, 4, 8)
slice value, 24 bytes backing array (heap, 8 B)
+----------+---------+---------+ 0 1 2 3 4 5 6 7
| ptr | len = 4 | cap = 8 │ ──► [a] [b] [c] [d] [ ] [ ] [ ] [ ]
+----------+---------+---------+
8 B each append writes in here (len < cap) without allocating
Two slice values sharing one backing array is the aliasing trap. append writes in place whenever len < cap and only reallocates on overflow:
func aliasBug() {
base := []int{0, 1, 2, 3, 4, 5, 6, 7} // one backing array A, cap 8
tail := base[4:6] // {&A[4], 2, 4}: tail "owns" nothing
mid := base[2:5] // {&A[2], 3, 6}: cap 6, A[2..7] writable
mid = append(mid, 99) // len 3 < cap 6: writes A[5] IN PLACE
fmt.Println(base) // [0 1 2 3 4 99 6 7] -- 5 silently became 99
fmt.Println(tail) // [4 99] -- the "unrelated" view changed
mid = append(mid, 100, 200, 300, 400) // needs 7 > cap 6: NEW array,
// copied, detached -- base and tail never move again
}
Diagnosis: the write landed at ptr + len = &A[5], which base and tail both observe. Fix: the three-index slice base[2:5:5] (cap forced to 3), which makes the next append copy — zero headroom, like handing out &[T] in Rust. Growth on reallocation: capacity doubles while small, then ~1.25x at large sizes — doubling below 1024 elements through Go 1.17; since Go 1.18 the knee is 256 elements with a smoother formula (~1.25–1.3x), rounded to a size class, so observed ratios jump.
Strings are immutable (ptr, len) — 16 bytes. Immutability makes them safe to alias and forces []byte(s) and string(b) to copy the payload each way — in a loop, those copies are the allocation profile. Two conversions are compiler-special-cased to skip the copy: m[string(b)] and range string(b). The zero-cast, when the bytes provably won't change, borrows instead:
b := []byte("hello, world")
s := unsafe.String(unsafe.SliceData(b), len(b)) // Go 1.20+: no copy
// pre-1.20 folklore: *(*string)(unsafe.Pointer(&b)) -- same aliasing
b[0] = 'H' // s[0] now reads 'H': the immutability rule is your problem
A real alias, like the slice views above — ch08's world of invariants you maintain personally.
Stacks that move
- Start: 2 KB since Go 1.4; since Go 1.19 adaptive from historical average use — in practice a few KB.
- Grow: when SP would cross the guard,
runtime.morestackallocates a 2x stack and copies:
BEFORE (2 KB) AFTER copy to 4 KB
hi +----------------+ hi +----------------+
| main() frame | | main() frame |
| p = &x | | p = &x' | same offset from
+----------------+ +----------------+ the stack base,
| h() frame | | h() frame | new absolute
| x = 7 | | x = 7 | address
+----------------+ SP +----------------+ SP
| frame F | | frame F |
+----------------+ +----------------+
| stackguard | SP hits guard | 2 KB of fresh |
| | -> morestack() | headroom ... |
lo +----------------+ | stackguard |
lo +----------------+
old 2 KB block freed; every pointer into it (like p) is
rewritten to the same offset in the new stack
The runtime can fix p because the compiler ships stack maps — per-safe-point metadata marking which stack words hold pointers. C never had this: ch04/ch07 frames are fixed once the prologue runs.
- The old design's death: pre-1.3 segmented stacks chained 4 KB blocks. If a hot loop's call chain straddled a segment boundary, every iteration allocated and freed a segment — the hot split problem, an allocation storm that appeared and vanished as stacks shifted a few bytes. Contiguous stacks (Go 1.3, finished in 1.4, which moved stacks into the runtime heap) grow by copying: a goroutine doubles O(log stack) times in its life.
- Shrink: at GC time, a goroutine using under a quarter of its stack gets it halved — idle goroutines give memory back.
The precise escape connection: a stack slot's address is a temporary identity — invalid after the frame returns and after any growth. Whenever the compiler cannot prove an address dies with the frame — returned, boxed, captured by an escaping closure, shipped to another goroutine — the pointed-to value must be heap-allocated. That is the whole rule, with a cost signature: a pointer to a stack slot prevents escape, pushing the object into the garbage stream. &x on a 4-byte int32 passed to another goroutine trades 4 stack bytes for a heap object plus GC tracking — such small escapes are "allocation noise".
The collector: tricolor, barriers, and never compacting
Reclamation is a concurrent tricolor mark-and-sweep: every object wears a color.
ROOTS (goroutine stacks, globals, registers)
|
+---------------------+---------------------+
| |
+----v----+ edge +-------+ edge +---+----+
| BLACK | -----------> | GREY | --------> | WHITE |
+---------+ +-------+ +--------+
BLACK = scanned, known live, done
GREY = known live, waiting to be scanned (the worklist / frontier)
WHITE = presumed dead until proven otherwise
mark step: pop a GREY object -> make it BLACK -> GREY every WHITE
object it points to
sweep: worklist empty => remaining WHITE objects were unreachable
-> their memory is reclaimed
The invariant that makes concurrency safe: a black object must never point directly at a white one — otherwise marking could "finish" while a live object hides behind a scanned object mutated mid-cycle. Mutators keep mutating, so Go installs a write barrier (Go 1.8+): on every heap pointer store, shade both the old value (Yuasa's deletion barrier — anything overwritten may be the last reference the start-of-cycle snapshot saw) and the new value (Dijkstra's insertion barrier — anything newly published is reachable). The hybrid lets goroutine stacks stay black all cycle: pre-1.8 Go re-scanned every stack inside a stop-the-world phase — the multi-millisecond pause; with the hybrid, the two remaining STW phases (sweep termination, mark termination) are each typically tens of microseconds.
The cycle: sweep termination (STW #1); concurrent mark — ~25% CPU on dedicated workers plus mark assist: allocating mutators get drafted, because allocation is the tax base; mark termination (STW #2); concurrent sweep. Nothing is ever moved: no compaction, ever.
Why Go survives that, when the JVM (ch12) treats compaction as essential: Java bump-allocates inside compacted regions and would fragment to death. Go's allocator descends from tcmalloc (ch19): ~68 size classes up to 32 KiB, so any object is interchangeable with any free slot of its class and fragmentation is structurally bounded:
[P0] mcache --\ per-P, lock-free: the hot path
[P1] mcache ---\ miss on size class k
[P2] mcache ----+-----------------> mcentral[k] (per class)
[..] / | span out of free slots?
v
mheap: page allocator over 64 MB arenas (64-bit)
objects > 32 KiB ("large"): skip mcache/mcentral,
dedicated span straight from mheap
Spans (8 KB pages grouped by class) are freed only when fully empty; the scavenger returns empty pages to the OS via madvise (MADV_DONTNEED default on Linux since Go 1.16). The price of never moving: fragmentation rattles inside spans, and RSS can sit far above live heap. The payoff: a write barrier suffices (moving collectors need read barriers), cgo and unsafe pointer arithmetic stay valid, and there is no "pinned object" concept to poison a compactor. Go chose address stability — the property step 3 demanded.
Pacing knobs, pools, and escape hatches
GOGC (default 100) is the heap-growth dial: the next GC triggers when the heap reaches live + (live + stacks + globals) * GOGC/100 — at 100, roughly double the live heap; GOGC=50 reclaims at 1.5x live. GOMEMLIMIT (Go 1.19+) is the second knob: a soft ceiling on runtime-managed memory (heap, stacks, runtime structures — not cgo's C malloc). Near the limit the collector runs earlier and the scavenger works harder; failing that, GC CPU is capped at ~50% to avoid a death spiral — the limit can be exceeded rather than stall the program, unlike the JVM's hard -Xmx (ch12). Container recipe: GOGC=off GOMEMLIMIT=....
Watch pacing with GODEBUG=gctrace=1. One real-format line (Go 1.21), field by field:
gc 12 @0.483s 1%: 0.031+0.61+0.023 ms clock, 0.25+0.32/1.2/0.062+0.18 ms cpu, 7->8->4 MB, 8 MB goal, 0 MB stacks, 1 MB globals, 8 P
gc 12 @0.483s— 12th cycle, 483 ms since program start1%— cumulative fraction of program time in GC (recent versions may add a second percentage for mark assist)0.031 + 0.61 + 0.023 ms clock— wall-clock per phase: STW sweep termination, concurrent mark, STW mark termination; the two STWs sum to 54 µs — the whole pause bill0.25 + 0.32/1.2/0.062 + 0.18 ms cpu— sweep CPU; mark CPU split dedicated / fractional / idle; mark-termination CPU7->8->4 MB— heap at cycle start, at end, live after marking;8 MB goal— the GOGC-derived trigger for the next cycle (live 4 MB, GOGC 100 → 8 MB;stacks/globals, Go 1.19+, explain the rounding)8 P— GOMAXPROCS
sync.Pool is GC-aware scratch on per-P local caches. It exists because the amortization pattern below is otherwise unwritable in a GC language — a global free list would be a leak, and per-request allocation is the noise you fight:
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func render(w io.Writer, rows []Row) {
buf := bufPool.Get().(*bytes.Buffer) // reuse, or New() on a miss
defer func() {
buf.Reset() // required: you get back what you put in
bufPool.Put(buf)
}()
for _, r := range rows {
fmt.Fprintf(buf, "%v\n", r)
}
w.Write(buf.Bytes())
}
Contract: a pool is a cache, not an owner. Every GC cycle drops its contents (the victim cache added in Go 1.13 lets one generation survive, softening post-GC misses); never Put an object you still depend on — a GC may legally take it.
Off-heap escapes. unsafe puts you in charge of aliasing, but the GC still sees unsafe.Pointer values and keeps their targets alive. cgo memory (C.malloc) is invisible to the collector — Go's pointer-passing rules let C hold a Go pointer only for the duration of a call, because the GC will not trace or pin it. Manual arenas had a moment: proposal golang/go #51317 shipped as GOEXPERIMENT=arenas in Go 1.20 and 1.21 — a narrow arena.New API with compiler use-after-free detection. Honest status: shelved by the Go team in early 2024 (proving safety for arbitrary types cost more than the win); as of Go 1.23 the sanctioned pattern is chunked []T slabs with 32-bit index handles, or a sync.Pool per type.
What insiders know
- The gctrace clock/cpu split is the interview trap.
ms clockis wall time (what latency feels);ms cpuis summed across threads (what throughput pays). A 0.61 ms clock mark on 8 P can burn 1.2+ ms of CPU. map[string]interface{}and[]interface{}allocate per element. Each insert or append boxes its value: a heap copy per element. A concrete[]T, or generics (Go 1.18+), avoids the box.- Small integers box for free.
runtimekeeps a static table (staticuint64s) for 0–255:var i interface{} = 7allocates nothing;= 7000allocates 8 bytes.fmt.Println(7)doesn't allocate for the 7 — the variadic[]interface{}slice still does if it escapes. GOGC=offexists (env, ordebug.SetGCPercent(-1)): the heap grows unbounded — fine for batch jobs that die in seconds, fatal for servers. Yet GOMEMLIMIT still fires with GOGC=off.- The large-object threshold is 32 KiB, not 64 KB. Objects over 32,768 bytes bypass mcache/mcentral for dedicated mheap spans, and fall outside
MemStats.BySize(which stops at 32 KiB). The "64 KB" folklore conflates it with the 64 MB arena size (64-bit). - String concat in a loop is quadratic allocation.
s += tallocates a fresh buffer per iteration (only a few simple shapes are compiler-optimized, fragile across versions);strings.Builder(Go 1.10+) grows likeappend, copying once. - The
-gcflagscaching gotcha.go build -gcflags='-m'prints nothing for a cached package — the notes are build output, and cached builds emit none.touchthe file (fast) orgo build -a(slow) to see them. unsafe.Pointer->uintptr-> arithmetic -> back is the one true GC blind spot. The heap never moves, so the address stays valid — the danger is liveness: with no typed pointer live across the sequence, the GC may free the object mid-computation. And stack addresses move on growth — never do this against a goroutine stack.- RSS != HeapAlloc. Idle-but-unreleased spans keep RSS high;
HeapIdle - HeapReleasedis what the scavenger hasn'tmadvised back yet. OOMKills are filed against RSS — the exact gap GOMEMLIMIT (1.19+) closes.
See it yourself
Lab 1 — escape analysis on one file. Save the escape.go from earlier plus a main that calls all four functions, then:
$ touch escape.go # defeat the build cache
$ go build -gcflags='-m' . 2>&1 | head -20
$ go build -gcflags='-m -m' . 2>&1 | grep -A2 'moved to heap'
Notice which declarations stay silent (the stack verdicts) and that -m -m shows the flow: chains — the compiler's proof. Then change return &p to return &Point{X: 1, Y: 2} and watch the message change shape: a literal escaping instead of a variable moving.
Lab 2 — the pacer in your terminal.
// gcwatch.go
package main
import (
"fmt"
"runtime"
)
func main() {
var sink []*byte
for i := 0; i < 3<<20; i++ {
b := make([]byte, 64) // 64 B of garbage per iteration
if i%1024 == 0 {
sink = append(sink, &b[0]) // keep ~1/1024 alive
}
if i%(1<<20) == 0 {
var m runtime.MemStats
runtime.ReadMemStats(&m) // brief STW; fine once per million
fmt.Printf("HeapAlloc=%dMB NextGC=%dMB NumGC=%d TotalAlloc=%dMB\n",
m.HeapAlloc>>20, m.NextGC>>20, m.NumGC, m.TotalAlloc>>20)
}
}
}
$ GODEBUG=gctrace=1 go run gcwatch.go
gc 1 @0.019s 2%: 0.028+0.61+0.015 ms clock, 0.22+0.35/1.5/0.08+0.14 ms cpu, 4->5->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 P
Watch HeapAlloc climb toward NextGC, NumGC tick over at each crossing, and TotalAlloc dwarf HeapAlloc (cumulative garbage vs live). Rerun with GOGC=50 GODEBUG=gctrace=1 go run gcwatch.go: goals shrink, cycles multiply. Also worth printing: HeapObjects, PauseNs (the last 256 STW pauses), PauseTotalNs.
Lab 3 — find garbage producers with pprof.
$ go test -run=xxx -bench=. -memprofile=mem.out ./...
$ go tool pprof -sample_index=alloc_space mem.out
(pprof) top10 # who allocated the most, ever -- the GC's workload
(pprof) list render
alloc_space (all allocation, dead or alive) finds GC pressure; inuse_space (the default) finds what is live now. Sampling is 512 KiB per sample (runtime.MemProfileRate), so numbers are estimates. With a server: import _ "net/http/pprof", then go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap.
How to use this knowledge
- Allocation-noise debugging. A handler high in an
alloc_spaceprofile pays three taxes: allocator work, GC pacing (more garbage → earlier triggers), cache misses. Read its lines with-m: the usual culprits are interface boxing,[]interface{}, string concat,&localhanded to a goroutine. - Container sizing. Set
GOMEMLIMITto ~90% of the container limit and budget RSS, not live heap — this explains an OOMKill at 700 MB with a 300 MB live heap: unreleased spans plus doubling room between GCs. - The silent-overwrite incident. "Sometimes the last record repeats": two slice views over one buffer plus an
append. Diagnose by readingptr/len/caparithmetic; fix with a three-index slice. - Interview answers with numbers. Why no compaction? Size-class interchangeability (ch19's tcmalloc lineage) plus address stability for cgo/unsafe. Why ~10–100 µs pauses? The hybrid write barrier removed stack rescans (Go 1.8). What does
GOGC=100mean? The heap doubles before each cycle.
Connections
- ch04 (the stack): Go frames are ch04 frames plus a stack-check branch in every prologue (ch07) — "stacks that move" as machine code.
- ch05 (the heap): escape analysis minimizes how often you pay ch05's costs;
mallocgcis the fast path when you do. - ch06 (object layout): the itab is ch06's fat pointer animated by a compiler decision.
- ch08–ch10 (C, C++, Rust): Go declined all three lifetime disciplines — manual free, RAII, borrow checking — and paid with a GC plus a pacer; section 1 is that trade.
- ch12 (Java), ch13 (C#): the compacting, generational opposite pole — same tricolor theory, opposite moving/latency choices.
- ch14 (Python), ch16 (Swift/ARC): the refcount alternative whose per-assignment cost Go's copy-everything semantics would make brutal.
- ch19 (allocators): mcache/mcentral/mheap is tcmalloc's three-level design; ch19 shows where the size-class table comes from.
- ch20 (hardware memory model): the write barrier is also a memory-ordering contract; per-P mcache is per-core state, so false sharing lives here.
- Roadmap, security module: a non-moving GC makes use-after-free nearly impossible in pure Go — but
unsafe, cgo, and heap grooming reopen ch21's world.
Forward links — where the rest of the program picks this chapter up:
- Module 09 ch02 (race detection) — the race detector's vector clocks are the runtime proof that Go's memory model only promised.
- Module 10 ch02 (the optimizer) — escape analysis decides stack vs heap: the compiler's alias analysis is this chapter's GC-pause story upstream.
- Module 22 ch03 (data parallel) — Go shops meet the same sharding arithmetic when the model outgrows one machine — channels as gradient queues.
Quiz
- (concept) A colleague: "Go has a GC because Google engineers didn't trust programmers with
free()." Derive the deeper chain this chapter argues, from goroutine design to GC, in four steps.
- (concept) Go 1.8's write barrier shades two values per heap pointer store. Name both, name the two classic barriers they combine, and state the pause-related property the combination buys.
- (predict) For each function: where does
v/zlive (stack or heap), and which-gcflags='-m'line appears?
func a(v Vec) float64 { return v.X + v.Y } // (a)
func b() *Vec { v := Vec{1, 2, 3}; return &v } // (b)
func c() interface{} { v := Vec{1, 2, 3}; return v } // (c)
func d() func() float64 { z := 0.0; return func() float64 { z++; return z } } // (d)
- (predict) A
gctraceline ends7->8->4 MB, 8 MB goal. The program reruns withGOGC=50(same live heap; ignore stacks/globals). What is the next goal, and what does GOGC=50 trade?
- (predict) What does this print, and what are
len/capofmidat the end?
base := []int{0, 1, 2, 3, 4, 5, 6, 7}
mid := base[2:5]
mid = append(mid, 99)
mid = append(mid, 100, 200, 300, 400)
fmt.Println(base, mid)
- (bug-diagnosis) A teammate swears the function below "only writes to
preview", yetdata[10]becomes -1 in production. Explain the exact write theappendperforms, and give the one-token fix.
data := make([]int, 1000, 1000)
// ... 1000 rows filled ...
preview := data[:10]
preview = append(preview, -1)
- (bug-diagnosis) On Go 1.21 this prints
3 3 3; on Go 1.22 it prints a permutation of0 1 2. Explain both behaviors in terms of what the closure captures, and what-gcflags='-m'reveals.
for i := 0; i < 3; i++ {
go func() { fmt.Println(i) }()
}
- (cross-chapter) Rust's
&dyn Traitand Go's interface value are both 2-word fat pointers (ch06, ch10). Explain why assigning a 24-byte struct to a Go interface copies it to the heap, while coercing&my_structto&dyn MyTraitin Rust points directly at the (possibly stack-allocated) struct.
Answers
- The chain, not the trust story. (1) Millions of concurrent flows are impossible with OS threads — MBs of stack address space, tens of KB committed each — so goroutines with KB-scale stacks. (2) A fixed 2 KB stack can't hold arbitrary recursion, so stacks grow by copying (Go 1.3+), invalidating every address into them. (3)
&xand cross-goroutine pointers must stay ergonomic, so escaped values need stable addresses: the heap, gated by escape analysis. (4) Free-flowing pointers have no static owner — manual free is use-after-free at scale (ch08), refcounting costs per assignment and leaks cycles (ch14) — so a tracing GC is forced.
- It shades the old (overwritten) value — Yuasa's deletion barrier, snapshot semantics — and the new value — Dijkstra's insertion barrier. Together, anything reachable at cycle start or published mid-cycle is shaded grey, so a mutator can never hide a live object behind a black one. Payoff: stacks are never re-scanned at mark termination — pre-1.8 Go's stack-rescan STW was the multi-millisecond pause; since 1.8 the two STW phases are tens of microseconds.
- (a) Stack, and silence — a value parameter, no address taken. (b) Heap:
moved to heap: v(the address flows to the return). (c) Heap:v escapes to heap— 24 bytes don't fit the interface's single data word, so the value is copied there. (d) Heap:moved to heap: zplusfunc literal escapes to heap— the returned closure mutatesz, forcing capture by reference.
- Goal = live + live*(GOGC/100) = 4 + 2 = 6 MB. GOGC=50 trades memory for GC frequency: the heap only grows to 1.5x live between cycles — more CPU per byte allocated, smaller peak — the knob for memory-tight containers, and the one GOMEMLIMIT (1.19+) supersedes.
- First
append:mid = {&A[2], 4, 6}; 99 written in place at&A[5], sobase = [0 1 2 3 4 99 6 7]. The second needs 8 > cap 6, so it allocates a fresh array — capacity doubles 6 → 12 (small slices double; the 256/1.25x knee is Go 1.18+) — copies[2 3 4 99], appends the rest:mid = [2 3 4 99 100 200 300 400],len = 8,cap = 12, now detached frombase.
previewis{&data[0], 10, 1000}— cap is the distance to the backing array's end, not the view's end. Withlen < cap,appendwrites in place atptr + len=&data[10]— no new array, no error. Fix: the three-index slicedata[:10:10](cap = 10) forces a copy on the nextappend. Rule: cap, not len, boundsappend's blast radius.
- Go ≤ 1.21: one
ifor the whole loop; the closure captures it by reference (mutated byi++) and thegostatement makes it escape —-mshowsmoved to heap: iandfunc literal escapes to heap. All three goroutines watch the same memory cell; the loop usually finishes first, so each reads the final value 3. Go 1.22 gave each iteration a freshi, so each closure captures its own copy — 0, 1, 2 in scheduler order. The fix was literally a per-iteration allocation.
- Both data words must be a stable pointer to the value. Rust's borrow checker proves the borrow outlives the trait object's use, so the data word can point at the struct wherever it lives — stack included. Go has no lifetime proof: the interface may outlive its frame, that stack may be copied by growth at any moment, and Go refuses to forbid
var s Shape = localStruct— so the compiler copies the struct to the heap, the one place whose address never changes. Same 2-word layout; the difference is who proves the lifetime: the type system (ch10) or the collector.