The idea in one minute#
A Go program keeps values in two kinds of place. Each goroutine has a stack: a contiguous block where a function’s local variables live for exactly as long as the function runs. Allocating there is free — the function’s frame is just the next stretch of the block — and so is freeing: return, and the space is reused by the next call.
Everything else lives on the heap: memory that outlives any single function, obtained from the allocator and reclaimed later by the garbage collector. Heap allocation costs tens of nanoseconds and, more importantly, creates work for the collector.
You do not choose. The compiler does, per variable (lesson 02). But you influence it constantly.
An analogy#
A chef’s cutting board versus the walk-in fridge. Things on the board are used and cleared the moment the dish is done — no bookkeeping. Things in the fridge can be used by anyone at any time, so someone has to label them, and someone has to go through periodically and throw out what nobody needs any more.
A picture#
flowchart LR
subgraph G1["Goroutine 1 stack: starts at 2 KB, grows by copying"]
direction TB
F1["main frame<br/>locals of main"]
F2["handle frame<br/>n int, buf [64]byte, p pointer"]
F3["parse frame<br/>i int, tmp Point"]
SP["stack pointer: next free byte"]
F1 --- F2 --- F3 --- SP
end
subgraph HEAP["Heap: shared by all goroutines"]
O1[("Request struct")]
O2[("backing array<br/>of a slice")]
O3[("string bytes")]
end
F2 -->|"p"| O1
O1 --> O2
O1 --> O3
class F1,F2,F3,SP compute
class O1,O2,O3 memoryHow it really works#
The stack#
- A function call reserves a frame for its locals, arguments and results by moving the stack pointer. Returning moves it back. No allocator, no garbage collector.
- Stack memory is hot: the same few kilobytes are reused constantly and stay in the CPU cache.
- Each goroutine has its own stack, starting small — a few kilobytes (2 KB is the classic figure; the runtime now adapts the initial size to what goroutines have recently needed). That is why a program can have a million goroutines; an OS thread reserves megabytes.
Stacks grow by copying#
At the start of most functions the compiler inserts a check: is there room for this frame? If not, the runtime allocates a stack twice the size, copies the old one into it, fixes up every pointer that pointed into the old stack, and continues. During garbage collection a goroutine using less than a quarter of its stack may have it halved.
Consequences:
- Stack addresses are not stable. Go permits this because pointers into a stack can only be held by that same stack — anything visible more widely is moved to the heap by escape analysis.
- Deep recursion is safe up to a large limit (1 GB by default on 64-bit systems), then the program dies with a stack overflow.
- A goroutine that once needed a deep stack keeps the larger stack until a GC shrinks it.
The heap#
A value goes to the heap when it must outlive the function that created it, when its size is not known at compile time, or when it is too large for the stack. On the heap:
| Cost | Size |
|---|---|
| The allocation itself | ~15–50 ns for a small object (lesson 03) |
| Zeroing the memory | Proportional to size |
| GC marking | Proportional to the pointers reachable (lesson 04) |
| Cache behaviour | Objects scattered in memory miss more often than stack data |
| Triggering collections | Every byte allocated brings the next GC cycle closer |
The last row is the one that scales: allocation rate, in bytes per second, determines how often the collector runs.
What decides, in brief#
| Stays on the stack | Goes to the heap |
|---|---|
| A local whose address never leaves the function | A local whose address is returned or stored somewhere longer-lived |
| A small array or struct | make([]T, n) with n unknown at compile time or large |
| A slice whose backing array has a small constant size and does not escape | A value captured by a closure that outlives the function |
| A value stored in an interface that escapes | |
| Anything passed to a goroutine that outlives the caller |
Lesson 02 is entirely about reading these decisions.
Global data#
Package-level variables and string or numeric literals live in the binary’s data segments, neither stack nor heap. A global that points to heap memory keeps that memory alive forever — the simplest form of memory leak in Go.
Measuring#
runtime.MemStats/runtime/metrics:HeapAlloc(live heap bytes),MallocsandTotalAlloc(cumulative),StackInuse,NumGC.testing.AllocsPerRun, and-benchmemon benchmarks: allocations and bytes per operation.- A heap profile (V.01) shows where allocations come from.
Code#
// stackheap.go — stack growth by copying, goroutine cost, and stack vs heap allocation speed.
package main
import (
"fmt"
"runtime"
"sync"
"testing"
"unsafe"
)
type Point struct{ X, Y, Z float64 }
//go:noinline
func onStack() float64 {
p := Point{1, 2, 3} // never leaves: lives in this frame
return p.X + p.Y + p.Z
}
var keep *Point
//go:noinline
func onHeap() float64 {
p := &Point{1, 2, 3}
keep = p // escapes through a global: must be on the heap
return p.X + p.Y + p.Z
}
// depth recurses and counts how often the stack was moved: normally each frame sits a fixed
// distance below its caller, so any other distance means the whole stack was copied elsewhere.
func depth(n int, last, step *uintptr, moves *int) {
var local [128]byte
addr := uintptr(unsafe.Pointer(&local[0]))
if *last != 0 {
d := *last - addr
switch {
case *step == 0:
*step = d // the regular frame size, learned from the first call
case d != *step:
*moves++
}
}
*last = addr
if n > 0 {
depth(n-1, last, step, moves)
}
runtime.KeepAlive(local)
}
func main() {
// 1. Speed.
rs := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
onStack()
}
})
rh := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
onHeap()
}
})
fmt.Printf("stack value: %5.1f ns %.0f allocs\n", float64(rs.T.Nanoseconds())/float64(rs.N), testing.AllocsPerRun(100, func() { onStack() }))
fmt.Printf("heap value: %5.1f ns %.0f allocs\n", float64(rh.T.Nanoseconds())/float64(rh.N), testing.AllocsPerRun(100, func() { onHeap() }))
// 2. The stack grows by being copied somewhere else.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
var last, step uintptr
moves := 0
depth(20000, &last, &step, &moves)
fmt.Printf("\nrecursing 20,000 frames deep: the stack was relocated %d times\n", moves)
}()
wg.Wait()
// 3. What a goroutine costs.
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
const n = 100000
stop := make(chan struct{})
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
wg.Done()
<-stop
}()
}
wg.Wait()
runtime.ReadMemStats(&after)
fmt.Printf("%d parked goroutines: %.1f kB each (stack %.1f kB)\n", n,
float64(after.Sys-before.Sys)/n/1024, float64(after.StackInuse-before.StackInuse)/n/1024)
close(stop)
}Remember this#
- Stack: per goroutine, freed on return, effectively free. Heap: shared, garbage collected, costs time now and later.
- Goroutine stacks start at a few kilobytes and grow by copying to a block twice the size.
- The compiler chooses stack or heap for each variable.
- Allocation rate is what drives garbage-collection work.
Try it#
- Run
stackheap.go. What is the per-allocation cost on your machine? Multiply it by a million requests per second each making 50 allocations. - Change
depth’s local array from 128 bytes to 4,096. How does the number of relocations change, and why? - Start 100,000 goroutines that each recurse 100 frames before parking. How much memory per goroutine now?
Check yourself#
- Why is stack allocation nearly free?
- What happens when a goroutine’s stack is full?
- Name three things that force a value onto the heap.