14.4 Goroutine Stacks: Starting Small and Growing
Right, let’s talk about where your goroutines actually live. You don’t just summon them from the aether; they need a place to store their local variables, their function arguments, their return addresses—all the little bits of state that make them, well, them. That place is the stack.
Now, if you’re coming from the world of OS threads, you’re probably used to the idea of a big, fat, pre-allocated stack for each thread. The kernel typically reserves a megabyte or two (and you can often tweak this). It’s like giving every employee a massive, empty warehouse to work in from day one. Safe? Sure. A colossal waste of memory if you have ten thousand employees mostly just sorting paperclips? Absolutely.
Go designers looked at this and said, “Nah, we can do better.” And they did. A new goroutine starts its life with a tiny stack, just 2 KiB. Let that sink in. Two kilobytes. It’s not a warehouse; it’s a cozy, well-organized cubicle. And the real magic isn’t just that it’s small, but that it can grow (and shrink!) dynamically on demand. This is the secret sauce that lets you spin up a million goroutines without setting your RAM on fire.
The Initial Stack: Why So Small?
The 2 KiB starting size isn’t a random guess; it’s a calculated bet. The vast majority of your functions are small. They take a few arguments, call a few other functions, and return. They don’t need a mansion. By starting small, Go keeps the memory footprint of idle or lightly-used goroutines incredibly low. This is why you can write a server that handles ten thousand concurrent connections without your process spontaneously combusting. Each of those waiting connections is a goroutine, and most are just sitting there, sleeping on their tiny, cheap little stacks.
How Stack Growth Works (The Magic Trick)
So what happens when our goroutine gets ambitious and writes a deeply recursive function, or tries to allocate a large local array? It runs out of stack. In most languages, this is a segfault or a stack overflow exception. In Go, it’s a request for more office space.
When the compiler detects that a function might need more stack (it does this with clever “stack checks” at the beginning of functions), it sets up a trap. If the goroutine hits its limit, it triggers a graceful, coordinated process:
- Execution is paused.
- A new, larger stack segment is allocated (usually twice the size of the old one).
- The entire contents of the old stack are copied over to the new one.
- All pointers on the stack that referenced the old memory are updated to point to the new locations. (This is the really impressive part, made possible by Go’s careful stack management and runtime cooperation).
- The goroutine resumes execution, none the wiser, now with plenty of room.
This is called a stack split. From your perspective, it’s seamless. It just works.
package main
// A function that will definitely need more than 2KB of stack.
func recursiveDeep(depth int) {
var buf [256]byte // Allocate a decent-sized array on the stack each call
buf[0] = byte(depth)
// Prevent infinite recursion in case you run it
if depth > 0 {
recursiveDeep(depth - 1)
}
}
func main() {
// This will cause the stack to grow multiple times.
// Run it and see that you don't get a stack overflow!
recursiveDeep(100)
}
The Dark Side: Performance Implications
Nothing is free, of course. That copying operation I mentioned? It’s not cheap. If your code is constantly triggering stack growth, you’ll pay for it in performance. This is why you should generally avoid putting massive arrays or very deep recursion on the stack. The compiler can often optimize small arrays away, but if you need [10000]int, you’re better off putting it on the heap (by making it a slice with make, for instance). The heap is designed for large, variable-sized allocations. Use the right tool for the job.
The Stack Shrinks, Too
Here’s the other beautiful part: Go’s runtime is just as aggressive about giving memory back. If a goroutine’s stack usage drops significantly and remains low, the runtime will eventually copy its stack to a smaller allocation and free the big one. This keeps your long-lived goroutines (like background workers) from holding onto massive stacks they used one time during a spike hours ago. It’s frugal, and I respect it.
One Big Gotcha: Pointers and runtime.KeepAlive
Because the stack can be moved at any function call, you have to be careful with pointers that escape the stack. This is mostly handled for you, but it can bite you in rare, advanced scenarios involving unsafe. If you ever store a pointer to a stack variable and then try to use it after the goroutine that owns the stack has resumed (and potentially moved), you’re in for a world of pain. The runtime function runtime.KeepAlive exists precisely to guard against the over-enthusiastic garbage collector in these edge cases. If you don’t know what that means, consider it a good sign—you’re probably writing safe, normal Go code.
The takeaway? Stop worrying about stack sizes. The runtime has your back. Just write your code, and let it handle the grunt work of memory management. It’s one of those things that works so well you almost forget it’s there—until you try to do the same thing in another language and suddenly miss your brilliant, frugal, stack-copying friend.