bigywater.tech
Go · 54

goroutine 栈增长:从 2KB 到复制整个栈的过程

goroutine 能那么便宜,是因为它的栈从很小开始、不够再长。这篇用递归把栈撑大,从指针地址的变化直接看到栈被拷贝,量出每个 goroutine 的初始开销、增长与收缩的节奏、增长的成本、栈的上限,以及序言里那条触发增长的检查。

约 2 KiB
一个刚创建的 goroutine 的栈内存(实测每个 2050 字节)
14 次
递归 20 万层时栈被整体搬动(翻倍)的次数,共 20.57 ms
约减半
深递归返回后,栈内存每次 GC 缩小的比例(32.3 → 16.3 → 8.3 → 4.4 MiB)

初始栈

实测 · 本机执行输出
  每个 goroutine 先递归   0 层再阻塞:100000 个 goroutine 的栈内存增加 195.5 MiB → 每个约 2050 字节
  每个 goroutine 先递归  20 层再阻塞:100000 个 goroutine 的栈内存增加 391.0 MiB → 每个约 4100 字节
  每个 goroutine 先递归 200 层再阻塞:100000 个 goroutine 的栈内存增加 3126.1 MiB → 每个约 32779 字节

每个 goroutine 从大约 2 KiB 的栈开始(10 万个阻塞的 goroutine 每个增加约 2050 字节),所以可以轻松创建几十万个。栈用完时增长到更大的容量并保持,深一点的 goroutine 就占更多:递归 20 层后是 4 KiB,200 层后 32 KiB。

栈如何增长

实测 · 本机执行输出
  main.rec 的函数头(go build -gcflags=-S):main.rec STEXT size=512 align=0x0 args=0x30 locals=0xd8 funcid=0x0
  递归 100 层:共搬动栈 3 次,耗时 0.04 ms;前几次搬动:
    深度 9(距上次搬动 9 层):地址差 +339968 字节
    深度 28(距上次搬动 19 层):地址差 -688128 字节
    深度 64(距上次搬动 36 层):地址差 +761856 字节
  递归 5000 层:共搬动栈 9 次,耗时 0.66 ms;前几次搬动:
    深度 9(距上次搬动 9 层):地址差 +1028096 字节
    深度 28(距上次搬动 19 层):地址差 -1376256 字节
    深度 64(距上次搬动 36 层):地址差 +1449984 字节
    深度 137(距上次搬动 73 层):地址差 +65536 字节
    深度 284(距上次搬动 147 层):地址差 +303104 字节
    深度 576(距上次搬动 292 层):地址差 +262144 字节
    深度 1161(距上次搬动 585 层):地址差 +524288 字节
    深度 2332(距上次搬动 1171 层):地址差 +1048576 字节
    深度 4672(距上次搬动 2340 层):地址差 +2097152 字节
  递归 200000 层:共搬动栈 14 次,耗时 20.57 ms;前几次搬动:
    深度 9(距上次搬动 9 层):地址差 +593920 字节
    深度 28(距上次搬动 19 层):地址差 -942080 字节
    深度 64(距上次搬动 36 层):地址差 +1015808 字节
    深度 137(距上次搬动 73 层):地址差 +65536 字节
    深度 284(距上次搬动 147 层):地址差 +253952 字节
    深度 576(距上次搬动 292 层):地址差 +262144 字节
    深度 1161(距上次搬动 585 层):地址差 +524288 字节
    深度 2332(距上次搬动 1171 层):地址差 +1048576 字节
    ……
    深度 74889(距上次搬动 37449 层):地址差 +33554432 字节
    深度 149788(距上次搬动 74899 层):地址差 +67108864 字节

实验里递归的每一层都带着一个指向底部变量的 unsafe.Pointer,运行时在拷贝栈时会改写栈上所有指针,所以只要它的值变了就说明栈被整体搬走了。第一次在栈用到约 2 KiB 时(9 层 × 216 字节栈帧),之后每隔的层数大约翻倍——栈的容量每次翻倍,新栈地址与旧栈不同。这也是为什么指向栈上变量的指针不能被存成整数(见前面 unsafe 一篇)。

栈会收缩

实测 · 本机执行输出
  递归 20 万层之前栈内存 0.3 MiB;递归之后(函数已返回)32.3 MiB
    第 1 次 runtime.GC() 之后:16.31 MiB
    第 2 次 runtime.GC() 之后:8.34 MiB
    第 3 次 runtime.GC() 之后:4.38 MiB
    第 4 次 runtime.GC() 之后:2.38 MiB
    第 5 次 runtime.GC() 之后:1.38 MiB
    第 6 次 runtime.GC() 之后:0.88 MiB
    第 7 次 runtime.GC() 之后:0.62 MiB
    第 8 次 runtime.GC() 之后:0.53 MiB

深递归返回后栈不会马上变小(32.3 MiB 还在),要等 GC:GC 发现栈只用了一小部分时把它缩小一半,所以每次 GC 约减半,八次之后回到 0.53 MiB。需要及时归还内存的程序,不要依赖栈收缩——它是渐进的。

成本

实测 · 本机执行输出
  第 1 轮:第一次递归 10 万层 7.79 ms;紧接着第二次 0.74 ms
  第 2 轮:第一次递归 10 万层 6.58 ms;紧接着第二次 0.73 ms
  第 3 轮:第一次递归 10 万层 5.48 ms;紧接着第二次 0.73 ms
  创建 200000 个 goroutine,每个递归 10 层后退出:每个 365 ns
  创建 200000 个 goroutine,每个递归 100 层后退出:每个 893 ns

增长要分配新栈并拷贝旧栈:10 万层的第一次递归比第二次慢 7–10 倍(5.5–7.8 ms 对 0.73 ms)。对短命的小 goroutine,需要增长的一次约多花 500 ns(365 对 893 ns)。热路径里反复创建需要深栈的 goroutine,会反复付这个成本;用 goroutine 池复用栈是一种做法(本文没有试)。

上限

实测 · 本机执行输出
  默认:退出码 2;max stack = 1000000000 / runtime: goroutine stack exceeds 1000000000-byte limit / runtime: sp=0x… stack=[0x…, 0x…] / fatal error: stack overflow
  SetMaxStack(1 MiB):退出码 2;max stack = 1048576 / runtime: goroutine stack exceeds 1048576-byte limit / runtime: sp=0x… stack=[0x…, 0x…] / fatal error: stack overflow

栈的上限默认是 1 GB(debug.SetMaxStack 可调),无限递归会在到达上限时 fatal error: stack overflow,不是 panic,recover 接不住,进程以退出码 2 结束。

序言与 nosplit

实测 · 本机执行输出
    (<gtmp>/sg2/main.go:4)	SUB	$32, RSP, R17
    (<gtmp>/sg2/main.go:4)	CMP	R16, R17
    (<gtmp>/sg2/main.go:4)	BLS	124
    (<gtmp>/sg2/main.go:4)	SUB	$8, RSP, R29
    (<gtmp>/sg2/main.go:4)	SUB	$1, R0, R0
    (<gtmp>/sg2/main.go:4)	CALL	runtime.morestack_noctxt(SB)
  函数头:main.f STEXT size=144 align=0x0 args=0x8 locals=0x98 funcid=0x0
  40 个依次调用、每个有 64 字节局部数组的 //go:nosplit 函数:go build rc=1
    # lab
    main.n1: nosplit stack over 792 byte limit
    main.n1<1>
        grows 144 bytes, calls main.n3<1>
            grows 144 bytes, calls main.n5<1>

增长的触发点是每个函数序言里的比较:栈指针预留出这个函数的栈帧后,与 goroutine 的栈保护线比较,不够就调用 runtime.morestack_noctxt。//go:nosplit 函数不做这个比较,所以它们的调用链必须总共不超过链接器给的一小块预留空间(这里是 792 字节),否则链接时就报错。除了运行时内部,一般代码用不上 nosplit。

分步演示

goroutine 栈增长
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。栈内存来自 runtime/metrics 的 /memory/classes/heap/stacks:bytes;搬动次数由递归时每层携带的 unsafe.Pointer 的值变化推断(第一次比较发生在第 1 层之后);函数头与序言来自 go build -gcflags=-S;栈上限的实验是独立进程(默认 1 GB 的那次要占用较多内存);每个数字来自一次运行
    • 没有验证:自适应初始栈大小(adaptivestackstart)的行为、栈增长算法里“翻倍”的具体规则(只从搬动间隔看出大致翻倍)、栈收缩的触发条件(只看到每次 GC 约减半)、amd64 上的栈帧与序言、不同帧大小下 792 字节限制的来源
    京ICP备2026067502号-1京公网安备11010502063487号