《Go 语言高级编程》3.3 Green Tea GC 与容器感知 GOMAXPROCS

Green Tea GC 在 1.25 是可选的 GOEXPERIMENT,1.26 起成为基线默认;本节用各工具链的 buildcfg 源码钉死这一结论,并诚实报告本机微基准无法分离出它的独立收益。后半部分用一个真实受限 CPU 的 Docker 容器验证容器感知 GOMAXPROCS:--cpus=2 时 GOMAXPROCS 从 6 变成 2,关闭开关后恢复为 6。

3.3 Green Tea GC 与容器感知 GOMAXPROCS

运行时里有两件看似无关、却都在 Go 1.25/1.26 落地的事:一件是垃圾回收器换了个更会利用 CPU 缓存的实现(Green Tea GC),另一件是默认 GOMAXPROCS 开始考虑容器的 CPU 限额。前者影响吞吐,后者影响「你的服务在 Kubernetes 里到底用几个核」——后者出错的代价往往更大。

这两个话题也是网上错误信息最集中的地方:「Green Tea GC 默认开了吗」「1.25 还是 1.26」「容器里 GOMAXPROCS 会自动降吗」。本节把每条结论都钉在本机证据上,并且——该说测不出来就说测不出来。

本节要回答:Green Tea GC 的开关状态到底是什么?容器感知 GOMAXPROCS 真的生效吗?结论:Green Tea GC 在 Go 1.25 是可选的 GOEXPERIMENT=greenteagc,Go 1.26 起进入基线默认;容器感知 GOMAXPROCS 自 Go 1.25 默认开启,实测 --cpus=2 的容器里 GOMAXPROCS 从 6 降到 2。

3.3.1 Green Tea GC 是什么

Green Tea GC 是 Go 运行时垃圾回收器的一次实现级重写。传统 GC 的标记阶段在扫描对象时,内存访问模式对 CPU 缓存很不友好——它按对象图跳着访问,缓存命中率低。Green Tea GC 的核心思路是按内存页(span)为单位批量扫描,把「扫描」这件事变得更连续、更适合 CPU 预取,从而降低标记阶段的开销,尤其在小对象密集的堆上。

它不改变 GC 的语义:STW 时机、GOGC/GOMEMLIMIT 的含义、回收的正确性都不变。它只改「标记阶段扫得快不快」。

3.3.2 版本归属与开关状态

这是本节最需要钉死的一张表。证据来自各工具链的 internal/buildcfg/exp.go 基线块,以及 GOEXPERIMENT 的接受情况:

工具链greenteagc 开关是否认识是否在基线(默认开)
go1.24.0不认识(unknown GOEXPERIMENT greenteagc)否
go1.25.0认识否(需显式 GOEXPERIMENT=greenteagc)
go1.26.3认识是(GreenTeaGC: true)
go1.27.0认识是(GreenTeaGC: true)

基线块的直接读数(用正则从源码里提取):

1.24.0 GreenTeaGC: False
1.25.0 GreenTeaGC: False
1.26.3 GreenTeaGC: True
1.27.0 GreenTeaGC: True

实测命令:

$ GOTOOLCHAIN=go1.24.0 GOEXPERIMENT=greenteagc go env GOEXPERIMENT
go: unknown GOEXPERIMENT greenteagc
$ GOTOOLCHAIN=go1.25.0 GOEXPERIMENT=greenteagc go env GOEXPERIMENT
greenteagc
$ GOTOOLCHAIN=go1.27.0 GOEXPERIMENT=nogreenteagc go env GOEXPERIMENT
nogreenteagc

三条结论:

  • 1.24 没有 Green Tea GC(实验名不存在);
  • 1.25 有但默认关闭,要显式 GOEXPERIMENT=greenteagc 才启用;
  • 1.26 起默认开启,且仍可用 nogreenteagc 显式关闭(1.27 同样)。

注意一个容易混淆的点:go env GOEXPERIMENT 在默认情况下输出为空。空不等于「没有实验特性在生效」——它只是说「没有相对基线的显式改动」。Green Tea GC 在 1.26/1.27 是基线的一部分,所以 GOEXPERIMENT 为空时它依然是开着的。这正是为什么不能只看 go env GOEXPERIMENT 就下结论。

3.3.3 实测:微基准无法分离收益(诚实报告)

我在 1.27 上用同一个 GC 压力基准对照默认与 nogreenteagc:

type node struct {
	next  *node
	pad   [6]uintptr
	value uintptr
}

func BenchmarkGCChurn(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		var head *node
		for j := 0; j < 2000; j++ {
			head = &node{next: head, value: uintptr(j)}
		}
		runtime.KeepAlive(head)
	}
}

实测(Apple M1 Pro,-benchtime=2000x -count=5):

=== go1.27.0 默认(Green Tea GC 开) ===
BenchmarkGCChurn-10   2000   32434 ns/op   128006 B/op   2000 allocs/op
BenchmarkGCChurn-10   2000   28539 ns/op   128000 B/op   2000 allocs/op
BenchmarkGCChurn-10   2000   29035 ns/op   128000 B/op   2000 allocs/op
BenchmarkGCChurn-10   2000   26485 ns/op   128000 B/op   2000 allocs/op
BenchmarkGCChurn-10   2000   25439 ns/op   128000 B/op   2000 allocs/op

=== go1.27.0 nogreenteagc(Green Tea GC 关) ===
BenchmarkGCChurn-10   2000   31281 ns/op   128009 B/op   2000 allocs/op
BenchmarkGCChurn-10   2000   28055 ns/op   128000 B/op   2000 allocs/op
BenchmarkGCChurn-10   2000   26133 ns/op   128000 B/op   2000 allocs/op
BenchmarkGCChurn-10   2000   26508 ns/op   128003 B/op   2000 allocs/op
BenchmarkGCChurn-10   2000   25736 ns/op   128000 B/op   2000 allocs/op

诚实结论:在这个基准上,开与关的差异落在噪声范围内(默认组中位数约 28539 ns/op,关闭组约 26508 ns/op,关闭组甚至略快,但这不构成「Green Tea GC 更慢」的结论——两个组的样本互相重叠,属于测量噪声)。

我把它写出来,是因为「Green Tea GC 让性能提升 X%」这类说法必须带场景。这个基准太小、太短,GC 根本没被显著触发(B/op 稳定在 128000,说明分配量小、回收压力低),自然分不出高下。Green Tea GC 的收益要在小对象密集、堆较大、GC 频繁的场景里才可能显现,那需要更大的堆和更长的运行时间,超出了单条命令的合理时长。

作为对照,1.26 上跑同一基准:

=== go1.26.3 默认 ===
BenchmarkGCChurn-10   2000   50084 ns/op   128005 B/op   2000 allocs/op
BenchmarkGCChurn-10   2000   47595 ns/op   128000 B/op   2000 allocs/op

1.26 明显慢于 1.27,但不能把这个差异归因于 Green Tea GC——因为两个版本都开着它,1.27 还多了 SizeSpecializedMalloc 等基线实验,差异来源无法分离。想得到「Green Tea GC 到底快多少」的可靠数字,需要固定其它变量、在足够大的堆上做长时压测。

3.3.4 gctrace 输出长什么样

想观察 GC 行为,GODEBUG=gctrace=1 仍然是最直接的手段。实测 1.27 的输出:

$ GOTOOLCHAIN=go1.27.0 GODEBUG=gctrace=1 go run .
gc 1 @0.071s 0%: 0.029+0.37+0.038 ms clock, 0.29+0.084/0.49/0.14+0.38 ms cpu, 3->3->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 2 @0.135s 0%: 0.11+0.42+0.11 ms clock, 1.1+0.38/0.53/0+1.1 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 3 @0.137s 0%: 0.073+0.30+0.027 ms clock, 0.73+0.33/0.63/0.22+0.27 ms cpu, 3->3->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P

每行的字段含义:

字段含义
gc 1 @0.071s 0%第 1 次 GC,程序启动后 0.071s,GC 占用 CPU 比例 0%
0.029+0.37+0.038 ms clock三个阶段墙钟时间:STW 清扫终止、并发标记、STW 标记终止
0.29+0.084/0.49/0.14+0.38 ms cpu同三阶段的 CPU 时间(并发标记拆成辅助/后台/空闲三档)
3->3->0 MB标记前堆大小 → 标记后 → 存活堆
4 MB goal下次 GC 的目标堆大小
10 P本次 GC 使用的 P 数量

中间那段并发标记(0.37 ms)就是 Green Tea GC 优化的对象——它把标记改成按页批量扫描,目标是压低这段的 CPU 时间。要比较开关效果,应该统计这个字段的分布,而不是看单个基准的 ns/op。

同一台机器上对照默认与 nogreenteagc 的 gctrace:

=== 默认(Green Tea GC 开) ===
gc 2 @0.081s 0%: 0.095+0.57+0.025 ms clock, 0.95+0.45/0.78/0+0.25 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P

=== nogreenteagc(关) ===
gc 2 @0.076s 0%: 0.13+0.38+0.032 ms clock, 1.3+0.37/0.60/0+0.32 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P

单次 GC 的并发标记 CPU 时间在两组之间互有高低(上面一次默认组是 0.78ms、关闭组是 0.60ms),仍然分不出稳定差异——原因同上:堆太小、GC 次数太少。这也是我把结论写成「本机未实测独立收益」而不是给出一个百分比的原因。

3.3.5 容器感知 GOMAXPROCS

另一半是 GOMAXPROCS 的默认值。在 Go 1.25 之前,runtime.GOMAXPROCS(0) 默认等于机器的逻辑 CPU 数,完全无视容器 CPU 限额。一个 --cpus=2 的容器跑在 64 核宿主机上,Go 会以为有 64 个核可用,起 64 个 P,然后因为配额只有 2 核而疯狂限流(throttling),尾延迟飙升。

Go 1.25 引入了两个 GODEBUG 开关(来自 internal/godebugs 表):

开关包引入(Changed)默认作用
containermaxprocsruntime251(开)是否把 cgroup CPU 限额计入默认 GOMAXPROCS
updatemaxprocsruntime251(开)是否周期性更新 GOMAXPROCS 以跟随 CPU 亲和性或 cgroup 限额变化

go doc 之外的官方说明(/usr/local/go/doc/godebug.md)原文:

Go 1.25 added a new `containermaxprocs` setting that controls whether the Go
runtime will consider cgroup CPU limits when setting the default GOMAXPROCS.
The default value `containermaxprocs=1` will use cgroup limits in addition to
the total logical CPU count and CPU affinity. `containermaxprocs=0` will
disable consideration of cgroup limits. This setting only affects Linux.

注意最后一句:只影响 Linux。这解释了为什么在 macOS 上做实验看不出效果。

3.3.6 实测:真实受限容器里的 GOMAXPROCS

macOS 没有 cgroup,所以我在本机的 Docker 里跑真实的 Linux 容器(镜像 golang:1.27-alpine,实测容器内 go version 为 go1.27.2 linux/arm64)。同一份程序在不同 CPU 限额下输出:

$ docker run --rm --cpus=2 golang:1.27-alpine ... go run m.go
go version go1.27.2 linux/arm64
NumCPU= 6 GOMAXPROCS= 2

$ docker run --rm --cpus=4 golang:1.27-alpine ... go run m.go
GOMAXPROCS= 4 NumCPU= 6

$ docker run --rm --cpus=2 -e GODEBUG=containermaxprocs=0 golang:1.27-alpine ... go run m.go
GOMAXPROCS= 6 NumCPU= 6

三行对照,把机制说得清清楚楚:

容器 CPU 限额NumCPU()GOMAXPROCS(0)说明
--cpus=262容器感知生效,跟 CPU 限额走
--cpus=464跟随限额变化
--cpus=2 + containermaxprocs=066关掉开关后回退到「看到几个核就用几个」

注意 NumCPU() 始终是 6——它返回的是宿主机可见的逻辑 CPU 数,不受 cgroup 限额影响;只有 GOMAXPROCS 的默认值被限额修正了。这个区别很重要:判断「我该起多少 worker」要看 GOMAXPROCS(0),不是 NumCPU()。

宿主机上(macOS)的对照,验证「非 Linux 不生效」:

$ GOTOOLCHAIN=go1.27.0 go run .
NumCPU           = 10
GOMAXPROCS(0)    = 10
$ GOTOOLCHAIN=go1.27.0 GODEBUG=containermaxprocs=0 go run .
NumCPU           = 10
GOMAXPROCS(0)    = 10

macOS 上开关关不关都一样(都是 10),与文档「只影响 Linux」一致。显式设置 GOMAXPROCS=2 仍然有效(实测 GOMAXPROCS=2 环境变量下 GOMAXPROCS(0) 返回 2)——容器感知改的是「默认值」,不覆盖你的显式设置。

3.3.7 本机未实测的部分

为诚实起见,以下几点我本机未实测,原因如下:

  • Green Tea GC 的独立收益数字:本机微基准噪声大于效应,且无法在单条命令的时长内构造出足够大的堆来触发差异,故不给具体百分比。本机未实测(原因:基准规模不足)。
  • updatemaxprocs 的周期性更新:需要在运行中动态改变 cgroup 限额(如 docker update --cpus)并观察 GOMAXPROCS 随时间变化,属于长时实验,未做。本机未实测(原因:需长时动态变更 cgroup)。
  • 多平台差异:本节只在 linux/arm64 与 darwin/arm64 上验证,未覆盖 Windows、cgroup v1 等环境。

3.3.8 小结

  • Green Tea GC:Go 1.25 为可选 GOEXPERIMENT=greenteagc,Go 1.26 起进入基线默认,1.27 沿用;GOEXPERIMENT 为空不代表它没生效。
  • 本机微基准测不出开与关的差异(噪声范围内),Green Tea GC 的收益需要小对象密集 + 大堆 + 长时压测才能分离;观察点应是 gctrace 的并发标记 CPU 时间。
  • 容器感知 GOMAXPROCS 自 Go 1.25 默认开启(containermaxprocs、updatemaxprocs 两个 godebug,Changed: 25),只影响 Linux。
  • 实测 --cpus=2 容器里 GOMAXPROCS 从 6 降到 2,containermaxprocs=0 后恢复为 6;NumCPU() 始终不受限额影响。
  • 起 worker 数量应参考 GOMAXPROCS(0) 而非 NumCPU()。

第 3 章到此结束。下一章进入 Go 1.26/1.27 的标准库演进,先看被反复问「现在能用了吗」的 encoding/json/v2。

阅读导航:上一节:3.2 unique 包与值规范化 · 下一节:4.1 encoding/json/v2 实跑与迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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