5.1 栈增长与连续栈
goroutine 的栈是可以长大的:初始只有 2 KiB,函数调用一深,运行时会把它换成更大的。但「换栈」在 Go 里对程序是透明的——你持有的局部变量地址不会失效,闭包捕获的指针不会变成野指针。这背后是一套「连续栈」机制:栈不够就整块复制到新地址,并把所有指向旧栈的指针重新定位。
本节用递归把「帧布局」和「栈复制」两件事都测出来,再回到源码看这三步分别在哪个函数里发生。
本节要回答:goroutine 的栈怎么长、长大时代价多大、为什么程序感知不到?结论是:帧在栈上连续排布(实测相邻帧间距恒为 160 字节);栈满时运行时把旧栈整块
memmove到新地址(容量翻倍),并调用adjustpointers把所有指向旧栈的指针重定位,因此同一帧的&局部变量在复制前后数值不变。 本机实测:2 万层递归触发 8 次栈复制,StackInuse从 256 KiB 涨到 4352 KiB。
5.1.1 实验:帧间距、栈复制与 StackInuse
复现基线:
- Go 工具链
go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0) - 机器:Apple M1 Pro,10 核,32 GiB(
sysctl -n hw.ncpu= 10) - 未开启
-race;GOGC默认,GOMAXPROCS默认(10) - 递归深度固定 20000 层,单 goroutine;每个
recurse帧含一个[48]byte局部数组 - 记录每层帧内局部变量的地址,跑 3 遍,结论稳定
程序把每层帧的 &frame[0] 记进一个全局切片,再用相邻两层地址之差观察帧布局:
var marks []uintptr
//go:noinline
func recurse(depth int) {
var frame [48]byte
frame[0] = byte(depth)
marks = append(marks, uintptr(unsafe.Pointer(&frame[0])))
if depth > 0 {
recurse(depth - 1)
}
runtime.KeepAlive(frame[0])
}
实测输出:
$ GOTOOLCHAIN=go1.27.0 go run .
启动 StackInuse = 256 KiB
frames: 20001
level addr delta
1 18326647981560 160
2 18326647981400 160
3 18326647981240 160
4 18326647981080 160
5 18326647980920 160
大跳变(栈复制)次数: 8
深递归后 StackInuse = 4352 KiB
同一帧 &marker: before=135515548804775 after=135515548804775 equal=true
四行数字,四个结论:
- 相邻帧间距恒为 160 字节。这就是「连续栈」的直接证据:帧在栈上一个挨一个排布,
delta不抖动。[48]byte数组加上对齐、保存的寄存器、返回地址等,正好凑成 160。也说明递归 2 万层的纯帧开销约 3.2 MB(20000 × 160)。 - 出现 8 次「大跳变」。跳变处相邻地址的差值远超帧间距(甚至为负),意味着「第 N 层记下的地址」和「第 N+1 层记下的地址」不在同一段内存里——中间发生了栈整体搬家。2 万层触发 8 次,与「每次翻倍」的模型吻合:本 goroutine 从 16 KiB 起步,翻倍 8 次到达 4 MiB(16 KiB → 32 KiB → …… → 4 MiB)。
StackInuse从 256 KiB 涨到 4352 KiB(约 17 倍)。注意它比「纯帧开销 3.2 MB」大——因为栈是按 2 的幂预留的,最后一档是 4 MiB,实际驻留 4352 KiB。- 同一帧
&marker在深调用前后完全相等(equal=true)。这是「透明性」的证据:尽管底层栈搬了家,程序看到的地址没变,因为运行时把所有指向旧栈的指针都改成了新地址。
第 4 点是整节最反直觉的地方,也最容易被误读成「没发生复制」。真实情况是:地址被调整过,只是调整后的值与调整前恰好相等——因为旧地址被改写成「旧地址 + 偏移量」,而这个偏移量正是新旧栈的差值。程序拿到的永远是新栈上的有效地址。
再补一个实验:把递归深度从 1000 拉到 64000,看 StackInuse 的增量是否线性——这能验证「栈成本 = 帧大小 × 深度」的模型,并观察是否出现明显的「翻倍台阶」。
func measure(depth int) uint64 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
base := m.StackInuse
_ = rec(depth)
runtime.ReadMemStats(&m)
peak := m.StackInuse
runtime.GC()
runtime.ReadMemStats(&m)
return peak - base
}
实测输出:
$ GOTOOLCHAIN=go1.27.0 go run .
深度 1000 峰值 StackInuse 增量 ≈ 96 KiB
深度 4000 峰值 StackInuse 增量 ≈ 448 KiB
深度 16000 峰值 StackInuse 增量 ≈ 1792 KiB
深度 64000 峰值 StackInuse 增量 ≈ 7168 KiB
四点增量基本按深度线性增长(约每层 100–115 字节,与 160 字节帧同量级,差额来自最后一块栈的 2 的幂预留)。没有出现「深度翻 4 倍、内存翻 16 倍」的爆炸——这正是「翻倍增长 + 摊还复制」的效果:栈容量按 2 的幂阶梯上升,但每一档都被充分利用,不会因为增长策略本身浪费数量级的内存。
5.1.2 源码:栈检查、翻倍、指针重定位
第一步是栈检查。它由编译器在每个函数序言里插入,不是运行时函数。用 -S 看一个含大局部数组的函数:
$ GOTOOLCHAIN=go1.27.0 go build -gcflags='-S' -o /dev/null .
main.big STEXT size=160 align=0x0 args=0x8 locals=0x28 funcid=0x0
0x0000 00000 (/tmp/gbrt2/stack/chk.go:6) TEXT main.big(SB), ABIInternal, $48-8
0x0000 00000 (/tmp/gbrt2/stack/chk.go:6) MOVD 16(g), R16
0x0004 00004 (/tmp/gbrt2/stack/chk.go:6) CMP R16, RSP
0x0008 00008 (/tmp/gbrt2/stack/chk.go:6) BLS 136
0x000c 00012 (/tmp/gbrt2/stack/chk.go:6) PCDATA $0, $-1
0x000c 00012 (/tmp/gbrt2/stack/chk.go:6) MOVD.W R30, -48(RSP)
这三条指令是整件事的入口:
MOVD 16(g), R16:把g.stackguard0读进 R16。偏移 16 不是巧合——src/runtime/runtime2.go里g的前两个字段是stack(16 字节)与stackguard0,注释直接写着「stackguard0 is the stack pointer compared in the Go stack growth prologue」。CMP R16, RSP:比较当前栈指针与警戒线。BLS 136:若RSP <= stackguard0,跳到偏移 136 处调用runtime.morestack(无符号「小于等于」跳转)。
stackguard0 的值来自 src/runtime/stack.go 的常量:
const (
// The minimum size of stack used by Go code
stackMin = 2048
...
// The stack guard is a pointer this many bytes above the
// bottom of the stack.
stackGuard = stackNosplit + stackSystem + abi.StackSmall
)
stackMin = 2048 就是「2 KiB 初始栈」的来源(fixedStack 再把它向上取整到 2 的幂)。警戒线 stackGuard 由「不分裂函数链的上限 + OS 专用空间 + StackSmall(128 字节)」组成,其中 StackSmall = 128、StackBig = 4096 定义在 src/internal/abi/stack.go。
第二步是翻倍。runtime.morestack(汇编)最终跳到 src/runtime/stack.go:newstack,它算出新尺寸:
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
...
if newsize > maxstacksize || newsize > maxstackceiling {
if maxstacksize < maxstackceiling {
print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")
} else {
print("runtime: goroutine stack exceeds ", maxstackceiling, "-byte limit\n")
}
print("runtime: sp=", hex(sp), " stack=[", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
throw("stack overflow")
}
...
copystack(gp, newsize)
newsize := oldsize * 2 就是 5.1.1 里 8 次跳变的由来。上限 maxstacksize 在 64 位平台是 1 GB(src/runtime/proc.go 里 maxstacksize = 1000000000),32 位是 250 MB。超过就 throw("stack overflow")——这是不可恢复的,因为此时已经没有栈可以执行 panic 处理了。
第三步是复制与指针重定位,在 src/runtime/stack.go:copystack:
func copystack(gp *g, newsize uintptr) {
...
old := gp.stack
used := old.hi - gp.sched.sp
gcController.addScannableStack(getg().m.p.ptr(), int64(newsize)-int64(old.hi-old.lo))
// allocate new stack
new := stackalloc(uint32(newsize))
...
var adjinfo adjustinfo
adjinfo.old = old
adjinfo.delta = new.hi - old.hi
...
// 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.
adjustctxt(gp, &adjinfo)
adjustdefers(gp, &adjinfo)
adjustpanics(gp, &adjinfo)
...
// Swap out old stack for new one
gp.stack = new
gp.stackguard0 = new.lo + stackGuard
gp.sched.sp = new.hi - used
memmove 是「搬家」,紧随其后的 adjustctxt / adjustdefers / adjustpanics 是「改地址」。真正扫描帧内指针、逐个重定位的是 src/runtime/stack.go:adjustpointers:
func adjustpointers(scanp unsafe.Pointer, bv *bitvector, adjinfo *adjustinfo, f funcInfo) {
...
if minp <= p && p < maxp {
...
*(*uintptr)(vpp) = p + adjinfo.delta
...
}
}
p + adjinfo.delta 就是 5.1.1 里 equal=true 的机制:旧栈地址被加上「新旧栈的高地址之差」,从而指向新栈的对应位置。注意它依赖 bv(该帧的精确指针位图)——只有编译器知道每个帧的哪个位置是指针。这也是为什么栈复制只对 Go 函数帧安全,对 cgo 帧、汇编帧就不一定(isShrinkStackSafe 里对 syscall 做了排除)。
中间那一步「从 morestack 到 newstack」发生在汇编里。src/runtime/asm_arm64.s:runtime.morestack 先把当前函数的上下文(SP/BP/PC/LR/CTXT)存进 g.sched,再切到 g0 栈去执行 newstack:
TEXT runtime·morestack(SB),NOSPLIT|NOFRAME,$0-0
// Cannot grow scheduler stack (m->g0).
MOVD g_m(g), R8
MOVD m_g0(R8), R4
// Called from f.
// Set g->sched to context in f
MOVD RSP, R0
MOVD R0, (g_sched+gobuf_sp)(g)
MOVD R29, (g_sched+gobuf_bp)(g)
MOVD LR, (g_sched+gobuf_pc)(g)
栈增长必须在 g0 上做——理由很直接:要搬的正是当前 goroutine 的栈,不能在「正在被搬的那块内存」上执行搬运代码。
还有两个细节值得记住:
- 栈也会缩。
src/runtime/stack.go:shrinkstack在 GC 时把**使用量不足四分之一(空闲超过四分之三)**的栈缩到oldsize / 2,下界是fixedStack。所以StackInuse不是只增不减的——上文的measure在每次测量后调用runtime.GC(),正是为了让下一轮测量从收缩后的基线开始。 maxstackceiling与maxstacksize是两个量:maxstacksize是当前限制(初始 1 MiB,runtime.main启动时改到 1 GB),maxstackceiling是硬顶。debug.SetMaxStack改的是前者。
5.1.3 决策:栈相关的取舍
| 现象 / 需求 | 源码依据 | 决策 |
|---|---|---|
递归很深、StackInuse 很大 | 每帧 ≈ 帧大小,2 万层 × 160 B ≈ 3.2 MB | 深递归先估帧大小;能改成迭代就改,帧开销是 O(深度) |
| 看到「地址跳变」,担心指针失效 | copystack + adjustpointers 会重定位 | 不用管:程序持有的指针会被自动修正,这正是连续栈的意义 |
| 需要「不搬家的栈」(如传给 cgo) | isShrinkStackSafe 排除 syscall 帧 | 别把栈上指针跨 cgo 边界长期持有;用 C.malloc 或堆内存 |
| goroutine 开很多(几十万) | 初始栈 2 KiB(stackMin),按需增长 | goroutine 便宜是因为初始栈小且惰性增长;开百万 goroutine 的关键是让每个 goroutine 别用太深的栈 |
| 想给栈设上限、防止爆栈 | maxstacksize / debug.SetMaxStack | 用 debug.SetMaxStack 收紧;但注意这是全局限制,不是 per-goroutine |
| 频繁栈复制拖慢 | 每次翻倍,复制是 O(已用字节) | 让热路径函数帧别太大(少放大局部数组),减少复制频率与单次复制量 |
| 帧大小异常大 | 编译器按局部变量与对齐算 | 大局部数组会逃逸到堆(HeapAllocReason),帧本身不会无界膨胀;用 -m 确认 |
一条核心判断:栈复制是摊还成本。每次复制把容量翻倍,所以 N 次增长总共复制了约 2×最终大小,而不是 N×。实测 2 万层只复制 8 次就是这个模型的体现——真正要担心的不是「复制次数」,而是单次复制的字节数(栈越大,一次 memmove 越贵),以及过大的函数帧(既增加复制量,又更容易触发增长)。
阅读导航:上一节:4.3 分配热点定位与对象复用 · 下一节:5.2 结构体对齐、padding 与缓存行 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。