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) | 默认 | 作用 |
|---|---|---|---|---|
containermaxprocs | runtime | 25 | 1(开) | 是否把 cgroup CPU 限额计入默认 GOMAXPROCS |
updatemaxprocs | runtime | 25 | 1(开) | 是否周期性更新 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=2 | 6 | 2 | 容器感知生效,跟 CPU 限额走 |
--cpus=4 | 6 | 4 | 跟随限额变化 |
--cpus=2 + containermaxprocs=0 | 6 | 6 | 关掉开关后回退到「看到几个核就用几个」 |
注意 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 实跑与迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。