《Go 语言运行时原理》5.1 栈增长与连续栈

用 2 万层递归实测 goroutine 栈:相邻帧地址间距恒为 160 字节(连续栈的证据),全程出现 8 次地址整体搬家(栈复制),StackInuse 从 256 KiB 涨到 4352 KiB;并把「栈检查指令」「翻倍增长」「指针重定位」钉到 go1.27.0 的栈增长序言、newstack、copystack 源码上。

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

四行数字,四个结论:

  1. 相邻帧间距恒为 160 字节。这就是「连续栈」的直接证据:帧在栈上一个挨一个排布,delta 不抖动。[48]byte 数组加上对齐、保存的寄存器、返回地址等,正好凑成 160。也说明递归 2 万层的纯帧开销约 3.2 MB(20000 × 160)。
  2. 出现 8 次「大跳变」。跳变处相邻地址的差值远超帧间距(甚至为负),意味着「第 N 层记下的地址」和「第 N+1 层记下的地址」不在同一段内存里——中间发生了栈整体搬家。2 万层触发 8 次,与「每次翻倍」的模型吻合:本 goroutine 从 16 KiB 起步,翻倍 8 次到达 4 MiB(16 KiB → 32 KiB → …… → 4 MiB)。
  3. StackInuse 从 256 KiB 涨到 4352 KiB(约 17 倍)。注意它比「纯帧开销 3.2 MB」大——因为栈是按 2 的幂预留的,最后一档是 4 MiB,实际驻留 4352 KiB。
  4. 同一帧 &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 与缓存行 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练