How do goroutine stacks grow, and why does that constrain the language?
A goroutine starts with a small stack, historically 2 KB. Since Go 1.19 the runtime picks the starting size from the average stack usage it has observed. Every function that is not a leaf has a prologue that compares the stack pointer with a guard value. If there is not enough room, it calls runtime.morestack, which allocates a stack twice as large, copies the whole stack into it, and adjusts every pointer into the stack. Go has used these contiguous, copying stacks since 1.4, replacing segmented stacks and their "hot split" problem. During GC, a stack using less than a quarter of its space is shrunk.
Consequences:
- A heap object must never point into a stack, because stacks move. The compiler enforces this through escape analysis.
- Addresses of stack variables are not stable, which is why you must not keep them as
uintptr.
var sink byte
//go:noinline
func grow(n int) {
var pad [1024]byte // must really be used, or the compiler drops it
pad[n%len(pad)] = byte(n)
if n > 0 {
grow(n - 1)
}
sink += pad[n%len(pad)] // keeps pad live across the recursive call
}
func main() {
x := 1
before := uintptr(unsafe.Pointer(&x))
grow(200) // ~200 KB of frames: forces several stack copies
after := uintptr(unsafe.Pointer(&x))
fmt.Println(before == after) // typically false: x moved with the stack
}
Goroutines are cheap because they start small, and deep recursion is fine up to the maximum stack size, 1 GB on 64-bit, beyond which you get a fatal "stack overflow".
More on Memory, GC & Runtime Internals
- Q332How do you inspect escape analysis decisions, and how do you read the output?
- Q333List the common reasons a value escapes to the heap.
- Q335Describe Go's memory allocator: size classes, mcache/mcentral/mheap, and the tiny allocator.
- Q336Explain Go's concurrent tri-color mark-and-sweep garbage collector.
- Q337What is a write barrier and which one does Go use?
- Q338Walk through the phases of a GC cycle. Where are the stop-the-world pauses?