决定在生产环境运行交换空间(swap)吸收内存峰值,有一控制组(cgroup)含两进程:一为 Go 语言(Go)进程,调用 io.ReadAll 与 proto.Unmarshal 建 blob 及标 scan 的 graph struct;另一为静默 HTTP 服务器。收集器读 scan spans。作者以为内存压力下内核按 cgroup 逐出页面,两进程页面皆逐出,不太会与垃圾回收器换入换出。但他错了,实验发现 Go 语言(Go)回收器在 stop-the-world 暂停读堆外未释放区元数据,该数据可能在 swap 中。他随后在赫茨纳(Hetzner)机器用 kernel 6.8 启 MGLRU 模拟运行。
正文
我决定在生产环境运行 swap 来吸收内存峰值,写下此事以免你像我一样踩坑。
我有一个 cgroup 运行两个进程:一个是 Go 进程,调用 io.ReadAll 和 proto.Unmarshal,先生成 blob 再生成图结构(被 Go 分配器标记为 scan);另一个是基本闲置的 HTTP 服务器。
收集器运行时,会逐指针读取那些 scan span 并决定如何处理。我原以为:内存压力下内核会把页面换出到 swap 设备,但换出是按 cgroup 而非按进程,两个进程的页面都会被换出,所以内核和 GC 之间发生频繁换入换出拉锯的概率很小。
我错了。实验时发现一个可能致灾的问题:Go 的垃圾回收器在 stop-the-world 暂停期间会读取其元数据(位于堆外、不会被释放的区域),而这些元数据可能位于 swap 中。
我在 Hetzner 机器上用内核 6.8 并开启 MGLRU 做了模拟运行,实验相关的图表、模拟分配器、bpf 脚本、python 脚本等都在 https://github.com/frnsimoes/go-gc-swap-cost 。中位暂停约 51 微秒;当元数据位于 NVMe 时,最差暂停达 40 毫秒。
为查清这 40 毫秒去向,我写了个 bpf 脚本 统计世界停止期间的页错误。最差的一次:39902 微秒中发生 228 次缺页,其中 39013 微秒耗在缺页上。这些缺页发生在 GC 的记账代码中,调用栈涉及 runtime.gcStart、runtime.finishsweep_m、runtime.(*spanSet).reset、runtime.nextMarkBitArenaEpoch 等函数。
这是一个潜在的故障模式。Go 的 GC 必须在两个时刻停止世界:sweep termination(清扫终止)和 mark termination(标记终止)。30 分钟内我们观测到 312 次此类暂停。
原因如下:运行时分配这些页面后不释放而是复用,GC 周期中会读取它们。由于内核按年龄换出页面,最久未访问的页面被送入 swap。GC 运行并停止世界,试图读取那些页面时便触发 major page fault。内核需要读取 PTE、调用 do_swap_page、找新帧、计入 cgroup、读页、提交 bio、等待磁盘、再放回内存(简述如此)。
这 40 毫秒初看无害,但这是 stop-the-world 暂停,意味着一切停止——用 Go 的术语说,每个 P 都停了。例如正在等待 I/O 的 goroutine 可能在暂停期间返回却无人处理。40 毫秒是中位暂停的 800 倍,测试中每次内存峰值会发生两三次,确实很长。
另注意到:构建一条 511 KiB 消息通常需 3–5 毫秒,在 NVMe 上飙到 105 毫秒,在 Hetzner 网络卷上达 903 毫秒。单条消息的代价比元数据暂停还大,但只有执行分配的 goroutine 承担,至少是局部的,不像元数据那样全局影响。
我还没确认那些时间花在哪,但想指出这是另一种代价。无论如何我认同 Chris Down 的观点,swap 并非邪恶,只是它与 GC 配合不佳,而我在生产环境大量使用 swap。
更新,9月14日:有人问 Go 1.26 的 Green Tea 垃圾回收器 是否改变了读取元数据的方式,我测量后影响可忽略。
评论
社区普遍将交换空间下的垃圾回收延迟尖峰视为预期之中或自找的麻烦,主流意见是低延迟服务应禁用交换空间或避开 Go;少数声音探讨技术缓解或归咎于语言选型。
认为低延迟服务应直接禁用或规避交换空间
- 多个评论指出若在意延迟就应系统级或控制组级关闭交换空间,否则内存压力会转化为垃圾回收的停止世界尖峰,甚至回收元数据换出会加剧停顿。也有人建议用内存锁定锁定页面或延迟清理换出内存中的垃圾。(认可度:较多人) > 如果在意延迟,就禁用交换空间,可以是系统级或特定控制组级。(@jacobgold)
认为 Go 的垃圾回收机制本质与交换空间不兼容
- 无论实现如何,垃圾回收需要遍历引用树,会导致交换空间的缓存被频繁丢弃,且元数据换出会将内存压力变成世界暂停。不少人由此倾向无运行时或引用计数语言。(认可度:较多人) > 仅凭垃圾回收需遍历整棵树追踪引用这一事实,就使交换空间极不实用,连 Apple 也因此弃用垃圾回收转向自动引用计数。(@nasretdinov)
认为语言选型才是根源问题
- 部分人认为选择宽容内存管理漏洞的语言就要承担后果,低延迟需求本应选 Rust 或现代 C++,Go 只适合替代脚本语言。(认可度:少数人) > 选了纵容糟糕内存管理的语言就别惊讶它要干活,你本可以用 Rust 或现代 C++ 的 RAII。(@HackerThemAll)
探讨可能的技术缓解方案
- 有人提问为何不用并发无停止世界的垃圾回收,或建议升级新版 Go 与内核;也有设想交换空间感知型垃圾回收先换入页面再暂停,或延迟清理换出页中的垃圾。(认可度:少数人) > 能否做交换空间感知的垃圾回收,先让所需页面换入再暂停世界?(@Gabrys1)
有价值的补充
- soltanov 指出垃圾回收延迟服务水平目标必须纳入操作系统内存压力,否则页错误会被误判为收集器问题导致错误修复。
- xavdid 提到 Discord 早在 2020 年就发表过从 Go 转向 Rust 的博客,印证低延迟场景的取舍。
- raverbashing 提出引用计数有更可预测的运行性能,但承认交换空间仍会按访问模式触发。