all_lessons/Go/03 · Pointers, heap & GClesson 4 / 18

Pointers, the heap, escape analysis & the GC

This is the lesson where Go's "team up" bet meets the machine head-on. C++ hands you the question every value forces — where does this live, and who frees it? — and demands an answer in the code. Go takes that question off your desk and answers it for you: the compiler decides stack or heap, and a garbage collector frees what's dead. That is a convenience the language deliberately bought, and this lesson is about reading the receipt — what a Go pointer is and isn't, who actually chooses where data lives, and exactly what the GC costs so you can bound it.

The thesis, here
Memory management is the purest case of "complexity must be earned." Manual lifetime control is enormously powerful and is the single largest source of security-critical bugs in C and C++ — use-after-free, double-free, leaks. Go judged that for the overwhelming majority of code, that power is not worth what the reader and the team pay for it in bugs and review burden, and took it out of the language. In exchange it pays a small, bounded, known runtime price — a concurrent garbage collector with sub-millisecond pauses. The engineering skill this lesson builds is not "GC good, malloc bad." It is reading the bill: knowing precisely which convenience was purchased and what it cost, so you can tell when the price is fine (almost always) and when it isn't (the latency tail).
Linear position
Prerequisite: Lesson 02 gave you value semantics — assignment and argument-passing copy the value, and a slice/map/struct has a known in-memory layout (a slice header is 3 words: pointer, len, cap). A pointer is the tool for sharing instead of copying, so it only makes sense once copying is the default you understand.
New capability: explain what a Go pointer can and cannot do versus C, state who decides stack-vs-heap (the compiler, not new), read the output of escape analysis, and quantify the GC's cost well enough to defend the trade-off in a design review.
The plan
Four moves. (1) Why a pointer exists at all — sharing and mutation — and how Go's pointer is deliberately weaker than C's. (2) Stack versus heap, and the surprising fact that the compiler, not the keyword you typed, decides which. (3) Escape analysis: the rules the compiler uses, how to see its decisions, and how to write code that stays on the stack. (4) The garbage collector as the priced convenience — how it works, its sub-millisecond pause, the one knob (GOGC), and how to bound its cost.

1 · Why pointers exist, and what Go's pointer refuses to do

Lesson 02 established that Go copies by default: pass a struct to a function and the function gets its own copy. That default is a feature — it answers "who owns this?" with "you do, locally" — but it has two limits. First, the callee cannot change the caller's value; the copy is a dead end. Second, copying a large value costs CPU and memory proportional to its size. A pointer solves both: it is a value that holds the address of another value, so passing the pointer (one word, 8 bytes on a 64-bit machine) lets the callee both mutate the original and avoid the copy.

type Account struct {
    Balance int
    // ... imagine 200 bytes of other fields
}

func deposit(a Account, amount int)  { a.Balance += amount }   // mutates a COPY — caller unchanged
func depositP(a *Account, amount int) { a.Balance += amount }  // mutates the original

func main() {
    acct := Account{Balance: 100}
    deposit(acct, 50)        // acct.Balance still 100 — the copy was thrown away
    depositP(&acct, 50)      // acct.Balance now 150; & takes the address, * is implied by the . here
}

Two operators do all the work. &x takes the address of x (yields a *T); *p dereferences p to read or write the pointed-to value. Go quietly auto-dereferences for field and method access, so you write a.Balance rather than (*a).Balance — that ergonomic shortcut is why pointers feel lighter in Go than in C.

Now the deliberate subtraction. A C pointer is a number you can do arithmetic on: p + 1, walk off the end of an array, cast an int* to a char*, point at freed memory. That power is exactly the source of the bug classes Go wants gone. So a Go pointer is intentionally crippled:

no pointer arithmetic
You cannot add to or subtract from a pointer (outside the escape-hatch unsafe package). No walking off the end of a buffer — the canonical C overflow bug simply has no syntax.
no dangling pointers
You can return &localVar from a function — the thing it points to stays alive as long as the pointer does (see §2/§3). The GC guarantees the target outlives the pointer, so use-after-free cannot happen.
type-safe, no reinterpret cast
A *Account points at an Account, period. No casting it to a *int to peek at the first field. The type system stays sound.
a typed nil, checked at use
The zero value of any *T is nil. Dereferencing nil panics (a clean, catchable runtime error) rather than corrupting memory like a wild C read.

This is question 2 of the five — who owns this data, and how does it move? — made physical. A value is owned locally and copied; a pointer is a deliberate decision to share one piece of state across a boundary. Lesson 04 will show this exact choice resurface as value-vs-pointer receivers on methods, and Lesson 11 will show that shared-via-pointer state is precisely what a mutex must protect.

2 · Stack vs heap — and the keyword does not decide

Every value lives in one of two places. The stack is the per-goroutine region that grows and shrinks with function calls; allocating there is nearly free (bump a pointer) and reclaiming is automatic and instant (the frame is popped on return) — and crucially, stack memory is never the GC's problem. The heap is the shared region for values that must outlive the function that created them; allocating there costs more, and every heap object is something the GC must later trace and free.

Here is the fact that trips up programmers coming from C++: in Go, the keyword you write does not decide where the value lives. In C++, Account a; is on the stack and new Account is on the heap — the syntax is the decision. In Go, &Account{} and new(Account) and a plain var a Account are all just requests; the compiler runs escape analysis and decides for itself. The rule it follows is a safety rule, not a performance one: if a value's lifetime might outlive the function's frame, it must go on the heap; otherwise it can stay on the stack.

source you write escape analysis where it lives ------------------ ---------------- -------------- var a Account ─┐ a := Account{} ├─▶ does any reference ─┐ no ──▶ STACK a := new(Account) │ to it survive the ├─ yes ─▶ HEAP a := &Account{} ─┘ function's return? ─┘ (GC's job)

This is why returning the address of a local variable is safe in Go and a catastrophe in C. In C, return &local; hands back a pointer into a stack frame that's about to be destroyed. In Go, the compiler sees that the address escapes the function and silently allocates that value on the heap instead, so the pointer is always valid:

func newAccount(start int) *Account {
    a := Account{Balance: start} // looks stack-local...
    return &a                    // ...but its address escapes, so the compiler heap-allocates it
}                                // perfectly safe; the GC frees it when the last pointer is gone

The payoff: the correctness of where data lives is the compiler's job, and it cannot get it wrong. What's left for you is only a performance concern — keeping things on the stack when you can, so the GC has less to do — and that is a thing you tune with evidence, not a thing you can break.

3 · Escape analysis — see it, and steer it

Escape analysis runs at compile time and is conservative: when it can't prove a value stays local, it heap-allocates to be safe. The common reasons a value escapes:

its address is returned
return &a — the pointer outlives the frame, so the target must too (the §2 example).
it's stored in something that outlives the call
Assigned into a heap object, a package-level variable, a slice/map that survives, or sent on a channel (Lesson 09 — sending transfers ownership across goroutines).
it's captured by a closure that escapes
A returned func that references a local keeps that local alive, so the local escapes to the heap.
the compiler can't see the concrete type
Passing a value to an interface{} / any parameter (e.g. fmt.Println) usually forces a heap allocation — the callee's behavior isn't known at the call site. Relevant once you reach interfaces (Lesson 05).
it's too big or its size isn't known
A very large local, or a slice whose length isn't a compile-time constant, may be moved off the (bounded) stack.

You don't have to guess — the compiler will tell you. Build with -gcflags=-m and it prints every escape decision:

$ go build -gcflags='-m' ./...
./acct.go:5:6:  &a escapes to heap          # the returned address forces a heap allocation
./acct.go:10:13: x does not escape          # stays on the stack — free to allocate and reclaim

Why care, when correctness is already handled? Because heap allocation is the GC's input. A stack value costs the GC nothing. A heap value must be allocated, traced on the next cycle, and eventually freed. Cut allocations and you cut GC work directly — and the standard way to find them is go test -benchmem, which reports allocs/op and B/op per benchmark iteration (Lesson 15). The honest order of operations: write clear code first; measure with -benchmem and pprof; only then chase escapes in the proven hot path. Reaching for & everywhere to "avoid copies" often increases heap pressure and is a classic premature pessimization.

A common mistake
"Pointers are faster because they avoid copying." Not reliably. Copying a small struct (say ≤ 4 words / 32 bytes) on the stack is often cheaper than allocating it on the heap and giving the GC another object to trace and a pointer to chase (which can also miss the CPU cache). The right rule: pass by value for small immutable data; use a pointer when you must mutate the caller's value, or when the value is genuinely large. Decide by measuring with -benchmem, not by reflex.

4 · The garbage collector — the priced convenience

Here is what Go bought with that money. Manual memory management is the largest single source of memory-safety CVEs in systems software. Go's bet — half (2) of the bargain — is that eliminating that entire bug class, and freeing every engineer from lifetime bookkeeping, is worth a runtime cost provided the cost stays small, bounded, and predictable. So the design goal of Go's GC was never "fastest throughput"; it was low, predictable latency — because an unpredictable multi-millisecond pause is exactly the kind of cost a team can't reason about.

The mechanism is a concurrent, tri-color, mark-and-sweep collector. Tri-color: every object is white (unproven, assume garbage), grey (reachable, not yet scanned), or black (reachable and fully scanned). The collector starts from the roots (stacks, globals), greys them, and works grey→black until none are grey; whatever is still white is unreachable and its memory is swept back to the allocator. Concurrent is the key word: the marking runs alongside your program on a background goroutine, not by freezing it. A write barrier catches the case where your running code rewires a pointer mid-mark, so correctness holds without a global pause.

reachable from roots? a GC cycle, mostly concurrent ───────────────────── ───────────────────────────── roots (stacks, globals) STW ┐ start: enable write │ point to ~µs ┘ barrier, scan roots ▼ │ ╴╴╴╴ BLACK ── scanned, keep ▼ CONCURRENT MARK │ ▲ (app runs; mark goroutines ▼ │ write barrier trace grey→black; barrier GREY ── reachable, scanning records mutations) │ │ ▼ ▼ STW ~µs finish mark WHITE ── unreachable ⇒ swept (freed) CONCURRENT SWEEP (lazy, on alloc)

The cost, with real numbers. There are two brief stop-the-world pauses per cycle (to start and to finish marking); since Go 1.8 (2016) these were engineered down to the sub-millisecond range — typically tens to a few hundred microseconds, and they do not grow with heap size, only with the number of goroutines whose stacks must be rescanned. The heavy lifting — the mark — happens concurrently, so the price you actually pay is not a long freeze but a modest CPU tax: the GC uses up to ~25% of CPU during a cycle, and to keep allocation from outrunning collection it may briefly enlist your allocating goroutine to help (mark assist). The slogan: Go trades a little throughput (those CPU cycles) to buy short, predictable latency.

There is essentially one knob, GOGC (default 100). It sets the heap-growth target: at GOGC=100 the next collection triggers when the live heap has grown 100% — doubled — since the last one. Raise it (GOGC=200) and you collect less often, spending more memory to save CPU; lower it (GOGC=50) and you collect more often, spending CPU to hold memory down. Since Go 1.19 you can also set a hard GOMEMLIMIT (a soft memory ceiling) so a service in a container collects harder as it approaches its limit instead of being OOM-killed. That is the whole tuning surface — deliberately tiny, because a knob no one understands is itself a complexity cost.

What you controlEffectThe trade
GOGC=100 (default)Collect when live heap doublesBalanced CPU vs memory
GOGC=200Collect half as oftenLess CPU/fewer pauses, more RAM
GOGC=offDisable GC entirelyNo GC cost; you must not run out of memory (batch jobs only)
GOMEMLIMIT=512MiBSoft memory ceiling; GC works harder near itAvoids OOM-kill in containers at the price of more GC CPU
Allocate less (§3)Fewer heap objects to traceThe real lever — clear code first, then cut proven hot-path allocs

And the honest cost, named as the brief demands: the GC is small and bounded, but it is not free, and "bounded pause" still means "pause." For 99.9% of services the sub-millisecond pause and ~25% transient CPU tax are invisible against network and disk latency. But at the extreme latency tail — high-frequency trading, hard real-time control, microsecond-budget request paths — that pause is real, and it is a reason such systems are written in C++ or Rust instead. The mature stance is not to deny the cost; it's to measure your tail (e.g. via the runtime/metrics pause-time histogram) and decide whether Go's bargain fits your latency budget.

Checkpoint exercise

Try it
Write a tiny program with two functions that build the same struct: one returns it by value (func a() T) and one returns its address (func b() *T). Build with go build -gcflags='-m' . and confirm the compiler reports the value in b escaping to the heap while the one in a does not. Then write a benchmark for each and run go test -bench=. -benchmem; look at the allocs/op column and watch the pointer version report an allocation the value version doesn't. Now walk question 5 of the five — what did it cost, and who pays? — over your choice: the pointer let you share/mutate, but it handed the GC one more object to trace. When was that worth it, and when was the copy the cheaper answer?

Where this points next

You now know what a pointer is, who decides where data lives, and what the GC costs — the complete memory model. Lesson 04 spends it. The first real design decision you'll make over and over in Go is whether a method takes a value receiver (func (a Account) ...) or a pointer receiver (func (a *Account) ...) — and that choice is nothing more than this lesson applied to behavior: a pointer receiver when the method must mutate the value or the value is large enough that copying it per call matters, a value receiver when it shouldn't and isn't. Structs, methods, and receivers are next, and they begin the four-lesson arc — values → methods → interfaces → composition — that is how Go builds abstraction without inheritance.

Takeaway
A Go pointer is a deliberately weakened C pointer: it shares and mutates a value across a boundary (8 bytes, no copy) but cannot do arithmetic, cannot dangle, cannot be reinterpret-cast, and panics cleanly on a nil dereference — the price of which is that whole bug classes (use-after-free, buffer overflow) have no syntax. Where a value lives is not chosen by your keyword; the compiler's escape analysis decides — stack if the value can't outlive its frame, heap if it can — so correctness is guaranteed and only performance is left for you to tune (with -gcflags=-m and -benchmem, on the proven hot path). The heap is fed to a concurrent tri-color mark-sweep GC tuned for low, predictable latency: sub-millisecond stop-the-world pauses (tens of microseconds, flat in heap size), a transient ~25% CPU tax, and essentially one knob (GOGC, default 100, plus GOMEMLIMIT). That is half (2) of the bargain in its clearest form — a small, bounded, known runtime price paid to delete an entire class of bugs — and the transferable habit is to always ask of any convenience, here automatic memory management: which complexity did it remove, and what is it costing, in numbers, to whom?

Interview prompts