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