Below the API

Stack and Heap

Intermediate Intermediate 45 min Difficulty 3/5

Prerequisites II.01, II.04

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 memory

How 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:

CostSize
The allocation itself~15–50 ns for a small object (lesson 03)
Zeroing the memoryProportional to size
GC markingProportional to the pointers reachable (lesson 04)
Cache behaviourObjects scattered in memory miss more often than stack data
Triggering collectionsEvery 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 stackGoes to the heap
A local whose address never leaves the functionA local whose address is returned or stored somewhere longer-lived
A small array or structmake([]T, n) with n unknown at compile time or large
A slice whose backing array has a small constant size and does not escapeA 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), Mallocs and TotalAlloc (cumulative), StackInuse, NumGC.
  • testing.AllocsPerRun, and -benchmem on 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#

  1. Run stackheap.go. What is the per-allocation cost on your machine? Multiply it by a million requests per second each making 50 allocations.
  2. Change depth’s local array from 128 bytes to 4,096. How does the number of relocations change, and why?
  3. Start 100,000 goroutines that each recurse 100 frames before parking. How much memory per goroutine now?

Check yourself#

  1. Why is stack allocation nearly free?
  2. What happens when a goroutine’s stack is full?
  3. Name three things that force a value onto the heap.

↑↓ navigate ↵ open