Go GC 十年:一部延迟战争史

0 阅读8分钟

封面

2014 年,Go 的垃圾回收器还在用最原始的 STW 标记清除。每次回收,整个程序停 300 毫秒。对于一个 Web 服务来说,300 毫秒的停顿意味着什么?意味着用户的请求超时,监控告警亮红,SRE 值班电话响起。

Go 团队决定动手。他们花十年时间,把 STW 停顿从 300 毫秒压到亚毫秒——这场仗打了五步:并发标记、混合写屏障、GOGC 调优、GOMEMLIMIT 兜底、Green Tea 探路。但数字背后,是一连串的取舍——每一步"选了什么"都不如"没选什么"更值得理解。

一、Go 1.5:并发三色标记——从 300ms 到亚毫秒

Go 1.5 之前的标记清除算法,工作方式很粗暴:暂停程序,扫描所有存活对象,清除死对象,恢复程序。暂停期间,你的代码一行都不执行。

三色标记打破了这个僵局。它把标记过程拆成三个颜色:白色=未访问,灰色=已发现但子对象未处理,黑色=已处理完毕。算法从根对象出发,把可达对象标灰;然后逐个处理灰色对象——把它的子对象标灰,自己标黑。处理完所有灰色对象后,剩下的白色对象就是垃圾。

关键变化是:标记过程可以和用户代码并发执行。GC 不再需要独占 CPU,STW 只剩初始和收尾两个极短的阶段。

Go 1.5 之后,STW 降到 100-300 微秒。从 300 毫秒到亚毫秒,三个数量级的跨越。

但并发标记引入了新问题:用户代码在 GC 运行期间可能修改指针,导致漏标。解决方案是写屏障——每次指针写入都被 GC 记录。Go 1.5 采用的是 Dijkstra 式插入写屏障:只要新指针被写入,GC 就把它记录下来。写屏障是并发标记的代价,也是后续所有演进的起点。

从 STW 标记清除到并发三色标记

写屏障解决了并发标记的漏标问题,但它自己也有副作用——而且还不小。下一场仗,就是冲着这个副作用来的。

二、Go 1.8:混合写屏障——消灭栈重扫描

三色标记的写屏障最初用的是 Dijkstra 插入屏障。它有一个讨厌的副作用:GC 结束时必须重新扫描所有 goroutine 的栈,因为栈上的指针修改没有被写屏障捕获。栈重扫描需要 STW,goroutine 越多,STW 越长。

Go 1.8 引入了混合写屏障:Dijkstra 插入屏障 + Yuasa 删除屏障。组合使用后,栈上的指针修改不再需要重新扫描——因为删除屏障确保被覆盖的旧指针指向的对象不会被错误回收。

效果:STW 从数百微秒进一步压缩,且不再与 goroutine 数量正相关。

这里的取舍很清晰:混合写屏障比纯 Dijkstra 屏障开销略高(每次写操作多做一点工作),但换来了 STW 的确定性。Go 的逻辑是:写屏障的开销分摊到每次写操作,感知不到;但 STW 的停顿集中在一次,感知强烈。

从 Dijkstra 到混合写屏障

STW 的问题基本解决了。但延迟战争不只是 STW——还有一个更隐蔽的敌人:GC 触发时机。

三、GOGC:内存换 CPU 的旋钮

理解 Go GC 的调优,先理解 GOGC。

GOGC 控制的是 GC 触发的时机。公式很简单: NextGC = LiveHeap × (1 + GOGC/100)

GOGC=100(默认值)意味着:当堆增长到存活对象的 2 倍时触发 GC。GOGC=200 意味着 3 倍,GOGC=50 意味着 1.5 倍。

GOGC 本质是一个"内存换 CPU"的旋钮。GOGC 越大,GC 频率越低,CPU 省了,但堆更大。GOGC 越小,GC 更积极,堆小了,但 CPU 开销上升。

我跑了一组实测:10MB 存活堆,1000 万次 64B 短生命周期分配,GODEBUG=gctrace=1 采集数据。

GOGCGC 次数GC CPU 占比总耗时
504 次5.3%4.8ms
1002 次4.8%3.4ms
2001 次4.4%2.6ms
off0 次0%2.4ms,但内存不受控

GOGC=50 比 GOGC=200 多跑了 3 次 GC,耗时多了 85%。数字摆在这里:GOGC 小→GC 频繁→CPU 贵但堆小;GOGC 大→GC 懒惰→CPU 省但堆大。

GOGC 旋钮:内存换 CPU

但 GOGC 有一个关键盲区:没有上限意识。假设存活堆 10GB、GOGC=200,GC 要等堆到 30GB 才触发。如果你的容器只有 16GB 内存,进程会被 OOM Kill。

GOMEMLIMIT:给旋钮加安全网

Go 1.19 引入 GOMEMLIMIT,给 GOGC 加了硬上限:当堆接近 GOMEMLIMIT 时,GC 会自动更积极地运行,等效于动态降低 GOGC。

我实测了 GOGC=off + GOMEMLIMIT=32MB 的组合——堆分配到 18MB 时 GC 仍然按预期触发,而 GOGC=off 单独使用时 GC 完全不工作。GOMEMLIMIT 确实补上了 GOGC 的盲区。

对 P99 延迟的意义也很大。Ilya Brin 在生产环境发现:P50 延迟 5ms,但 P99 飙到 520ms。根因是 GC 在大堆时不够积极,偶发性全堆标记造成尾部延迟暴涨。GOMEMLIMIT 让 GC 提前介入,减少这种毛刺。

GOMEMLIMIT 安全网

GOGC 和 GOMEMLIMIT 解决了"GC 什么时候该出手"的问题。但延迟战争还没打完——STW 压到亚毫秒后,新的瓶颈浮出水面了。

四、Green Tea GC:新战场——CPU Cache Miss

前面的所有改进都在打同一场仗:缩短 STW。当 STW 压到亚毫秒以后,Go 团队发现延迟战争的下一个敌人不在 STW,而在 GC 的 CPU 开销本身。

Go 1.5 之后的并发标记,每次 GC 都要遍历整个对象图。堆小的时候没问题,堆大了就是灾难:CPU Cache Miss 暴增,标记阶段扫描一个大堆,GC CPU 开销可能超过 20%。

打个比方:逐对象扫描就像在一栋大楼里挨个敲门——每扇门都是一次内存访问,可能触发 Cache Miss。Green Tea GC 的思路是换一种敲门方式。

Green Tea GC(Go 1.25 实验性引入,Go 1.26 将默认启用)改变了扫描的基本单位:从逐对象扫描改为按页扫描。它用位图标记每页中的对象存活状态,甚至利用 SIMD 指令一次处理多个对象——就像从"挨个敲门"变成"看楼层平面图",整层楼的对象状态一目了然。

从"对象图洪水"到"页级扫描",这不是换个算法,是换了个战场。之前的延迟战争打的是 STW 时长,Green Tea 打的是 Cache 友好性。战场变了,战争性质没变:还在追求更低的延迟代价。

Green Tea GC:逐对象→按页扫描

Green Tea 是延迟战争的新战线,但 Go GC 还有两个老问题没有解——它们不是 bug,是刻意的设计选择。

五、不完美的真相:碎片化与无分代的代价

碎片化。Go 不做内存整理(compaction)。内存分配器把对象按 67 个大小类别管理,释放后的空间只能被同大小类别的新对象复用。大量小对象释放后,内存里满是碎片。

我跑了一个实验:分配 100 万个 64B 小对象,隔一个释放一个,然后用 runtime.ReadMemStats 看 Go runtime 持有的内存。

阶段HeapAllocHeapSys碎片率
分配 100 万个 64B 对象后91.9MB99.4MB7.6%
隔一释放后293KB99.4MB99.7%
再分配 4KB 大对象后293KB99.4MB99.7%

释放了约 50 万个 64B 对象后,HeapAlloc 只有 293KB,但 HeapSys 仍然是 99.4MB——Go runtime 从系统申请了 99.4MB,你的程序只用了 293KB。再分配 4KB 大对象?不行,4KB 属于不同的 size class,放不进 64B span 的空洞。

大堆缓存服务的碎片率更夸张:碎片化可能让你多花 30-50% 的内存。

碎片化与无分代的代价

不分代。分代 GC 的核心假说是"大多数对象朝生夕死",只频繁回收新生代能大幅减少工作量。但分代需要写屏障跟踪跨代引用,这意味着每次指针写入都有额外开销——即使 GC 没在运行。Go 的设计目标是最大化应用代码的 CPU 时间,写屏障的"全员税"不可接受。所以 Go 选择每次 GC 都扫描全堆。

这两个"不"——不整理、不分代——是 Go 用效率换确定性的结果。碎片化是"低延迟 > 内存效率"的代价,不分代是"mutator 吞吐 > GC 效率"的代价。

Go 团队知道这些问题。他们的判断是:对于大多数 Go 服务(堆 <4GB),这些代价可接受。如果你的场景是大堆缓存服务,碎片化可能让你多花约 30% 的内存,这时需要评估是否值得。

尾声:战争仍在继续

从 300ms 到 0.5ms,Go GC 用十年时间打赢了 STW 这场仗。但战争远未结束。

Green Tea GC 正在跟 CPU Cache Miss 较劲。碎片化问题没有银弹,Go 的态度是"用空间换确定性"。分代?短期内不会来,因为写屏障的"全员税"与 Go 的设计目标冲突。

如果你在用 Go,记住这个调优思路:先用默认 GOGC=100 跑起来;遇到 P99 毛刺,设 GOMEMLIMIT 让 GC 提前出手;大堆场景关注 GC CPU 开销,必要时调整 GOGC。具体参数不重要,理解"为什么这样调"才重要。

Go GC 的故事不是一个完美演进的故事,是一个"每一步都在取舍"的故事。理解它没选什么,比理解它选了什么,更能帮你做出正确的工程决策。