亲眼看着 Go 的新垃圾回收器在堆上移动

查看原文 HN 讨论

文章摘要

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-loadsL1-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 条顶层评论。以下是全部有实质内容的部分。