《Go 语言运行时原理》6.3 Green Tea GC 实测对比

实测 Green Tea GC:1.27 与 1.26 各跑「默认 / GOEXPERIMENT=nogreenteagc」两种配置、9 轮取中位数,Green Tea 把 GC CPU 从约 0.19s 降到约 0.06s(约 3 倍),但 1.26→1.27 的墙钟提升并不来自 GC;并钉到 mgcmark_greenteagc.go 的 span 内联标记位与延迟扫描实现。

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 s0.061 s
1.27 GOEXPERIMENT=nogreenteagc0.159 s0.195 s
1.26 默认(Green Tea 开)0.221 s0.062 s
1.26 GOEXPERIMENT=nogreenteagc0.234 s0.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 的实验它是编译期标志换版本/换构建要重新编译,不能靠运行时环境变量切换

三条判断纪律:

  1. 先证明「开没开」,再谈「快不快」。go env GOEXPERIMENT 只反映用户设置;真正的证据在二进制符号或 exp.go 的 baseline 里。1.26 默认就开了,这一条最容易踩。
  2. 把「GC CPU」和「墙钟」分开看。Green Tea 的效果在 GC CPU 上很明显(3 倍),在墙钟上被并行度稀释(约 9%)。只看墙钟会低估它,只看 GC CPU 会高估它对延迟的影响。
  3. 版本对比要控制变量。本节的 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 实验矩阵 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练