跳到主要内容
把「goroutine 很轻」改准,花了四版

把「goroutine 很轻」改准,花了四版

老墨
老墨

· 阅读约 5 分钟

Nazar Boyko 那个栈内存实验,数据摆得干净,结论也稳,没什么可挑的。我读完的反应有点跑偏:想改的不是他的文章,是他顺手引的那几句社区老话。代码写错了编译器会报错,这类话不会。

最该动手的是这句,旧稿:

Goroutine 很轻,初始栈 2KB,你可以放心开一百万个。

传了十年。它不算错,坏就坏在太顺口,顺口到没人追问一句:这 2KB,是【谁】在占。

Nazar 的数据把答案摆得很直白。一百万个 goroutine 全 parked 在一个 channel 上,StackInuse 2048.8MB,除一下,一个正好 2048 字节,跟 runtime 里那个 stackMin 常量对得上。"2KB 说"不是谣传,它是一个常量。

问题是它描述的,是一个还没干过任何活的 goroutine。这个限定,被"很轻"两个字整个盖掉了。"很轻"跟"仔细""认真"是一类词:态度,不是标准,模型接不住,人做容量规划也接不住。删。

V1:

一个 goroutine 的初始栈是 2KB。

干净,还是没用。它只要干过一次活,这个数字就作废。

同一个系列里,十万个 goroutine 各自调一次带 8KB 局部数组的函数,StackInuse 1461.8MB,平均一个 14.6KB。为什么不是 8KB:8KB 数组塞不进 8KB 栈,返回地址、调用者的栈帧、运行时留的保护区域都得占地方,往上取一个 2 的幂,落在 16KB。换成 64KB 数组更狠,十万个 goroutine,StackInuse 12093.6MB,平均 128KB。

V1 缺的不是精度,是这数字的保质期。

V2 把限定补上:

初始 2KB,按需以 2 的幂次增长——8KB 局部数组落在 16KB,64KB 落在 128KB。

还是不够。栈会缩。

shrinkstack 的规则写在 runtime/stack.go 里:GC 期间,一个 goroutine 用到的栈不到容量的四分之一,运行时分配一个一半大的新栈,把内容复制过去,不低于最小栈。Nazar 那组 64KB 实验的 StackInuse 是这么走的:12093.6、6554.6、3277.8、1639.4、820.3、410.8,然后停住。410.8MB 对应十万个 goroutine,每个约 4KB。

一句话里得有第三个数字——不是起点,不是峰值,是收敛值。

V2 想补这个,方向补错了:它把"什么时候缩"写成了一个隐含的时间函数,好像过一会儿自己就会好。

这里岔一句。Vinh Nguyen 在评论里把 8KB 那组重跑了一遍,默认 GOGC,不手动调 runtime.GC():一百万个 parked goroutine 占约 770MB 栈,NumGC 停在 4,闲着十八秒,栈一点没降。原因不复杂——GC 的触发条件是堆分配,栈增长不走堆,堆一旦平稳,collector 就不动了,shrinkstack 也就没机会被走到。把 GOMEMLIMIT 设成 600MiB 再跑,栈掉到约 448MB,因为内存上限会把栈算进去,逼着 collector 继续跑。

扯回来。这条直接定了 V3 的写法:收缩不是"过一会儿就好",是"有 GC 才会发生"。默认配置里还有个兜底,运行时每两分钟左右强制回收一次,但每次只减半,从 128KB 落到 4KB 得连着跨五道坎,实际是十几分钟的量级。

V3:

一个空闲 goroutine 约 2.8KB 常驻(2KB 初始栈,加约 640 字节的 g 结构、闭包和 defer 记录)。调用过较深的栈帧之后,栈按 2 的幂次增长并保持——8KB 局部数组落在 16KB,64KB 落在 128KB。只有 GC 真的跑起来(堆分配触发、GOMEMLIMIT 施压、或每两分钟一次的强制回收),使用率低于四分之一的栈才会减半,最终稳定在 4KB 附近。

这版我自己满意了两天。第三天想起来一件事,又回头改了半句。

Go 1.19 起,运行时按历史平均栈用量分配初始栈,startingStackSize 每次 GC 重算一次,向上取整到 2 的幂。也就是说,在一个多数 goroutine 都会长到 8K、16K 的服务里,新建的 goroutine 可能压根不从 2KB 起步,直接就是 8K 或 16K。运行时自己都不信那句 2KB 了。

最后落到这版:

一个空闲 goroutine 约 2.8KB 常驻。它调用过最深的那层栈帧,决定了它此后保留多大的栈:8KB 局部数组对应 16KB,64KB 对应 128KB;而这个值只有 GC 真的跑起来,才会以减半的方式向 4KB 收敛。在长期偏高负载的进程里,新建 goroutine 的初始栈可能已经不是 2KB。

从二十几个字改到这么长,看着像退步。但那句"很轻"省下来的字,最后都是别人在容量规划上还的。Nazar 那个 64KB 实验,常驻峰值 11GB,进程向内核申请的 Sys 一度涨到差不多 19.4GB——为了让实际用着的栈小一点,反而先跟系统多要了一大截内存。

Artjoms Stukans 在评论里说的那层我也认:杀死进程的是峰值,不是平均值。GC 前的 13.4GB 在六轮回收后落到 4.1GB,而这六轮跑完可能比不少监控的抓取间隔还短——曲线是诚实的,只是没拍到那个时刻。

跟改 prompt 是一回事。一句话里被省掉的那个限定词,决定了读到它的人接下来做什么决策。差别只在反馈速度:prompt 写含糊了,模型当场给你一个很自信的错答案,你立刻知道要改;这句话写含糊了,你在半年后凌晨三点的告警里见到它。

定稿里"初始栈可能已经不是 2KB"那半句,我自己没在真实服务里量过,只看到代码里是这么算的。哪天量出来不对,回来再改一版,不丢人。