Go 的新垃圾回收器 Green Tea:一个降低GC CPU开销的大工程


Go 1.25 悄悄带来了一个实验性的新垃圾回收器,代号 Green Tea。在 Google 内部的生产环境中,多数服务的 GC CPU 开销降低了 10%,部分服务降低了高达 40%。Go 团队计划在 Go 1.26 将其设为默认 GC。

这篇文章从头讲清楚:现有 GC 为什么慢,Green Tea 改了什么,以及这个看似简单的想法背后藏着多少工程细节。


现有 GC 是怎么工作的

对象与指针

Go 的 GC 只关心两件事:对象(object)指针(pointer)

对象是堆上分配的内存块,指针是引用这块内存的数字地址。GC 的任务是找出哪些对象还在被程序使用,把剩下的回收掉。

标记-清扫算法

Go GC 使用的是经典的 标记-清扫(mark-sweep) 算法,分两个阶段:

标记阶段(Mark):从一组"根"出发------全局变量、goroutine 栈上的局部变量------沿着指针向外遍历整个对象图,把访问过的对象全部标记为"存活"。这本质上就是一次图的广度/深度优先搜索。

清扫阶段(Sweep):遍历堆上所有对象,把没有被标记到的对象的内存标记为可用,供分配器复用。

算法本身并不复杂。但实际情况是,Go 程序可能有 20% 甚至更多的 CPU 时间花在 GC 上,这笔开销实在不小。

标记-清扫:从根出发遍历对象图,未访问的对象即为垃圾


问题出在哪里

对 GC 开销做分解分析,可以得到两个关键数据:

  • GC 总时间的约 90% 花在标记阶段,清扫阶段只占 10%;
  • 标记阶段中,约 35% 以上的时间是在等待内存访问,也就是 CPU 在空等数据从主存搬进来。

后者才是真正的根子。

随机跳跃的图泛洪

图泛洪的工作模式决定了它在内存访问上的随机性:

复制代码
扫描对象 A → 发现指针 → 跳到内存某处的对象 B → 发现指针 → 跳到另一处的对象 C → ...

两个相互引用的对象,在内存里未必相邻,往往差了几 KB 甚至几 MB。每次跳转,CPU 都要等待新地址的数据从主存加载进来。现代 CPU 的 L1 缓存命中只需 4 个时钟周期,而主存访问可能需要几百个周期------差了将近 100 倍。

更糟的是,每次扫描的工作量很小,而且高度依赖上一次的结果(得到指针之后才知道下一步去哪),CPU 的乱序执行和预取机制完全无法发挥作用。一位 Go 团队的工程师把这种情况直接称为"微架构灾难"。

L1 缓存与主存之间的延迟差距可达 100 倍

硬件趋势让问题越来越严重

让人担忧的是,这个问题随着硬件发展在持续恶化:

NUMA(非均匀内存访问):内存与 CPU 核心的亲和性越来越强,跨 NUMA 节点的内存访问比本地访问慢很多,而图泛洪无法感知这一点。

内存带宽下降:虽然 CPU 核心数不断增加,但单核可用内存带宽在下降,随机访问的代价更高了。

核心数增加:标记阶段是并行的,多个 goroutine 同时往一个共享的"待扫描对象队列"里放和取,队列竞争成为瓶颈。

向量指令无法利用:AVX-512 等向量指令擅长处理规整、连续的数据,而每次只处理一个大小不固定的对象,完全无法利用这类硬件加速。


Green Tea 的核心思想

Go 团队给出的解法只有一句话:

以页(page)为单位追踪和扫描,而不是以对象为单位。

Go 运行时中,页(page)是 8 KiB 的连续对齐内存块,同一页内的对象大小相同。这个特性,是 Green Tea 一切优化的基础。

具体改了什么

原始 mark-sweep 的工作列表里放的是对象 ,Green Tea 的工作列表里放的是

同时,每个对象的元数据从 1 bit 变成 2 bit:

  • seen bit:是否发现过指向该对象的指针
  • scanned bit:是否已经扫描过该对象的指针

扫描流程变成:

  1. 从工作列表里取出一个
  2. 对比该页所有对象的 seen bit 和 scanned bit,找出"已发现但未扫描"的对象;
  3. 按内存顺序依次扫描这些对象(它们都在同一页内,物理上相邻);
  4. 扫描中发现新指针时,把目标对象所在的加入工作列表(如果不在的话),并设置目标对象的 seen bit。

关键变化在于:同一页内的多个对象被集中在一次工作流中处理,而不是每发现一个对象就立刻去追它的指针。工作列表使用先进先出(队列)而非先进后出(栈),目的是让页在队列中等待时,能尽量积累更多待扫描对象,从而在一次处理中扫更多内容。

用公路比喻

博客里用了一个很形象的比喻:

原来的图泛洪像是在城市里开车------不断地转弯、停红灯、躲行人,引擎永远无法提速。

Green Tea 则是驶上高速公路------更少的转弯,更长的直线,CPU 终于可以连续地处理一段内存,让缓存预取机制发挥作用。

扫描路径的直观对比

原始 mark-sweep 在上面的示例堆中需要 7 次独立扫描,频繁在不同页之间跳跃;Green Tea 只需 4 次扫描,多次在同一页内连续处理多个对象。

堆越大,这种效果越显著------因为同一页内积累的待扫描对象会更多。


AVX-512 向量加速

Green Tea 还带来了另一个传统 GC 完全无法实现的能力:用向量指令批量处理整页的扫描元数据

同一页内的所有对象大小相同,因此页内的所有元数据(seen bits、scanned bits、指针位图)都有固定的格式和规律。现代 x86 CPU 上的 AVX-512 提供 512 bit 宽的向量寄存器,恰好可以一次性容纳整页的元数据。

扫描内核的核心步骤大致如下:

  1. 将 seen bits 和 scanned bits 一次性加载进向量寄存器;
  2. 用位运算求差集,得到"本轮需要扫描的对象"位图;
  3. 将每个对象对应的 1 bit 展开成该对象占用的所有 word(8 字节)数量的 bits;
  4. 与指针位图求交集,得到"本轮所有需要追踪的指针"的精确位置;
  5. 批量读取指针值,写入缓冲区。

这套流程几乎全部在 CPU 寄存器内完成,不需要随机访问内存,每次循环处理 64 字节。其中最关键的是 VGF2P8AFFINEQB 这条指令------它来自 x86 的"Galois Field New Instructions"扩展,可以高效完成步骤 3 中的 bit 展开,只需几个 CPU 周期。

AVX-512 的 512 位宽寄存器,恰好可以容纳整页的元数据

对于传统的图泛洪,因为每个对象大小不同,元数据格式不固定,这条路完全走不通。Green Tea 以页为单位的设计,天然解决了这个前提条件。


实测效果

根据 Go 团队公开的数据:

场景 GC CPU 开销降低
典型工作负载(众数) 约 10%
部分工作负载 最高 40%
向量加速(预期,Go 1.26) 额外再降 10%

换算成整体 CPU 开销:如果一个服务原本有 10% 的时间在跑 GC,那么 10%~40% 的 GC 改善意味着整体节省 1%~4% 的 CPU。在大规模生产环境中,这是非常可观的数字。

Green Tea 已经在 Google 内部大规模上线,结果与基准测试一致。

不过,也有例外情况。Green Tea 的核心假设是"同一页内可以积累到足够多的待扫描对象"。如果程序的对象图非常稀疏,每次只能在同一页内扫到 1~2 个对象,那么 Green Tea 积累等待的开销反而会超过收益。Go 团队对这种情况做了特殊处理(单对象页直接走简化路径),但尚未完全消除这类场景下的回退。


如何启用

Go 1.25(实验性,无向量加速):

bash 复制代码
GOEXPERIMENT=greenteagc go build ./...

Go 1.26(计划成为默认,含向量加速):

bash 复制代码
# 不需要任何额外设置,默认即是 Green Tea
# 如需回退:
GOEXPERIMENT=nogreenteagc go build ./...

Go 团队目前正在收集反馈。如果遇到了性能回退或者看到了明显提升,建议在 GitHub Issue #73581 上回复,帮助团队改进 Go 1.26 的正式版本。


这个想法从何而来

Green Tea 的"以页为单位"核心思想,看起来简单,实际上是 Go 团队多年探索的结果。

最初的想法可以追溯到 2018 年------有趣的是,团队里没有人记得到底是谁最先提出的,大家都以为是别人的主意。

2024 年,Austin Clements 日本四处寻觅咖啡馆,喝了无数抹茶,并由此构思出了早期版本的原型!这个原型证明了绿茶的核心理念是可行的。从此,便开始了绿茶的研发之路。GC 的代号"Green Tea",就是这样来的。2025 年,Michael Knyszek 完成了完整的实现和生产化,各种细节在过程中也不断演化调整。

从一个想法到可以大规模使用,中间有大量"行不通的中间方案"和"必须搞清楚的工程细节"。

上图 是Go 团队在博客里的一张时间线图,列出了 2018 年以来尝试过但被放弃的各种中间方案。


总结

Green Tea GC 的核心改变,用一句话概括:把"追着对象跑"改成了"把一页内的事情做完再走"

这个改变让 GC 的内存访问模式从随机跳跃变成连续扫描,进而让 CPU 缓存能够真正发挥作用,并且首次为 GC 标记阶段引入了 AVX-512 向量加速。

对于大多数 Go 服务来说,不需要改一行业务代码,只需要升级到 Go 1.25 并设置一个环境变量,或者等待 Go 1.26 正式发布,GC 开销就能有可见的改善。


参考资料:

相关推荐
爱勇宝1 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
AskHarries1 小时前
工具失败时怎么办:重试、回滚、人工确认和风险提示
后端·程序员
苏三说技术3 小时前
Claude Code从失控到起飞,只用了这些技巧
后端
长栎4 小时前
写 for 循环写了十年,你却从没用过迭代器模式最狠的那一面
后端
LiaCode4 小时前
Redis 在生产项目的使用
前端·后端
用户559822481224 小时前
Docker Compose Down 导致容器数据误删——ext4 日志恢复全记录
后端
LiaCode4 小时前
一天学完 redis 的爽翻版核心知识总结
前端·后端
大刚测试开发实战4 小时前
如何内网穿透访问本地私有化部署的TestHub
前端·后端·github
xiaodaoluanzha4 小时前
迄今為止,最簡單的編程語言 Nolang
前端·后端
Csvn4 小时前
Docker 容器管理入门 — 从镜像到容器编排
后端