I launched one hundred thousand sleeping goroutines and expected memory use to resemble one hundred thousand operating-system threads. It did not. The programme was not free, but it was possible, which made the stack implementation more interesting than the programme.

The trigger can be reduced to this:

ch := make(chan bool)
for i := 0; i < 100000; i++ {
	go func() {
		<-ch
	}()
}

An operating-system thread traditionally reserves a comparatively large contiguous stack. Multiplying that reservation by one hundred thousand would end the experiment quickly. A Go 1 goroutine starts with a small stack segment and grows as calls require more space. Most of these goroutines call very little before blocking, so most never need a large stack.

The mechanism begins in compiled function entry code. Before a function consumes its frame, generated code checks whether the current stack segment has sufficient room. If it does, execution continues normally. If not, control enters the runtime, which arranges more stack space and resumes the call. The programme sees an ordinary function call; the compiler and runtime cooperate to make the stack conditional.

In this Go 1 implementation, growth uses segmented stacks. A new stack segment can be allocated and linked to the older segment rather than requiring one giant contiguous reservation from the start. The runtime records enough information to return across the segment boundary later. As calls unwind, it can move back to the preceding segment.

My surprise was that “small stacks” really means “small initial commitment plus machinery.” Each goroutine still needs a descriptor, scheduling state, stack memory, and any heap data referenced by its closure. One hundred thousand blocked goroutines consume real memory. They simply avoid paying in advance for deep call chains they may never execute.

Recursion demonstrates growth more directly:

func descend(n int) int {
	var pad [128]byte
	pad[0] = byte(n)
	if n == 0 {
		return int(pad[0])
	}
	return int(pad[0]) + descend(n-1)
}

Each call needs a frame, so eventually a stack check fails and the runtime grows the goroutine’s stack. The exact initial size, check sequence, and segment bookkeeping are implementation details. They are useful when reading assembly or runtime source, but they are not values a Go 1 programme should encode.

Stack growth interacts with pointers. The garbage collector and runtime must know where active frames and pointers live while goroutines stop, resume, and cross segments. A goroutine blocked in a channel operation still has reachable values on its stack. Those values cannot be reclaimed merely because the goroutine is not currently executing.

The scheduler is another separate layer. Cheap stacks make large goroutine counts feasible, but they do not create one kernel thread per goroutine. The old scheduler multiplexes runnable goroutines onto operating-system threads. A blocked goroutine can be parked while another runs. Later scheduler designs should not be projected backward onto the runtime installed here.

Segmented growth has a cost pattern worth noting. If execution repeatedly crosses a stack boundary near a hot call, allocating and discarding segments can become expensive. This is sometimes called a hot split. It is a runtime implementation concern rather than permission to avoid functions, but it explains why stack strategy can appear in profiles of otherwise ordinary code.

I prefer goroutines because they express independent activities and communication clearly, not because a benchmark proves I can manufacture six figures of them. A goroutine should still have a lifetime, an owner for cancellation, and a reason to exist. Leaking a cheap goroutine only makes the leak more affordable, not correct.

The caveat in my test is the closure and channel. Every goroutine waits forever unless the channel is closed, and closing it releases a runnable herd that has to be scheduled. Measuring only the quiet blocked state ignores startup and wake-up costs. A realistic service also has per-request buffers, queues, and other data that may dwarf the initial stack.

The result is a useful division of labour. I write straightforward calls and let stacks grow when necessary. The compiler inserts checks, the runtime supplies segments, and the scheduler runs whichever goroutines are ready. It is not magic; it is several carefully placed pieces of bookkeeping pretending not to interrupt the function. That is close enough to magic for a Wednesday, but much easier to profile.