亲眼看着 Go 的新垃圾回收器在堆上移动
文章摘要
Phil Eaton 在 The Consensus 上写的这篇深度文章,用一系列可复现的小实验把 Go 1.26 默认启用的 Green Tea 垃圾回收器「画」了出来。Green Tea 在 Go 1.25 引入,Go 1.26 成为默认;文章的目标是复述它的原理,看看哪些程序从中获益最多,再看看哪些程序不但不获益,还会撞上 Go 那个挥之不去的老问题——非移动式回收器无法回收稀疏页面。
第一步:看清 Go 怎么分配内存。 Go 把同一个 size class(对象大小向上取整到最近的档位)的对象分配在一段连续的 chunk 里,Go 术语叫 span,由一个或多个 8KiB 的页组成。这种按尺寸分区的分配方式在 malloc 实现里很常见,Go 的分配器就源自 tcmalloc。作者写了一个程序:随机分配 100 个 32/64/128 字节的对象(Small/Medium/Large),保持引用不被回收,然后按地址排序,把地址空间「走」一遍,每 32 字节打印一个字符,用 S/M-/L--- 标出对象、. 标出空隙。跑出来的图非常直观:尽管分配顺序是随机的,同尺寸的对象整整齐齐地聚在一起,M 一片、S 一片、L 一片;而且跑完 runtime.GC() 之后的第二遍输出与第一遍一模一样——Go 从不移动对象。作者用 C# 写了等价程序做对照,画出来的图完全是另一副样子:L、M、S 杂乱交错,同尺寸对象根本没有聚在一起(后面另一个负载里还能看到 C# 会移动对象)。文档当然会告诉你这些行为,但作者认为亲眼看到画出来的图别有价值。
第二步:Green Tea 到底改了什么。 传统的标记-清除从根(全局变量、局部变量)出发,沿着指针一路访问所有可达对象,然后第二遍释放没被访问到的对象。问题在于:如果对象 A 指向大小不同的 B/C/D,它们在 Go 里位于内存的不同区域;哪怕 A 指向的是另一批在很不同的时间创建的 A,它们也会散落在内存的很不同的地方。两种情况下,跟着指针走都引入了随机内存访问,对缓存极不友好。Green Tea 的改法是:扫描一整个 span,找出其中的对象和指针,并根据找到的指针把未来要扫描的 span 排进队列,而不是一见到指针就立刻跟过去。
第三步:用 perf 量化。 作者构造了一个 200 万个 Node(每个含 4 个指针)的负载,分 packed(指向相邻节点)和 scattered(指向随机节点)两种模式,为了公平起见把随机数生成挪到 Python 脚本里预先生成索引文件。用 GOEXPERIMENT=nogreenteagc 编译出旧 GC 版本做对比,结果非常干净:packed 从 4.23 秒降到 2.70 秒,scattered 从 11.05 秒降到 6.96 秒。
但接下来出现了一个反直觉的现象:缓存未命中率反而上升了(packed 从 25.69% 涨到 53.57%,scattered 从 17.56% 涨到 64.61%)。作者的排查过程本身就是文章的精华。第一,perf 的 cache-references/cache-misses 通常对应的是 L3,L3 未命中比例上升并不能说明 L1/L2 层面发生了什么。第二,程序运行时间变了却没有归一化,于是他改用 MPKI(每千条指令的未命中数)这个标准归一化指标——结果 L3 MPKI 依然是上升的(1.59→1.81、12.70→15.39)。要继续追下去就必须换到裸金属 x86 Linux 机器,因为大多数虚拟机不暴露 L1 事件所需的 PMU 计数器。作者去租了台 Vultr 裸金属,加上 L1-dcache-loads 和 L1-dcache-load-misses 重测,答案终于浮现:L1 未命中率持平或下降,而 L1 MPKI 明显下降(packed 2.23→1.98,scattered 31.57→14.06)。也就是说,更多读取命中了 L1(可能还有 L2),压根没走到 L3,于是 L3 的未命中变得没那么重要了。这就是 Green Tea 改进的一个具体着力点。
第四步:Go 仍然做不好的事。 因为 Go 永远不会移动内存来压缩或消除碎片,很容易构造出一个看似荒谬的场景:释放了绝大部分对象,内存却回收不回来。作者把对象数改成 5 万,然后释放其中 90%,再 runtime.GC() + debug.FreeOSMemory()。结果是:存活数据从 3639.9 KiB 降到 364.6 KiB,但被钉住的 span 数从 464 只降到 463——如果幸存者能被紧凑排布,理论上只需要 46 个。堆图上原本密密麻麻的 L--- 变成了稀稀拉拉的孤岛,而 HeapInuse 纹丝不动地停在 6320.0 KiB。
有意思的是,在简单场景下你可以自己动手「移动」(其实是复制)对象来规避碎片、把内存拿回来:把幸存的 Small/Medium/Large 分别 append 进三个类型化的 slice,然后把原来的引用置空,再跑一次 GC,紧凑排布就实现了。
HN 评论精华
需要说明的是,这条帖子虽然拿到了 261 分,但讨论相当稀少——只有 9 条顶层评论。以下是全部有实质内容的部分。
- nomorewords(引发最长子串):「非常有意思,但结尾有点突然,我一度以为订阅横幅底下还藏着什么我没看到的内容。」他的技术问题是:Go 里有没有手动移动对象、把它们压缩到特定区域的做法?他不太用 Go,相信不做堆压缩有很强的理由,但一直好奇——在那 0.0001% 的情况下,堆被碎片化到无法为一个新的大对象找到位置时,它是怎么避免崩溃的?
- john01dav 直接反问:「你确定它在那种情况下不是就崩了吗?」他还猜测 64 位架构上巨大的虚拟内存空间可能以某种方式参与其中。
- jerf 给出了答案,并把文章第二段贴了回来(他特意声明「没有阴阳怪气叫你去读原文的意思,这是个好问题,我只是贴出来方便讨论」):关键就在于按 size class 分配到连续 span 的机制——你不会陷入那种「无法分配」的境地。
- okzgn 抓住了文章最后那个技巧:「绝妙的优化手法:手动把对象复制到一个新的 slice 里,这样它们就不会因为正好坐在 GC 想释放的页中间而阻止内存归还。」amelius 的回复一针见血:「每个『复制式 GC』做的就是这件事。」并附上了康奈尔的 GC 讲义链接。
- torginus 提出了本串最有价值的独立分析:值得注意的是新 GC 实际上略微增加了 L3 未命中率。L1 未命中相当快,而且往往是「免费」的,因为 CPU 可以用其他有用工作填上空档;但 L3 未命中意味着要读 RAM,以 CPU 周期计是一个永恒。他的猜想是:Go 程序通过 SMT(在服务器 CPU 上仍然常见)掩盖了等待 L3 的时间,调度另一个硬件线程的工作上来。「这种手法在线程更少的语言里、或在没有 SMT 的 CPU 上可能不那么好使,新 GC 最后可能反而更慢。」
- KolmogorovComp 起了一条跑题但有趣的支线:他想起一个关于 C# GC 的视频,作者为了躲开 GC 转向 Swift,并估计开发一个无停顿 GC 大约需要 50 亿美元的研发投入——有人有印象吗?他随后自己找到了出处:2023 年 Miguel de Icaza 的《Swift Godot: Fixing the Multi-million dollar mistake》,并强烈推荐。ahartmetz 提供了反例:据 Cliff Click(编译器与 JVM 专家)所说,Azul 有一个 JVM 能在极大堆(数十到数百 GB)和高分配率下做到微秒级停顿——对游戏而言这就是「无停顿」。
- vernonHeim:「堆的可视化是把 GC 行为从抽象变具体的好办法。很有意思的是能看到内存布局和缓存局部性的影响有多大,而不只是 GC 算法本身。」
- extra-AI 提了一个尖锐的问题:如果被扫描的对象本来就位于同一个页上,我们花在管理页面和追踪页内对象上的时间,是不是就白费了?(这条没有得到回复。)
- owaislone 贴了一个相关的 YouTube 讲座链接,KolmogorovComp 回复说「讲得很好」。
- fuzzfactor 贡献了最轻松的一条:「我到今天才开始想为什么他们管它叫 heap(堆)。」