ᕕ( ᐛ )ᕗ Jimyag's Blog

Golang Green Tea GC 算法解析:从对象遍历到 Span 局部性

· 2461 words · ~ 12 min read

Last modified:

Go 1.26 默认启用了新的 Green Tea GC。它不是把 Go 变成了分代 GC、移动 GC 或引用计数 GC,而是在 Go 现有的并发非移动 mark-sweep GC 上,重写了标记阶段的工作组织方式。

一句话概括:旧算法围绕“对象”调度扫描工作,Green Tea 围绕“内存页 / span”调度扫描工作,目标是让 GC 少做随机内存访问,多做连续内存访问。

这篇文章整理三部分内容:

  1. 旧 GC 的真实瓶颈是什么。
  2. Green Tea 为什么用 span 作为工作单位。
  3. 业务里应该怎么验证它有没有收益。

其中算法洞察、实现结构和 benchmark 方法主要参考 Alex Rios 的 Green Tea GC 系列,再用 Go 官方 blog、release notes 和 runtime issue 做事实校准。

先看结论

Green Tea GC 值得关注,但不要神化。

官方预期是:在大量使用 GC 的真实程序里,GC 开销可能下降 10%-40%;如果程序原本只有 5%-10% CPU 时间花在 GC 上,换算到整体 CPU,通常就是个位数百分比的收益。这个收益在大规模服务上很有价值,但它不是“代码不用优化了”的替代品。

更具体地说,它适合这类程序:

  • 小对象多,尤其是 16-512 字节的小对象。
  • live heap 大,GC mark 阶段明显吃 CPU。
  • 对象分配有一定聚集性,同一批相关对象更可能落在相邻内存区域。
  • GC profile 里 runtime.gcBgMarkWorker、scan、mark assist 等成本较高。

它不一定适合这类程序:

  • 大部分性能瓶颈不在 GC。
  • 堆上主要是大对象、少量对象,或者对象图非常稀疏。
  • 数据库、锁竞争、网络、序列化等其他瓶颈已经压过了 GC 收益。
  • benchmark 只测 ns/op,没有看 GC CPU、暂停、吞吐和尾延迟。

Go GC 原来怎么工作

Go 标准工具链的 GC 是 tracing mark-sweep。粗略流程是:

  1. 从 root 出发,比如全局变量、栈上的指针。
  2. 沿着指针遍历对象图。
  3. 发现一个 live object 就标记它。
  4. 扫描这个对象内部的指针,继续发现更多对象。
  5. 标记结束后,sweep 阶段回收没有标记的对象。

这个过程本质上像图遍历。

1
2
3
4
5
6
7
root
  |
  v
object A -> object B -> object C
                 |
                 v
              object D

问题是:对象图上的相邻关系,不等于内存地址上的相邻关系。

业务代码里,A 指向 B,只说明 A 里保存了 B 的地址;它不说明 AB 在物理内存里离得近。于是 GC 扫描时经常是这样的:

1
2
3
4
scan object at 0xc000100000
jump to        0xc001800040
jump to        0xc000108020
jump to        0xc004000080

这对 CPU 很不友好。缓存、预取器、向量指令都更喜欢连续、规则、可预测的访问;对象图遍历则天然容易变成随机跳转。

Go 官方 blog 里给过两个很关键的判断:

  • GC 成本里大约 90% 花在 marking,sweeping 只占较小部分。
  • marking 时间里通常至少 35% 只是卡在 heap memory access 上。

这说明瓶颈不是“循环写得不够快”,而是 CPU 经常在等内存。

这里有一个容易误判的点:SIMD、预取、并行、循环展开都能改善一部分执行效率,但如果大量 cycles 已经消耗在 memory stall 上,单纯让计算指令更快并不能根治问题。CPU 算得更快,内存带宽和访问延迟不会同步变快。Green Tea 改的不是“扫描函数里某几行代码”,而是“下一步扫描什么”。

这个问题也可以用硬件概念理解:

  • Cache line:CPU 通常按 64 字节左右的块把内存拉进缓存。顺序读能复用这批数据,随机跳转则经常浪费。
  • Prefetcher:CPU 会尝试预测下一批要读的数据。连续地址容易预测,指针追逐很难预测。
  • Spatial locality:访问地址 X 后,马上访问 X 附近的数据,缓存更容易命中。
  • Memory stall:需要的数据不在 cache 里时,流水线只能等内存返回。

所以 Green Tea 的目标很直接:把“对象图上的随机跳转”尽量变成“span 内的连续扫描”。

Green Tea 的核心洞察

Green Tea 的核心洞察很简单:

不要把对象作为 GC worklist 的主要单位,而是把内存页 / span 作为主要单位。

Go runtime 的 heap 本来就是按 page 和 span 组织的。Go 的 page 是 8 KiB;小对象分配会放进小对象 span,同一个 span 里通常是同一 size class 的对象。

旧方式更像这样:

1
2
worklist: object A, object B, object C, ...
scan:     一个对象一个对象扫

Green Tea 更像这样:

1
2
worklist: span X, span Y, span Z, ...
scan:     一个 span 内把待扫描对象集中处理

当 GC 发现一个 live pointer 指向小对象且该对象所在 span 使用 inline mark bits 时,它仍然会标记这个对象;但它不急着把这个对象本身塞进 worklist,而是记录到对象所在 span 的本地 metadata 里,并把这个 span 放入队列。等这个 span 出队时,GC 会一次性扫描这个 span 里已经发现但还没扫描的对象。

换句话说,Green Tea 把“马上扫一个对象”变成了“先把同一 span 里的待扫对象攒起来,然后按内存顺序批量扫”。

为什么这样会更快

关键收益来自局部性。

CPU 读内存不是按一个字节一个字节读,而是按 cache line 读。常见 cache line 是 64 字节。你读一个地址,附近一段数据也会被带进缓存。如果接下来继续读相邻地址,就很划算;如果下一步跳到很远的地址,刚带进来的缓存就浪费了。

Green Tea 把工作单位放大到 span 后,GC 有机会在一个 8 KiB 区域里连续扫多个对象:

1
2
3
4
5
span X: [obj0][obj1][obj2][obj3][obj4][obj5]...
              ^           ^     ^
              marked      marked

scan span X: 按内存顺序扫描 obj1、obj3、obj4

这个变化带来几类收益:

  • 缓存命中更好:对象和 per-span metadata 更可能已经在 cache 里。
  • worklist 压力更小:队列里放 span,不是大量零散对象。
  • 并行扩展更好:减少共享对象队列上的竞争。
  • 更适合 SIMD:span 内对象大小一致,metadata 规则,向量指令有发挥空间。

官方 Go blog 里把它概括为“work with pages, not objects”。这句话是理解 Green Tea 的关键。

实现上多了哪些结构

Green Tea 不是简单把 objectQueue 改成 spanQueue。它必须解决一个问题:worklist 只记录 span 以后,怎么知道这个 span 里哪些对象已经发现、哪些对象已经扫描?

答案是给小对象 span 增加更细的本地 metadata。

可以把它理解成两组 bit:

1
2
3
4
5
6
7
8
marks/scans per span

object slot:  0 1 2 3 4 5 6 ...
marked:       0 1 0 1 1 0 0 ...
scanned:      0 0 0 1 0 0 0 ...

to scan = marked - scanned
        = object 1 and object 4

其中:

  • marked 表示已经发现有指针指向这个对象。
  • scanned 表示这个对象内部的指针已经扫描过。
  • marked - scanned 就是这个 span 当前还需要处理的对象集合。

Alex Rios 的实现解析文章里提到 Go runtime 中类似 spanInlineMarkBitsspanQueuespanScanOwnership 这些结构。名字可能会随着 Go 版本继续调整,但设计思路稳定:每个 span 本地保存扫描状态,再通过 span 队列把工作交给 GC worker。

以文章里整理的结构为例,小对象 span 末尾会放一块紧凑 metadata:

1
2
3
4
5
6
type spanInlineMarkBits struct {
    scans [63]uint8
    owned spanScanOwnership
    marks [63]uint8
    class spanClass
}

这个结构大约 128 字节。marks 记录对象是否已被发现,scans 记录对象是否已被扫描,owned 用来描述当前 span 的扫描所有权和 mark 数量,class 保存 size class 信息。

为什么是 63 字节?8 KiB span 里,如果按 16 字节这个最小 Green Tea 小对象粒度计算,扣掉 128 字节 inline mark bits 后最多能覆盖 504 个对象;63 字节正好是 504 bit。带指针对象还会在 span 尾部预留 pointer mask,实际可容纳对象数会更少;对象更大时,需要的 bit 更少,所以这组 bitset 能覆盖小对象 span 的情况。

还有一个细节很重要:Green Tea 的 span 队列倾向 FIFO,而传统对象 workbuf 更接近 LIFO。原因是 FIFO 能让 span 在队列里多等一会儿,在等待期间积累更多 marked object。等它被取出来时,一次扫描能处理更多同 span 对象。

这和常规低延迟直觉不完全一样:这里“稍微晚点扫”反而可能更快,因为它换来了更好的批处理和局部性。

队列本身也不是一个全局大锁队列。实现思路是每个 P 有本地 span queue,常见路径走本地 ring buffer;当本地队列需要共享工作时,再通过可被其他 P 窃取的结构扩散出去。这样既保留局部性,也避免所有 GC worker 都挤在一个共享 worklist 上。

Green Tea 还会用紧凑的 objptr 表示“span 基址 + span 内对象索引”。span 按 8 KiB 对齐,低位可以编码对象槽位信息,因此不需要给每个 work item 额外分配结构体。

扫描流程拆开看

Green Tea 的流程可以拆成两个阶段:发现指针时做什么,span 出队时做什么。

先看它在原有 GC mark worker 里的位置:

  flowchart TD
    A["GC mark worker 进入 gcDrain"] --> B["优先处理 root mark job"]
    B --> C["循环 drain heap marking jobs"]
    C --> D{"tryGetObjFast<br/>P-local object workbuf"}
    D -->|有对象| E["scanObject"]
    D -->|没有| F{"tryGetSpanFast<br/>P-local span queue"}
    F -->|有 span| G["scanSpan"]
    F -->|没有| H{"tryGetObj<br/>global object workbuf"}
    H -->|有对象| E
    H -->|没有| I{"tryGetSpan<br/>local chain / global-visible span work"}
    I -->|有 span| G
    I -->|没有| J["wbBufFlush<br/>刷新写屏障缓冲"]
    J --> K{"再次取 object / span"}
    K -->|有对象| E
    K -->|有 span| G
    K -->|没有| L{"tryStealSpan<br/>从其他 P 窃取 span"}
    L -->|有 span| G
    L -->|没有| M["本轮 drain 暂停"]
    E --> N["扫描对象字段里的指针"]
    G --> O["扫描 span 内待处理对象"]
    N --> P["tryDeferToSpanScan<br/>或 fallback greyobject"]
    O --> P
    P --> C

这个顺序来自 runtime/mgcwork.go 的注释和 runtime/mgcmark.gogcDrain 实现。两个原则很明确:

  1. 先拿 P-local work,减少全局队列竞争。
  2. 同一层级里先处理 object workbuf,再处理 span queue,让 span 在队列里多积累一些 mark。

发现指针

GC worker 扫描某个对象时,如果发现一个指针指向小对象 span,大致会做这些事:

  1. 判断目标对象所在 span 是否使用 inline mark bits。
  2. 如果对象已经 marked,直接返回。
  3. 原子设置 marks 中对应对象的 bit。
  4. 如果对象是 noscan,也就是内部没有指针,记录 live bytes 后结束。
  5. 尝试获取这个 span 的扫描所有权。
  6. 如果获取成功,把 span 放进当前 P 的 span queue。

这里的所有权是关键。多个 worker 可能同时发现同一个 span 里的不同对象,但通常只需要一个 worker 负责把这个 span 入队。其他 worker 只需要设置 bit,不需要重复入队。

对应源码路径是 tryDeferToSpanScan

  flowchart TD
    A["发现一个可能指向 heap 的指针 p"] --> B{"arena 是否存在"}
    B -->|否| Z["返回 false<br/>走传统 greyobject"]
    B -->|是| C{"pageUseSpanInlineMarkBits<br/>是否命中"}
    C -->|否| Z
    C -->|是| D["base = alignDown(p, 8KiB)"]
    D --> E["读取 spanInlineMarkBits<br/>用 sizeclass 算 objIndex"]
    E --> F{"marks[objIndex] 已设置?"}
    F -->|是| Y["返回 true<br/>已经被 Green Tea 接管"]
    F -->|否| G["atomic.Or8 设置 mark bit"]
    G --> H{"span class 是 noscan?"}
    H -->|是| I["累计 bytesMarked<br/>不需要扫描字段"]
    I --> Y
    H -->|否| J{"tryAcquire ownership 成功?"}
    J -->|否| Y
    J -->|是| K["makeObjPtr(base, objIndex)"]
    K --> L["放入当前 P 的 spanQueue"]
    L --> M["必要时设置 spanqMask<br/>提示唤醒 mark worker"]
    M --> Y

这段代码里有几个实现点值得注意:

  • pageUseSpanInlineMarkBits 是 arena 级别的快速过滤,避免每个指针都去找 mspan。
  • spanInlineMarkBits 放在 span 尾部,所以拿到 page base 后可以直接算地址,不必先读 mspan
  • objIndex 不是用除法慢慢算,而是用 size class 对应的 reciprocal magic 做快速换算。
  • noscan 小对象只需要 mark,不需要入队扫描。
  • ownership 成功才入队;失败说明这个 span 已经被别人负责扫描,当前 worker 只设置 bit。

扫描 span

当某个 span 从队列里取出来后,GC worker 会:

  1. 释放 span ownership,并拿到当前 mark 状态。
  2. 如果只有一个对象需要扫描,走单对象 fast path,避免做整组 bitset 合并。
  3. 否则把 marks 合并到 scans,计算 marked - scanned
  4. 如果待扫描对象足够密集,并且当前平台支持对应优化,走更适合批处理的扫描路径。
  5. 如果对象很稀疏,就只扫少量对象,避免为了批处理付出过高成本。
  6. 扫描过程中发现的新指针,再继续尝试 defer 到目标 span 的扫描流程。

这个设计不是一味“整页扫描”。它真正追求的是在成本可控的前提下,把同一 span 内已经发现的对象尽量合并处理。

scanSpan 的源码路径画出来:

  flowchart TD
    A["scanSpan(objptr)"] --> B["spanBase = objptr.spanBase"]
    B --> C["读取 spanInlineMarkBits 和 sizeclass"]
    C --> D["imb.release<br/>释放 ownership"]
    D --> E{"本轮只有一个 mark?"}
    E -->|是| F["设置 scans[objIndex]"]
    F --> G["bytesMarked += elemsize"]
    G --> H["scanObjectSmall<br/>单对象 fast path"]
    H --> X["处理扫描出的指针"]
    E -->|否| I["计算 nelems 和 usableSpanSize"]
    I --> J["spanSetScans<br/>toScan = marks &^ scans<br/>scans |= toScan"]
    J --> K{"objsMarked == 0?"}
    K -->|是| R["返回"]
    K -->|否| L["bytesMarked += objsMarked * elemsize"]
    L --> M{"支持 fast ScanSpanPacked<br/>且密度 >= 1/8?"}
    M -->|否| N["scanObjectsSmall<br/>稀疏扫描"]
    M -->|是| O["ScanSpanPacked<br/>密集 / SIMD 友好扫描"]
    N --> X
    O --> X
    X --> P{"每个指针能否<br/>tryDeferToSpanScan?"}
    P -->|是| Q["目标 span mark / 入队"]
    P -->|否| S["findObject + greyobject<br/>走传统对象队列"]
    Q --> R
    S --> R

spanSetScans 是这里的核心:它按机器字批量读取 marksscans,算出 toGrey = marks &^ scans,再把这些 bit OR 回 scans。这一步完成了“发现过但没扫描过”的对象集合计算。

scanSpan 之后分两条路:

  • 稀疏路径objsMarked < nelems/8,走 scanObjectsSmall,只处理少量对象,避免为密集扫描付出额外成本。
  • 密集路径:对象密度够高且平台有 fast implementation,走 ScanSpanPacked,把对象 bitset、pointer bitmap 和内存读取组合起来批量处理。

在 amd64 上,internal/runtime/gc/scan/scan_amd64.go 会检查 AVX-512 相关能力:AVX512VLAVX512BWGFNIAVX512BITALGAVX512VBMI。条件满足时,HasFastScanSpanPacked() 为真,scanSpan 才会在密度足够时调用 AVX-512 版本的 ScanSpanPacked;条件不满足时,这里会走 scanObjectsSmall,而不是在 scanSpan 里退回纯 Go packed 扫描。

在非 amd64 平台,例如 darwin/arm64,internal/runtime/gc/scan/scan_generic.go 里的 HasFastScanSpanPacked() 直接返回 false。也就是说,Go 1.26 的 Green Tea 本身仍然启用,但 SIMD packed scan 这条加速路径不会启用。

ownership 的小优化

spanScanOwnership 不只是一个锁。它还会区分“没有 mark”“只有一个 mark”“多个 mark”这类状态。

1
2
3
4
5
6
7
type spanScanOwnership uint8

const (
    spanScanUnowned spanScanOwnership = 0
    spanScanOneMark                   = 1 << iota
    spanScanManyMark
)

这个状态能让 runtime 避免在常见稀疏场景里做过重的 bitset 操作。很多 span 可能一次只积累到一两个对象,如果每次都完整合并和扫描 metadata,额外 bookkeeping 会吃掉收益。

ownership 不是用互斥锁实现的,而是一个 uint8 状态位配合原子操作:

  flowchart TD
    A["Unowned<br/>未被任何 worker acquire"] -->|第一个 mark| B["OneMark<br/>span 已入队"]
    B -->|又发现同 span 对象| C["ManyMark<br/>需要 merge marks/scans"]
    C -->|继续发现对象| C
    B -->|scanSpan release| D["单对象 fast path<br/>设置 scans bit 后扫描一个对象"]
    C -->|scanSpan release| E["批量路径<br/>toScan = marks &^ scans"]
    D --> A
    E --> A
    A -->|后续新 mark| B

release 返回的不是精确对象数,而是“one / many”的上界。这个信息足够决定是否走单对象 fast path;如果是 many,再去做完整 bitset diff。

为什么主要优化小对象

Green Tea 主要作用在小对象上。Alex Rios 的文章按 Go runtime 实现总结:64 位平台大致覆盖 16-512 字节对象,32 位平台上限更低。

原因有三点:

  1. 小对象数量多,更容易让对象 worklist 膨胀,也更容易产生大量随机跳转。
  2. 小对象 span 的对象大小一致,metadata 可以压成规则 bitset。
  3. 大对象本身扫描工作更重,单次 cache miss 的相对成本没那么突出,也不适合塞进同一套 inline mark bits 结构。

所以如果你的服务主要分配大 buffer、大 slice backing array,Green Tea 的直接收益通常有限。它优化的是“小而多、带指针、需要 GC 扫描”的那部分堆对象。

一个并发例子

把流程串起来看会更直观:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
worker 1 发现 span X 里的 object A
  -> 设置 marks[A]
  -> 获取 span X ownership
  -> span X 入队

worker 2 发现 span X 里的 object B
  -> 设置 marks[B]
  -> 获取 ownership 失败,因为 span X 已经在队列里
  -> 不重复入队

worker 1 取出 span X
  -> 发现 A、B 都需要扫描
  -> 按 span 内顺序扫描 A 和 B

旧方式可能把 A、B 当成两个独立对象 work item,分别触发两次更零散的访问。Green Tea 则把它们合并成同一 span 内的一次顺序扫描。

spanQueue 的实现

spanQueue 是 P-local 的。源码里每个 gcWork 持有一个 spanq,结构分两层:

  flowchart LR
    A["P-local gcWork.spanq"] --> B["本地 ring[256]<br/>非线程安全<br/>owner P 快速 push/pop"]
    B --> C{"本地 ring 满<br/>或周期性 spill?"}
    C -->|否| D["继续留在本地<br/>保持局部性"]
    C -->|是| E["drain 一部分 span"]
    E --> F["spanSPMC chain<br/>single producer<br/>multi consumer"]
    F --> G["其他 P tryStealSpan"]
    G --> H["refill 到偷取方本地 ring"]

这个实现有几个取舍:

  • 本地 ring 是热路径,避免为每个 span work item 打全局锁。
  • putsSinceDrain 每 64 次 put 检查一次是否 spill,最多 spill 16 个 span,目的是让其他 P 有活干,但不要太早破坏本地性。
  • ring 满时会 drain 一半到 SPMC chain,再把新 span 放入本地 ring。
  • SPMC chain 的生产者只有 owner P,消费者可以是其他 P。这样比通用 MPMC 队列更简单。
  • work.spanqMask 是一个近似提示,标记哪些 P 可能有可偷的 span;它允许 race,因为 GC mark done 的 ragged barrier 会兜底检查。

这解释了 Green Tea 为什么不是“把所有 span 丢进一个全局队列”。它既要保留 locality,又要在 GC worker 间提供足够的并行度。

和旧实现的兼容边界

Green Tea 是通过 build tag 接进 runtime 的。mgcmark_greenteagc.gogoexperiment.greenteagc,关闭实验时走 mgcmark_nogreenteagc.go。后者里 tryDeferToSpanScan 直接返回 falsetryGetSpanFast / tryGetSpan / tryStealSpan 都返回空,scanSpan 不应被调用。

所以原有对象队列没有消失:

  • root 扫描、写屏障、传统 scanObject 仍然会生产 grey object。
  • 不适合 Green Tea 的对象继续走 greyobject 和 workbuf。
  • 大对象仍然按 oblet 拆分,避免单个大对象扫描时间过长。
  • 小对象 span 的 mark bits 在 moveInlineMarks 里会合并回常规 gcBits,并重置 inline mark bits,为后续阶段复用。

源码里的 gcmarknewobject 也有一个 Green Tea 特例:mark phase 中新分配的、使用 inline mark bits 的对象会同时设置 mark bit 和 scanned bit。原因是这类对象按 Go GC 规则是 black allocation,不需要再被当前 mark cycle 扫描。

源码里还有几个接入点

只看 mgcmark_greenteagc.go 容易漏掉 Green Tea 和 runtime 其他模块的连接。

第一,span 的布局在 mheap.allocSpan 里确定。启用 Green Tea 后,如果 size class 使用 inline mark bits,runtime 会从 span 尾部预留 spanInlineMarkBits 的空间;如果对象带指针且 heap bits 存在 span 内,还会继续预留 pointer/scan bitmap 空间。s.nelems 是按扣掉这些 reserve 后的可用空间计算出来的,不是简单 8KiB / elemsize

第二,arena 里有 pageUseSpanInlineMarkBits 位图。span 分配时,如果它使用 inline mark bits,就设置对应 page bit;span 释放时再清掉。tryDeferToSpanScan 先查这个位图,是为了在热路径上快速判断“这个指针有没有机会走 Green Tea”,避免每次都查 mspan

这里顺手补一个背景:Go 并发标记期使用的是 hybrid write barrier。可以把它粗略理解成“写指针时,新旧两个指针相关对象都尽量灰化”,这样 tri-color invariant 才不会被并发写破坏。也正因为写屏障会持续制造新的 grey object,Green Tea 不能只接管 root 扫描产生的对象,还必须接住写屏障缓冲吐出来的这部分工作。

第三,写屏障缓冲也会尝试走 Green Tea。mwbbuf.go 里的 flush 逻辑在 findObject 和传统 mark bits 之前先调用 tryDeferToSpanScan(ptr, gcw);如果成功,说明目标对象已经由 span queue 接管,写屏障就不再把它放进普通 object workbuf。

第四,每个 P 的 gcWork 里有一个 ptrBuf,Green Tea 扫描 span 时会用它暂存扫描出来的指针地址或指针值。GC mark 开始前,mgc.go 会为每个 P 懒初始化一页大小的 ptrBuf

第五,Green Tea 下唤醒 mark worker 的时机更谨慎。普通 object workbuf flush、span queue spill、span queue steal 这些路径会设置 mayNeedWorkerspanqMask 这样的提示,再由 gcDrain 在安全位置调用 gcController.enlistWorker()。源码注释里特别提到,Green Tea 下调用 enlistWorker 时不能持有 G 的 scan bit,否则可能形成死锁风险。

Span 可以被多次入队

旧对象 worklist 里,一个对象在一个 mark phase 中通常只需要入队一次。Green Tea 不一样:一个 span 在一次 mark phase 里可能被多次放入队列。

原因也直接:第一次扫描 span X 时,只能处理当时已经发现的对象。后面扫描别的 span 时,可能又发现了新的指针指向 span X 里的其他对象。此时 span X 需要再次入队,处理新的 marked - scanned 差集。

所以 Green Tea 不是减少“逻辑扫描需求”,而是改变扫描的批次和顺序:

1
2
3
4
5
1. 发现 span A 里的 obj1,span A 入队
2. 扫描 span A 的 obj1,发现 span B 里的 obj3
3. span B 入队
4. 扫描 span B 时,又发现 span A 里的 obj2、obj4
5. span A 再次入队,只扫描 obj2、obj4

它仍然保持 tracing GC 的正确性,只是把 work item 从对象换成了页级区域。

SIMD 为什么能派上用场

Green Tea 的另一个价值是给 SIMD 打开了空间。

旧算法扫描对象时,对象大小、位置、metadata 形态都不够规则。今天扫 24 字节对象,下一步跳到 128 字节对象,再下一步跳到数组对象;这种模式很难向量化。

Green Tea 的 span 内对象来自同一 size class,metadata 也能压成规则 bitset。这样就可以做类似下面的位运算:

1
2
3
activeObjects = marked - scanned
activeWords   = expand(activeObjects, objectSize)
activePtrs    = activeWords & pointerBitmap

含义是:

  1. 找到本轮还没扫描的对象。
  2. 根据对象大小把“对象级 bit”展开成“机器字级 bit”。
  3. 与 pointer bitmap 相交,得到真正需要读取的指针位置。
  4. 再批量加载这些指针。

Go 1.26 release notes 提到,在 Intel Ice Lake、AMD Zen 4 及更新的 amd64 平台上,GC 会在可能时利用向量指令扫描小对象,并预期带来约 10% 量级的额外 GC 开销下降。

这里要注意两个限制:

  • SIMD 优化不是所有 CPU 都有。
  • SIMD 加速的是 GC 扫描小对象的部分路径,不是整个程序。

版本状态

按当前 Go 官方文档:

  • Go 1.25:Green Tea 是实验特性,构建时用 GOEXPERIMENT=greenteagc 启用。
  • Go 1.26:Green Tea 默认启用,可以用 GOEXPERIMENT=nogreenteagc 临时关闭。
  • Go 1.27 预期:Go 1.26 文档里说明 nogreenteagc 这个 opt-out 预计会移除。

所以如果你现在升级到 Go 1.26,默认已经在跑 Green Tea。测试对比时要反过来:Go 1.26 的 baseline 是 Green Tea,旧 GC 需要显式 GOEXPERIMENT=nogreenteagc

怎么判断你的服务有没有收益

不要只看 microbenchmark。GC 算法改变的是 runtime 行为,收益经常会被其他瓶颈吞掉。

一个实用验证流程:

1. 先确认 GC 是瓶颈

线上或压测环境先看 profile:

1
go tool pprof http://127.0.0.1:6060/debug/pprof/profile

重点看:

  • runtime.gcBgMarkWorker
  • runtime.scanobject
  • runtime.gcDrain
  • mark assist 相关调用
  • GC CPU 占总 CPU 的比例

如果 GC 只占 1%-2%,Green Tea 再快也很难改变整体性能。

2. 写 benchmark 时先确认真的分配了

Alex Rios 的 benchmark 文章里有个非常实用的提醒:你以为在测 GC,实际可能在测空循环。

例如下面这种 benchmark 看起来分配了对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func BenchmarkAllocation(b *testing.B) {
    for b.Loop() {
        obj := &SmallObject{
            ID:    42,
            Name:  "test",
            Value: 3.14,
        }
        _ = obj
    }
}

在 Go 1.26 之前,或者使用旧的 b.N 写法时,编译器很可能看出 obj 没有逃逸,直接把分配优化掉,结果是 0 B/op0 allocs/op。Go 1.26 修正了 B.Loop 的 keep-alive 语义,能避免 loop body 被整体消掉,但它仍然不等于强制堆分配;如果对象没有逃逸,你仍然测不到真实的 GC 压力。

runtime.KeepAlive 也不等于强制堆分配。它能避免对象被 GC 过早回收,但不能绕过逃逸分析。要制造 GC 压力,需要让对象真的逃逸,例如把指针放进堆上的 slice:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func BenchmarkWithGCPressure(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        objects := make([]*SmallObject, 1000)
        for j := 0; j < 1000; j++ {
            objects[j] = &SmallObject{
                ID:    int64(j),
                Name:  "pressure-test",
                Value: float64(j),
            }
        }
        runtime.KeepAlive(objects)
    }
}

写 GC benchmark 时至少确认三件事:

  • b.ReportAllocs() 输出里确实有 B/opallocs/op
  • 压测期间确实发生了多轮 GC。
  • benchmark 的对象大小和生命周期接近真实业务。

Go 1.24 引入的 b.Loop() 是更清晰的 benchmark 循环写法;Go 1.26 起它不再为了防止优化而阻止 loop body 内联,并且会保持 loop body 中的函数参数、返回值和赋值变量存活。但它不会自动制造堆分配,GC benchmark 仍然要用 B/opallocs/op 和 GC trace 确认真的产生了 heap pressure。

3. 看 gctrace

GODEBUG=gctrace=1 观察 GC 周期:

1
GODEBUG=gctrace=1 ./your-service

输出大概长这样:

1
gc 12 @5.931s 8%: 0.04+12+0.06 ms clock, 0.3+38/74/12+0.4 ms cpu, 180->220->96 MB, 195 MB goal, 8 P

重点不是单行数字,而是对比两组二进制在同样负载下的分布:

  • GC 百分比是否下降。
  • concurrent mark 的 wall time 是否下降。
  • mark CPU 是否下降。
  • STW pause 是否变化。
  • heap goal、live heap 是否接近。

gctrace0.04+12+0.06 ms clock 这类字段通常可以粗略理解成:sweep termination STW、concurrent mark、mark termination STW。Green Tea 主要优化的是中间的 concurrent mark 和对应 CPU 消耗,不应该期待它把所有 STW pause 都显著压低。

如果需要在程序里采样,可以看 runtime/metrics

1
2
3
4
5
6
7
samples := []metrics.Sample{
    {Name: "/gc/heap/allocs:bytes"},
    {Name: "/gc/heap/goal:bytes"},
    {Name: "/gc/scan/heap:bytes"},
    {Name: "/sched/pauses/stopping/gc:seconds"},
}
metrics.Read(samples)

这里 /sched/pauses/stopping/gc:seconds 能看 GC 停顿分布,/gc/scan/heap:bytes 能辅助判断扫描规模。

4. Go 1.25 和 Go 1.26 的命令不同

Go 1.25 对比:

1
2
3
4
5
6
7
# 旧 GC baseline
go test -bench=. -benchmem -count=10 -benchtime=5s ./... > oldgc.txt

# Green Tea
GOEXPERIMENT=greenteagc go test -bench=. -benchmem -count=10 -benchtime=5s ./... > greentea.txt

benchstat oldgc.txt greentea.txt

Go 1.26 对比:

1
2
3
4
5
6
7
# Green Tea baseline
go test -bench=. -benchmem -count=10 -benchtime=5s ./... > greentea.txt

# 旧 GC opt-out
GOEXPERIMENT=nogreenteagc go test -bench=. -benchmem -count=10 -benchtime=5s ./... > oldgc.txt

benchstat oldgc.txt greentea.txt

-count=10 不是形式主义。GC、调度、CPU 频率、NUMA、容器配额都会带来波动,单次 benchmark 经常没有判断价值。

5. 压测真实路径

如果是服务端程序,最终要看真实压测:

  • QPS / TPS。
  • P50、P95、P99 延迟。
  • CPU 使用率。
  • RSS / heap live / heap goal。
  • GC pause 和 GC CPU。
  • 锁竞争、数据库耗时、网络耗时是否变成新瓶颈。

DoltHub 曾经测试过 Go 1.25 的实验 Green Tea GC:他们的 SQL 数据库工作负载里,整体吞吐和延迟几乎没有变化,底层 GC mark time 甚至出现小幅不利结果。这个案例很有参考价值:GC 自身优化了,不代表业务指标一定变好。

什么时候收益更可能明显

可以用下面这个判断表做预期管理。

工作负载特征 Green Tea 预期
大量小对象,GC CPU 高 更可能收益明显
对象分配和访问有聚集性 更可能收益明显
指针密集、live heap 较大 值得重点测试
大对象为主 收益有限
对象图非常稀疏,每个 span 只扫一两个对象 可能收益很小
瓶颈在数据库、锁、网络、序列化 业务指标可能看不出变化
GC 占 CPU 很低 整体收益通常很小

官方 blog 也提到一个关键 caveat:Green Tea 的假设是 span 在队列中等待时能积累足够多的待扫描对象。如果很多时候一个 span 每次只扫一个对象,那么它为了积累和维护 metadata 付出的额外成本就可能抵消收益。

不过它不需要“扫满整个 span”才有效。官方文章提到,即使一次只扫描一个 page 的约 2%,也可能优于原来的 graph flood。原因是随机内存访问的代价太高,少量局部性改善也能抵消一些额外 bookkeeping。

对业务代码的启发

Green Tea 的价值不只是“Go 运行时更快了”。它也提醒我们:性能问题经常不是算法复杂度一个维度。

同样是 O(n),连续扫 slice 和沿着 pointer-heavy linked list 跳转,CPU 看到的是两种完全不同的程序。

写业务代码时也可以借鉴这个方向:

  • 能用 slice 就别轻易用链表式结构。
  • 热路径上减少指针层级和零散小对象。
  • 把经常一起访问的数据放在一起。
  • 批量处理相邻数据,避免一个对象一个对象跨堆跳。
  • 用 pprof 和真实压测判断,不靠直觉判断。

Green Tea 做的事,本质上是让 GC 更尊重硬件:少随机跳转,多连续扫描;少处理孤立对象,多处理相邻区域。

小结

Green Tea GC 没有改变 Go GC 的大框架。Go 仍然是并发、非移动、mark-sweep 的 tracing GC。变化发生在 mark 阶段的工作调度:从对象优先,改成 span 优先。

这个变化解决的是现代 CPU 上越来越突出的内存局部性问题。旧 GC 的对象图遍历会频繁随机访问 heap,Green Tea 通过 per-span metadata、span 队列、FIFO 积累和小对象扫描批处理,让 GC 更容易命中缓存,也为 AVX-512 等向量指令留下空间。

实际落地时要冷静:如果你的程序 GC 压力高、小对象多、对象分布有局部性,Green Tea 可能带来可观收益;如果瓶颈不在 GC,或者对象图很稀疏,它可能几乎没有业务可见效果。正确姿势是升级 Go 1.26 后做 A/B 压测,结合 pprof、gctrace、benchstat 和业务指标一起判断。

参考资料

#Go #Golang #Go GC #Green Tea GC #Runtime #Performance