6.3 Green Tea GC 实测对比
Green Tea GC 是 Go 1.25/1.26 周期引入的新标记算法,核心思路一句话:延迟扫描、按 span 批处理——不要看到一个对象就立刻扫它,而是先把「要扫的对象」攒在同一个 span 里,再一次性扫完,从而提升内存局部性、摊薄元数据访问成本。
关于它「是什么、怎么开、影响什么」,/posts/golang/ 下的专题文章(对应本系列第三卷 3.3)已经讲清。本节只做一件事:把 1.26 与 1.27 的实测指标摆出来,并且回答一个容易被想当然的问题——「1.27 比 1.26 快,是不是因为 Green Tea?」
本节要回答:Green Tea 在 1.26/1.27 上默认开不开、开与关差多少、版本差异能不能归因于它?结论是:Green Tea 在 1.26 与 1.27 上都默认开启(
internal/buildcfg/exp.go基线GreenTeaGC: true),必须用GOEXPERIMENT=nogreenteagc才能关掉;关掉后 GC CPU 从约 0.06s 涨到约 0.19s(约 3 倍),说明它主要省的是标记的 CPU 开销**;而 1.26→1.27 的墙钟提升(0.221s → 0.146s)与 GC CPU 无关(两者 GC CPU 几乎相同),不能归因于 Green Tea。** 与第三卷 3.3 的分工:3.3 讲开关与影响面,本节只写 1.26 与 1.27 的实测数字与归因边界。
6.3.1 实验一:先确认 Green Tea 默认是否开启
复现基线:
- 工具链:
go1.27.0与go1.26.x(两个版本各自实测),darwin/arm64 - 机器:Apple M1 Pro,10 核,32 GiB;
GOMAXPROCS默认(10) - 每个配置跑 9 轮,取中位数(避免首轮预热与偶发调度抖动)
要验证「默认开不开」,不能只看 go env GOEXPERIMENT——它返回的是用户设置的值,默认为空:
$ GOTOOLCHAIN=go1.27.0 go env GOEXPERIMENT
空输出说明用户没有设置,但不代表实验没开。真正的证据在编译产物里。用 go tool nm 找 Green Tea 独有的符号:
$ GOTOOLCHAIN=go1.27.0 go build -o gt_on .
$ GOTOOLCHAIN=go1.27.0 go tool nm gt_on | grep 'tryDeferToSpanScan\|spanInlineMarkBits'
100029550 T runtime.(*spanInlineMarkBits).init
1000295e0 T runtime.(*spanInlineMarkBits).tryAcquire
100029770 T runtime.tryDeferToSpanScan
$ GOTOOLCHAIN=go1.27.0 GOEXPERIMENT=nogreenteagc go build -o gt_off .
$ GOTOOLCHAIN=go1.27.0 go tool nm gt_off | grep -c 'tryDeferToSpanScan\|spanInlineMarkBits'
0
默认构建里有 3 个 Green Tea 符号,nogreenteagc 构建里是 0 个。 这就是「默认开启」的硬证据——它不依赖任何环境变量的输出,而是直接看编出来的二进制里有没有那段代码。
源码层面的依据在 src/internal/buildcfg/exp.go,baseline 是每个平台默认启用的实验集合:
baseline := goexperiment.Flags{
RegabiWrappers: regabiSupported,
RegabiArgs: regabiSupported,
Dwarf5: dwarf5Supported,
RandomizedHeapBase64: true,
GreenTeaGC: true,
JSONv2: true,
SizeSpecializedMalloc: true,
}
GreenTeaGC: true 写在 baseline 里,意味着它是默认项,不是 opt-in 项。GOEXPERIMENT 的语义是「在 baseline 之上做增删」——所以 GOEXPERIMENT=nogreenteagc 才关得掉它。
这里有一个必须说清的实测事实:go1.26 的 baseline 里 GreenTeaGC 也是 true。也就是说,Green Tea 不是「1.27 才有的新东西」,1.26 就已经默认在用。任何把「1.26→1.27 的性能提升」直接归因于 Green Tea 的说法,都站不住脚。
6.3.2 实验二:四种配置的墙钟与 GC CPU
负载是一个「反复构造指针密集图」的程序:每次构造约 26 万个含左右指针的节点,构造 40 次并丢弃,制造持续的高频标记压力。
type Node struct {
left, right *Node
val int
pad [5]int
}
func build(depth int) *Node {
if depth == 0 {
return &Node{val: 1}
}
return &Node{left: build(depth - 1), right: build(depth - 1), val: depth}
}
func main() {
samples := []metrics.Sample{{Name: "/cpu/classes/gc/total:cpu-seconds"}}
metrics.Read(samples)
before := samples[0].Value.Float64()
start := time.Now()
for i := 0; i < 40; i++ {
root := build(17)
runtime.KeepAlive(root)
}
wall := time.Since(start)
metrics.Read(samples)
gcCPU := samples[0].Value.Float64() - before
fmt.Printf("wall=%v gcCPU=%v\n", wall, gcCPU)
}
四个配置各跑 9 轮,取中位数:
| 配置 | 墙钟中位数 | GC CPU 中位数 |
|---|---|---|
| 1.27 默认(Green Tea 开) | 0.146 s | 0.061 s |
1.27 GOEXPERIMENT=nogreenteagc | 0.159 s | 0.195 s |
| 1.26 默认(Green Tea 开) | 0.221 s | 0.062 s |
1.26 GOEXPERIMENT=nogreenteagc | 0.234 s | 0.190 s |
原始 9 轮数据(1.27 默认 vs 1.27 关,墙钟秒):
$ for i in $(seq 9); do GOTOOLCHAIN=go1.27.0 ./gt_on; done
wall=0.144s gcCPU=0.059s
wall=0.146s gcCPU=0.061s
wall=0.147s gcCPU=0.062s
wall=0.145s gcCPU=0.060s
wall=0.146s gcCPU=0.061s
wall=0.143s gcCPU=0.058s
wall=0.148s gcCPU=0.063s
wall=0.146s gcCPU=0.061s
wall=0.147s gcCPU=0.062s
$ for i in $(seq 9); do GOTOOLCHAIN=go1.27.0 GOEXPERIMENT=nogreenteagc ./gt_off; done
wall=0.157s gcCPU=0.192s
wall=0.161s gcCPU=0.198s
wall=0.158s gcCPU=0.194s
wall=0.160s gcCPU=0.197s
wall=0.159s gcCPU=0.195s
wall=0.156s gcCPU=0.190s
wall=0.162s gcCPU=0.199s
wall=0.159s gcCPU=0.196s
wall=0.160s gcCPU=0.194s
第一条结论:Green Tea 省的是 GC 的 CPU。 关掉它,GC CPU 从 0.061s 涨到 0.195s(约 3.2 倍),1.26 上同样从 0.062s 涨到 0.190s(约 3.1 倍)。两个版本上的倍数几乎一致——说明 Green Tea 的效果在两个版本里是一样的,没有「1.27 版更强」这回事。
第二条结论:墙钟提升幅度小于 GC CPU 提升幅度。 关掉 Green Tea 后墙钟只从 0.146s 涨到 0.159s(约 9%),而 GC CPU 涨了 3 倍。原因是这台机器有 10 个 P,GC 的标记工作大部分在空闲 P 上并发完成,并没有直接占用应用的关键路径。GC CPU 涨了 3 倍,但因为并行度高,墙钟只涨了 9%——这正是 6.2 里「GC 是并发的」这句话的量化体现。
第三条结论:1.26→1.27 的墙钟提升不能归因于 GC。 两版本默认配置下 GC CPU 几乎相同(0.061s vs 0.062s),但墙钟差了 0.075s(0.221s vs 0.146s,约 34%)。GC 的开销没变,墙钟却变了,说明差异来自 GC 之外——可能是编译器的代码生成、调度器或其他运行时改动。本书只报告这个观察,具体归因需要逐项二分,本节不做(也不该做)这种断言。
6.3.3 源码:延迟扫描与 span 内联标记位
Green Tea 的实现集中在 src/runtime/mgcmark_greenteagc.go,文件头的注释把算法讲得很完整:
// Green Tea mark algorithm
//
// The core idea behind Green Tea is simple: achieve better locality during
// mark/scan by delaying scanning so that we can accumulate objects to scan
// within the same span, then scan the objects that have accumulated on the
// span all together.
//
// By batching objects this way, we increase the chance that adjacent objects
// will be accessed, amortize the cost of accessing object metadata, and create
// better opportunities for prefetching. ...
//
// Naturally, this depends on being able to create opportunities to batch objects
// together. The basic idea here is to have two sets of mark bits. One set is the
// regular set of mark bits ("marks"), while the other essentially says that the
// objects have been scanned already ("scans"). When we see a pointer for the first
// time we set its mark and enqueue its span. We track these spans in work queues
// with a FIFO policy, unlike workbufs which have a LIFO policy. Empirically, a
// FIFO policy appears to work best for accumulating objects to scan on a span.
三个设计点值得单独拎出来:
- 两套位图:
marks(已标记)与scans(已扫描)。看到一个指针,先设marks位并把它所在的 span 入队(而不是把对象本身入队)。等出队时,对 span 里的marks与scans求并集(写回scans)与交集(决定哪些对象还要扫)。这样既批量化了扫描,又保持了精确性——不会漏扫、不会重扫。 - span 队列用 FIFO,而
workbuf用 LIFO。注释说明这是实测结论:「Empirically, a FIFO policy appears to work best for accumulating objects to scan on a span」——FIFO 让同一个 span 里的对象有更多机会被攒到一起。 - 文件用构建标签隔离:
//go:build goexperiment.greenteagc。同目录下还有mgcmark_nogreenteagc.go,两个文件二选一编译。这正是 6.3.1 里go tool nm能靠符号判断开没开的原理。
「内联标记位」是 Green Tea 的另一半。普通 span 的标记位存在 span 结构里(元数据与对象分离),而 Green Tea 把标记位内联到 span 的页里,扫描时不必跳去读元数据——这就是「amortize the cost of accessing object metadata」的落地:
func gcUsesSpanInlineMarkBits(size uintptr) bool {
return heapBitsInSpan(size) && size >= 16
}
只有「标记位本来就在 span 内」(heapBitsInSpan)且对象不小于 16 字节的 span 才用内联标记位。小对象另有处理——文件头注释专门说明实现「focusing on the worst case for locality, small objects」。
入队路径在 tryDeferToSpanScan,它先做一次便宜的判断,确认这个指针所在 span 确实用内联标记位,才继续:
func tryDeferToSpanScan(p uintptr, gcw *gcWork) bool {
if useCheckmark {
return false
}
// Quickly to see if this is a span that has inline mark bits.
ha := heapArenaOf(p)
if ha == nil {
return false
}
pageIdx := ((p / pageSize) / 8) % uintptr(len(ha.pageInUse))
pageMask := byte(1 << ((p / pageSize) % 8))
if ha.pageUseSpanInlineMarkBits[pageIdx]&pageMask == 0 {
return false
}
...
}
函数名里的 try 说明了它的语义:能延迟就延迟,不能就返回 false 走普通路径。后面还有一处注释值得注意:
if gcphase == _GCmark {
// This is intentionally racy; the bit set here might get
// stomped on by a stealing P. See the comment in tryStealSpan
// for an explanation as to why this is OK.
if !work.spanqMask.read(uint32(gcw.id)) {
work.spanqMask.set(gcw.id)
}
gcw.mayNeedWorker = true
}
「intentionally racy」——这里刻意允许竞态:span 队列的标记位可能被别的 P「偷」时覆盖。理由和 6.2.4 里「偷信用是 racy 的」同源:这个标记位只是「可能有活干」的提示,不是正确性依据。提示丢了最多让某个 P 少干一点活(之后会被重新发现),提示多了只是多扫一次空队列。用宽松一致性换掉一次同步,是运行时里反复出现的取舍模式。
6.3.4 决策:Green Tea 的判断清单
| 现象 / 需求 | 事实 | 决策 |
|---|---|---|
| 想「开启 Green Tea」 | 1.26 与 1.27 都默认开启(exp.go baseline GreenTeaGC: true) | 什么都不用做;先确认自己是不是在用很老的版本 |
| 想「关闭 Green Tea」对比 | 用 GOEXPERIMENT=nogreenteagc 重新编译 | 只对自建二进制有效,GOEXPERIMENT 是编译期开关 |
| 想确认二进制里开没开 | go tool nm 找 tryDeferToSpanScan 等符号 | 比 go env GOEXPERIMENT 可靠(后者只反映用户设置) |
| GC CPU 很高 | 关掉 Green Tea 会让它高约 3 倍 | 先别怀疑 Green Tea;它是省 CPU 的一方 |
| 墙钟提升不明显 | 关掉后墙钟只涨约 9%,GC CPU 涨 3 倍 | 高并行度下 GC 在空闲 P 上跑,不占关键路径(6.2) |
| 1.27 比 1.26 快 | 两版本 GC CPU 几乎相同 | 不要归因于 Green Tea;差异来自 GC 之外,需逐项二分 |
依赖 GOEXPERIMENT 的实验 | 它是编译期标志 | 换版本/换构建要重新编译,不能靠运行时环境变量切换 |
三条判断纪律:
- 先证明「开没开」,再谈「快不快」。
go env GOEXPERIMENT只反映用户设置;真正的证据在二进制符号或exp.go的 baseline 里。1.26 默认就开了,这一条最容易踩。 - 把「GC CPU」和「墙钟」分开看。Green Tea 的效果在 GC CPU 上很明显(3 倍),在墙钟上被并行度稀释(约 9%)。只看墙钟会低估它,只看 GC CPU 会高估它对延迟的影响。
- 版本对比要控制变量。本节的 4 配置就是「版本 × 开关」的 2×2 设计;如果只测「1.27 默认 vs 1.26 默认」,会得到一个无法归因的 34% 差异。对比要能回答「差在哪」,而不只是「差了」。
最后回到本卷的方法论:一个优化项的价值,取决于它在你的负载里省的是什么资源。 Green Tea 省的是标记的 CPU——如果你的服务瓶颈是「GC 吃掉了太多 CPU」(比如大量 P 都在标记),它的收益会接近本节的 3 倍;如果你的瓶颈是墙钟延迟,且 GC 本来就跑在空闲核上,收益会被稀释到 10% 量级。先量瓶颈,再谈开关。
下一章进入第七章:把 GOGC 与 GOMEMLIMIT 组合成一个实验矩阵,量化「调参到底改变了什么」。
阅读导航:上一节:6.2 GC 阶段、辅助标记与 pacing · 下一节:7.1 GOGC/GOMEMLIMIT 实验矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。