Go Grows Goroutine Stacks by Copying Them: The 2 KB to 1 GB Journey
Every goroutine starts with a 2 KB stack and grows by doubling: the runtime allocates a bigger stack, copies the frames across, and then rewrites every pointer in every frame so it still points at the right object.
TL;DR: A goroutine gets a 2 KB stack and nothing more. When it outgrows that, the function prologue notices, calls
morestack, and the runtime allocates a stack twice as big, copies the live frames into it, and rewrites every pointer in every frame so it still points at the right object. The stack can double all the way to 1 GB, and it shrinks again when a goroutine gets quiet.
Every goroutine in Go looks like it has an infinite stack. It does not. It has 2 KB, and when that runs out the runtime does something that would be absurd in a systems language: it moves the stack.
This is a walk through how that works, based on the Go 1.28 development tree.
The starting point: 2 KB
The constants are all in one place:
// src/runtime/stack.go:70
const (
// stackSystem is a number of additional bytes to add
// to each stack below the usual guard area for OS-specific
// purposes like signal handling. Used on Windows, Plan 9,
// and iOS because they do not use a separate stack.
stackSystem = goos.IsWindows*4096 + goos.IsPlan9*512 + goos.IsIos*goarch.IsArm64*1024
// The minimum size of stack used by Go code
stackMin = 2048The minimum stack is rounded up to a power of two, because every stack the runtime allocates is a power of two:
// src/runtime/stack.go:80
// The minimum stack size to allocate.
// The hackery here rounds fixedStack0 up to a power of 2.
fixedStack0 = stackMin + stackSystem
fixedStack1 = fixedStack0 - 1
fixedStack2 = fixedStack1 | (fixedStack1 >> 1)
fixedStack3 = fixedStack2 | (fixedStack2 >> 2)
fixedStack4 = fixedStack3 | (fixedStack3 >> 4)
fixedStack5 = fixedStack4 | (fixedStack4 >> 8)
fixedStack6 = fixedStack5 | (fixedStack5 >> 16)
fixedStack = fixedStack6 + 1stackMin is 2048. On Linux stackSystem is zero, so fixedStack is also
2048 — the runtime rounds the minimum up to a power of two because every
stack it allocates is a power of two.
A new goroutine gets exactly that:
// src/runtime/proc.go:5317
// Allocate a new g, with a stack big enough for stacksize bytes.
func malg(stacksize int32) *g {
newg := new(g)
if stacksize >= 0 {
stacksize = round2(stackSystem + stacksize)
systemstack(func() {
newg.stack = stackalloc(uint32(stacksize))
if valgrindenabled {
newg.valgrindStackID = valgrindRegisterStack(unsafe.Pointer(newg.stack.lo), unsafe.Pointer(newg.stack.hi))
}
})
newg.stackguard0 = newg.stack.lo + stackGuard
newg.stackguard1 = ^uintptr(0)
// Clear the bottom word of the stack. We record g
// there on gsignal stack during VDSO on ARM and ARM64.
*(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0
}
return newg
}Two things to notice. First, stackguard0 is set to stack.lo + stackGuard
— a pointer a little way above the bottom of the stack. Second, stackguard1
is ^uintptr(0), meaning “there is no valid system-stack bound here”; the
runtime uses that to catch a goroutine trying to run system-stack code.
The size classes
Stacks come in power-of-two sizes, grouped into orders:
// src/runtime/malloc.go:135
// Per-P, per order stack segment cache size.
_StackCacheSize = 32 * 1024
// Number of orders that get caching. Order 0 is FixedStack
// and each successive order is twice as large.
// We want to cache 2KB, 4KB, 8KB, and 16KB stacks. Larger stacks
// will be allocated directly.
// Since FixedStack is different on different systems, we
// must vary NumStackOrders to keep the same maximum cached size.
// OS | FixedStack | NumStackOrders
// -----------------+------------+---------------
// linux/darwin/bsd | 2KB | 4
// windows/32 | 4KB | 3
// windows/64 | 8KB | 2
// plan9 | 4KB | 3
_NumStackOrders = 4 - goarch.PtrSize/4*goos.IsWindows - 1*goos.IsPlan9So on Linux the ladder is:
| Order | Size | Where it comes from |
|---|---|---|
| 0 | 2 KB | per-P cache, then global stack pool |
| 1 | 4 KB | per-P cache, then global stack pool |
| 2 | 8 KB | per-P cache, then global stack pool |
| 3 | 16 KB | per-P cache, then global stack pool |
| 4 | 32 KB | dedicated span from the heap |
| 5 | 64 KB | dedicated span |
| … | … | … |
| 19 | 1 GB | the hard ceiling on 64-bit |
The per-P cache is what keeps the common case cheap. stackalloc first looks
in the P’s own cache, and only goes to the global pool (or the heap) when the
cache is empty:
// src/runtime/stack.go:368
// Small stacks are allocated with a fixed-size free-list allocator.
// If we need a stack of a bigger size, we fall back on allocating
// a dedicated span.
var v unsafe.Pointer
if n < fixedStack<<_NumStackOrders && n < _StackCacheSize {
order := uint8(0)
n2 := n
for n2 > fixedStack {
order++
n2 >>= 1
}
var x gclinkptr
if stackNoCache != 0 || thisg.m.p == 0 || thisg.m.preemptoff != "" {
// thisg.m.p == 0 can happen in the guts of exitsyscall
// or procresize. Just get a stack from the global pool.
// Also don't touch stackcache during gc
// as it's flushed concurrently.
lock(&stackpool[order].item.mu)
x = stackpoolalloc(order)
unlock(&stackpool[order].item.mu)
} else {
c := thisg.m.p.ptr().mcache
x = c.stackcache[order].list
if x.ptr() == nil {
stackcacherefill(c, order)
x = c.stackcache[order].list
}
c.stackcache[order].list = x.ptr().next
c.stackcache[order].size -= uintptr(n)
}The order is computed by shifting: order = log2(size / fixedStack). That
loop is the entire size-class lookup.
The check at function entry
Nobody checks the stack pointer on every instruction. The check happens once,
in the prologue of every function, and the runtime documents the sequences in
a comment at the top of stack.go:
// src/runtime/stack.go:33
guard = g->stackguard
frame = function's stack frame size
argsize = size of function arguments (call + return)
stack frame size <= StackSmall:
CMPQ guard, SP
JHI 3(PC)
MOVQ m->morearg, $(argsize << 32)
CALL morestack(SB)
stack frame size > StackSmall but < StackBig
LEAQ (frame-StackSmall)(SP), R0
CMPQ guard, R0
JHI 3(PC)
MOVQ m->morearg, $(argsize << 32)
CALL morestack(SB)
stack frame size >= StackBig:
MOVQ m->morearg, $((argsize << 32) | frame)
CALL morestack(SB)One comparison and a conditional branch for small frames. Large frames skip
the comparison entirely and always call morestack.
That prologue is emitted by the assembler backend, not the compiler, and it is bracketed by an unsafe point so that an asynchronous preemption cannot land in the middle of it:
// src/cmd/internal/obj/x86/obj6.go:1030
if framesize <= abi.StackSmall {
// small stack: SP <= stackguard
// CMPQ SP, stackguard
p = obj.Appendp(p, newprog)
p.As = cmp
p.From.Type = obj.TYPE_REG
p.From.Reg = REG_SP
p.To.Type = obj.TYPE_MEM
p.To.Reg = rg
p.To.Offset = 2 * int64(ctxt.Arch.PtrSize) // G.stackguard0
if cursym.CFunc() {
p.To.Offset = 3 * int64(ctxt.Arch.PtrSize) // G.stackguard1
}
// Mark the stack bound check and morestack call async nonpreemptible.
// If we get preempted here, when resumed the preemption request is
// cleared, but we'll still call morestack, which will double the stack
// unnecessarily. See issue #35470.
p = ctxt.StartUnsafePoint(p, newprog)
} else if framesize <= abi.StackBig {morestack: the trampoline
When the check fails, the prologue calls morestack, which is pure assembly.
It saves the caller’s context into the M and jumps to newstack on the
system stack:
// src/runtime/asm_amd64.s:586
TEXT runtime·morestack(SB),NOSPLIT|NOFRAME,$0-0
// Cannot grow scheduler stack (m->g0).
get_tls(CX)
MOVQ g(CX), DI // DI = g
MOVQ g_m(DI), BX // BX = m
// Set g->sched to context in f.
MOVQ 0(SP), AX // f's PC
MOVQ AX, (g_sched+gobuf_pc)(DI)
LEAQ 8(SP), AX // f's SP
MOVQ AX, (g_sched+gobuf_sp)(DI)
MOVQ BP, (g_sched+gobuf_bp)(DI)
MOVQ DX, (g_sched+gobuf_ctxt)(DI)
MOVQ m_g0(BX), SI // SI = m.g0
CMPQ DI, SI
JNE 3(PC)
CALL runtime·badmorestackg0(SB)
CALL runtime·abort(SB)The m->g0 check is there to catch “the scheduler’s own stack overflowed”,
which cannot be fixed by growing it.
newstack: grow, shrink, or get out of the way
newstack is where the decision is made. It is not always a growth: the same
entry point is used to deliver a preemption request, and to perform a
pending shrink.
// src/runtime/stack.go:1105
// NOTE: stackguard0 may change underfoot, if another thread
// is about to try to preempt gp. Read it just once and use that same
// value now and below.
stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
// Be conservative about where we preempt.
// We are interested in preempting user Go code, not runtime code.
// If we're holding locks, mallocing, or preemption is disabled, don't
// preempt.
// This check is very early in newstack so that even the status change
// from Grunning to Gwaiting and back doesn't happen in this case.
// That status change by itself can be viewed as a small preemption,
// because the GC might change Gwaiting to Gscanwaiting, and then
// this goroutine has to wait for the GC to finish before continuing.
// If the GC is in some way dependent on this goroutine (for example,
// it needs a lock held by the goroutine), that small preemption turns
// into a real deadlock.
preempt := stackguard0 == stackPreemptThere it is: the runtime overloads the stack bound check to deliver
preemptions. stackguard0 is not just a bound; it is a mailbox. The values
it can hold are documented right in stack.go:
// src/runtime/stack.go:124
const (
uintptrMask = 1<<(8*goarch.PtrSize) - 1
// The values below can be stored to g.stackguard0 to force
// the next stack check to fail.
// These are all larger than any real SP.
// Goroutine preemption request.
// 0xfffffade in hex.
stackPreempt = uintptrMask & -1314
// Thread is forking. Causes a split stack check failure.
// 0xfffffb2e in hex.
stackFork = uintptrMask & -1234
// Force a stack movement. Used for debugging.
// 0xfffffeed in hex.
stackForceMove = uintptrMask & -275
// stackPoisonMin is the lowest allowed stack poison value.
stackPoisonMin = uintptrMask & -4096
)All of these are larger than any real stack pointer, so comparing SP against
them always fails, and the prologue always falls into morestack.
Growing: always double
If it really is a growth, the new stack is twice the old one — with one exception, a frame so large that doubling would not be enough:
// src/runtime/stack.go:1177
// Allocate a bigger segment and move the stack.
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
// Make sure we grow at least as much as needed to fit the new frame.
// (This is just an optimization - the caller of morestack will
// recheck the bounds on return.)
if f := findfunc(gp.sched.pc); f.valid() {
max := uintptr(funcMaxSPDelta(f))
needed := max + stackGuard
used := gp.stack.hi - gp.sched.sp
for newsize-used < needed {
newsize *= 2
}
}Doubling is what makes growth amortised O(1): a goroutine that ends up needing 1 MB has paid for 2 KB, 4 KB, 8 KB and so on, and the total bytes copied is about twice the final size.
The ceiling is 1 GB on 64-bit:
// src/runtime/proc.go:159
// Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.
// Using decimal instead of binary GB and MB because
// they look nicer in the stack overflow failure message.
if goarch.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}copystack: the move itself
The copy is the interesting part, because a stack is full of pointers into
itself. copystack allocates the new stack, copies the bytes, and then
rewrites the pointers:
// src/runtime/stack.go:929
func copystack(gp *g, newsize uintptr) {
if gp.syscallsp != 0 {
throw("stack growth not allowed in system call")
}
old := gp.stack
if old.lo == 0 {
throw("nil stackbase")
}
used := old.hi - gp.sched.sp
// Add just the difference to gcController.addScannableStack.
// g0 stacks never move, so this will never account for them.
// It's also fine if we have no P, addScannableStack can deal with
// that case.
gcController.addScannableStack(getg().m.p.ptr(), int64(newsize)-int64(old.hi-old.lo))
// allocate new stack
new := stackalloc(uint32(newsize))
if stackPoisonCopy != 0 {
fillstack(new, 0xfd)
}and later:
// src/runtime/stack.go:984
// Copy the stack (or the rest of it) to the new location
memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)
// Adjust remaining structures that have pointers into stacks.
// We have to do most of these before we traceback the new
// stack because gentraceback uses them.
adjustctxt(gp, &adjinfo)
adjustdefers(gp, &adjinfo)
adjustpanics(gp, &adjinfo)
if adjinfo.sghi != 0 {
adjinfo.sghi += adjinfo.delta
}
// Swap out old stack for new one
gp.stack = new
gp.stackguard0 = new.lo + stackGuard // NOTE: might clobber a preempt request
gp.sched.sp = new.hi - used
gp.stktopsp += adjinfo.delta
// Adjust pointers in the new stack.
var u unwinder
for u.init(gp, 0); u.valid(); u.next() {
adjustframe(&u.frame, &adjinfo)
}The delta is the distance between the two stacks. Every pointer that pointed
into the old stack gets that delta added.
Two wrinkles show up in that code. First, gp.stackguard0 = new.lo + stackGuard has a comment admitting it “might clobber a preempt request” —
the copy has to restore the bound, and a pending preemption has to be
re-requested. Second, the sudogs (channel wait records) can point into the
stack, and a concurrent channel operation can be writing to that memory
right now, so copystack takes the channel locks and copies the risky region
under them.
Adjusting the pointers
The adjustment itself is simple arithmetic, but the runtime must know which words in a frame are pointers. That information comes from the compiler, as a bitmap per frame:
// src/runtime/stack.go:652
func adjustpointers(scanp unsafe.Pointer, bv *bitvector, adjinfo *adjustinfo, f funcInfo) {
minp := adjinfo.old.lo
maxp := adjinfo.old.hi
delta := adjinfo.delta
num := uintptr(bv.n)
// If this frame might contain channel receive slots, use CAS
// to adjust pointers. If the slot hasn't been received into
// yet, it may contain stack pointers and a concurrent send
// could race with adjusting those pointers. (The sent value
// itself can never contain stack pointers.)
useCAS := uintptr(scanp) < adjinfo.sghi
for i := uintptr(0); i < num; i += 8 {The bitmap walk is the hot part: for each byte of the pointer bitmap, find
the set bits with TrailingZeros8 and adjust only the words that are
actually pointers.
The per-frame driver uses the same machinery:
// src/runtime/stack.go:757
f := frame.fn
if stackDebug >= 2 {
print(" adjusting ", funcname(f), " frame=[", hex(frame.sp), ",", hex(frame.fp), "] pc=", hex(frame.pc), " continpc=", hex(frame.continpc), "\n")
}
locals, args, objs := frame.getStackMap(true)
// Adjust local variables if stack frame has been allocated.
if locals.n > 0 {
size := uintptr(locals.n) * goarch.PtrSize
adjustpointers(unsafe.Pointer(frame.varp-size), &locals, adjinfo, f)
}
// Adjust arguments.
if args.n > 0 {
if stackDebug >= 3 {
print(" args\n")
}
adjustpointers(unsafe.Pointer(frame.argp), &args, adjinfo, funcInfo{})
}The stack map has three parts: locals, arguments, and stack objects (the compiler’s term for pointers to heap-allocated structs that live in the frame). All three have to be fixed up, plus the saved frame pointer, the goroutine’s saved context, deferred calls, active panics and sudogs.
Shrinking is deliberately conservative
Growing is easy; giving memory back is not. The runtime only shrinks when the goroutine is using less than a quarter of its stack:
// src/runtime/stack.go:1313
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize / 2
// Don't shrink the allocation below the minimum-sized stack
// allocation.
if newsize < fixedStack {
return
}
// Compute how much of the stack is currently in use and only
// shrink the stack if gp is using less than a quarter of its
// current stack. The currently used stack includes everything
// down to the SP plus the stack guard space that ensures
// there's room for nosplit functions.
avail := gp.stack.hi - gp.stack.lo
if used := gp.stack.hi - gp.sched.sp + stackNosplit; used >= avail/4 {
return
}And even then, it only shrinks when it is safe to know where the pointers are:
// src/runtime/stack.go:1241
// isShrinkStackSafe returns whether it's safe to attempt to shrink
// gp's stack. Shrinking the stack is only safe when we have precise
// pointer maps for all frames on the stack. The caller must hold the
// _Gscan bit for gp or must be running gp itself.
func isShrinkStackSafe(gp *g) bool {
// We can't copy the stack if we're in a syscall.
// The syscall might have pointers into the stack and
// often we don't have precise pointer maps for the innermost
// frames.
if gp.syscallsp != 0 {
return false
}
// We also can't copy the stack if we're at an asynchronous
// safe-point because we don't have precise pointer maps for
// all frames.
if gp.asyncSafePoint {
return false
}A goroutine in a syscall cannot be shrunk, because the kernel may be holding a pointer into the stack, and the runtime has no pointer map for the innermost frames of a syscall.
The starting size is adaptive
One more surprise: new goroutines do not always start with 2 KB. The runtime tracks the average stack size observed during the last GC cycle and starts new goroutines at that size.
// src/runtime/stack.go:1415
// startingStackSize is the amount of stack that new goroutines start with.
// It is a power of 2, and between fixedStack and maxstacksize, inclusive.
// startingStackSize is updated every GC by tracking the average size of
// stacks scanned during the GC.
var startingStackSize uint32 = fixedStackThis is off by default (debug.adaptivestackstart), but it explains why the
same program can have goroutines with different starting stack sizes on
different runs.
Trade-offs
What copying buys you. Goroutines get a small, cheap stack by default (2 KB of address space, one cache-friendly allocation) and an unbounded one on demand, with no fixed reservation and no stack-overflow crash until the 1 GB ceiling.
What it costs. Every growth copies the live stack and walks every frame adjusting pointers, under the assumption that the compiler’s pointer maps are right. A goroutine that recurses deeply pays O(n) copying on the way up and O(n) copying on the way down when it shrinks again.
Doubling versus exact sizing. Doubling wastes up to half the stack, but makes growth amortised constant-time. A runtime that sized stacks exactly would copy on almost every call.
The 1/4 shrink threshold. Shrinking aggressively would mean copying the stack back and forth constantly on a goroutine whose depth oscillates around a boundary. The quarter threshold makes shrinking rare and cheap.
Caveats
Stack overflow is a crash, not an error. Exceed 1 GB and the runtime
prints runtime: goroutine stack exceeds 1000000000-byte limit and throws
“stack overflow”, which kills the process. There is no way to recover, and
runtime/debug.SetMaxStack can only raise the ceiling as far as the
runtime’s 32-bit stack allocator allows (twice the default limit).
Cgo code runs on a different stack. A goroutine calling into C has its C frames on the system stack, which is a fixed, much smaller allocation. Deep C recursion will blow the C stack, not the Go one.
Assembly functions are not scanned precisely. That is one reason the
runtime refuses to shrink a stack that contains assembly frames, and one
reason isAsyncSafePoint rejects assembly PCs.
runtime.SetFinalizer, defer and recover all interact with stack
copying. The runtime explicitly adjusts deferred calls and active panics
during a copy, but the interaction is subtle enough that runtime/stack_test.go
grows stacks inside finalizers, inside LockOSThread goroutines and inside
panics to shake out bugs.
A goroutine’s stack is not one allocation. Since the runtime moved stacks to the heap, a goroutine’s stack is a span with a free list of stack-sized slots, and the per-P cache holds up to 32 KB per order. A program with a million goroutines has a million stack slots’ worth of address space reserved in the heap, even if most goroutines never use more than a few hundred bytes.
Wrapping up
The one thing to remember: Go stacks are not fixed. They start at 2 KB,
double when the function prologue’s stackguard0 check trips, and are
copied to the new address — which means every pointer in every frame has to
be rewritten by the distance between the old and new stack.
The part people get wrong: the stack bound check does double duty. The
same comparison that detects overflow is how the runtime delivers
asynchronous preemption requests (stackPreempt) and how it forces a stack
move for debugging (stackForceMove). The stack bound is a mailbox, not just
a limit.
What to go do about it: if you have a workload that recurses deeply, look
at runtime.ReadMemStats before and after — stack growth shows up as
StackInuse churn. And if you want to see the machinery, run with
GODEBUG=invalidptr=1 to catch bad pointers, or read
src/runtime/stack_test.go, which deliberately grows stacks in the places
that are hardest to get right.
Further reading
src/runtime/stack.go— the whole story: stack layout constants,stackalloc/stackfree,newstack,copystack,adjustframe,shrinkstack, and the adaptive starting size.src/runtime/panic.go—morestack, the entry point the prologue calls.src/runtime/runtime2.go— thegstruct:stack,stackguard0,stackguard1,preempt.src/runtime/asm_amd64.s— the assemblymorestacktrampoline.src/runtime/malloc.go—_StackCacheSizeand_NumStackOrders, the size-class ladder.src/runtime/proc.go—malg(stack allocation for a new goroutine) and the 1 GBmaxstacksize.src/runtime/stack_test.go— the tests that grow stacks inside finalizers, locked goroutines and panics.src/cmd/internal/obj/x86/obj6.go— the function prologue that emits the stack check and the unsafe point around it.src/internal/abi/symtab.go—PCDATA_UnsafePointand the pointer maps the runtime uses to fix up frames.