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 开销就能有可见的改善。


参考资料:

相关推荐
U盘失踪了2 小时前
go 常量
开发语言·后端·golang
披着羊皮不是狼2 小时前
(8):实现双删(MySQL+Redis)
spring boot·后端
Java面试题总结2 小时前
BCrypt密码加密
开发语言·python
cici158742 小时前
C# 五子棋小游戏源码(人机对战)
开发语言·单片机·c#
iiiiyu2 小时前
面向对象高级接口的综合案例
java·开发语言·数据结构·编程语言
fzil0012 小时前
让 AI 自己协作 —— 多智能体(Swarm)系统的设计与实现
java·开发语言·人工智能·ubuntu
鬼蛟3 小时前
springcloud
后端·spring·spring cloud
Mem0rin3 小时前
[Java/数据结构]二叉树练习题几则
java·开发语言·数据结构
止语Lab3 小时前
Go 错误分层实战:从裸奔到三层防线
开发语言·golang