Green Tea GC 深度拆解:Go 1.26 把跑了十年的三色标记换掉了——从「对象为中心」到「内存为中心」,一次为 CPU 缓存重写的垃圾回收器
Go 1.26 在 2026 年 2 月发布,release notes 里那段关于 GC 的描述写得很克制:
The Green Tea garbage collector, previously available as an experiment in Go 1.25, is now enabled by default after incorporating feedback.
一句话,波澜不惊。但如果你真的去翻 src/runtime/mgcmark_greenteagc.go 那两千多行代码,会发现这是 Go 自 1.5 引入并发三色标记以来,标记算法层面最大的一次改动。不是调参,不是加个开关,是把「扫描单个对象」这个核心循环整个换掉了。
我花了几天时间把设计文档、issue #73581 的讨论、以及 runtime 源码啃了一遍,又在自己的几个服务上做了对比测试。这篇文章不打算复述 release notes——那玩意儿五分钟就能读完。我想讲清楚三件事:
- 为什么必须改:老 GC 在 2026 年的硬件上到底烂在哪,那个「85% 和 35%」的数字是怎么来的
- 怎么改的:Green Tea 的 span 批量扫描、双位图、SPMC 工作窃取队列,源码级别怎么落地的
- 对你有什么用:哪些服务会受益、哪些不会、怎么测、怎么写代码去配合它,以及踩坑清单
如果你只想拿走结论,直接跳到最后一节的七条经验。想搞明白原理的,跟我一起往下走。
一、背景:这不是 GC 变慢了,是内存变慢了
1.1 那个被反复引用的数字
Go 团队在设计文档里给了两个非常刺眼的数据:
- 平均而言,85% 的 GC 时间花在 graph flood 的核心循环里——也就是 scan loop
- scan loop 里 超过 35% 的 CPU 周期纯粹是在等内存(stalled on memory access),这还不算连带效应
第二个数字是关键。它说的不是「GC 算法复杂度高」,而是「GC 的 CPU 在空转,等 DRAM 把数据搬过来」。
为什么?因为过去二十年,CPU 和内存的速度差在持续拉大:
| 层级 | 典型延迟(周期) | 相对 L1 |
|---|---|---|
| L1 cache | 4 | 1x |
| L2 cache | 12~14 | ~3x |
| L3 cache | 40~50 | ~12x |
| DRAM (本地 NUMA) | 200~300 | ~60x |
| DRAM (跨 NUMA) | 400~600 | ~120x |
一次 L1 命中和一次跨 NUMA 的 DRAM 访问,差了两个数量级。而现代服务器动辄 64 核、128 核,所有核共享一条物理带宽有限的内存总线。核越多,每个核能分到的带宽越少,NUMA 拓扑越复杂。
这就是「内存墙」(memory wall)在 GC 上的具体表现。
1.2 三色标记的原罪:它本质上是一次图的随机遍历
Go 的经典 GC 是标准的并发三色标记:
白色:还没被发现的对象(潜在垃圾)
灰色:已被发现,但它引用的对象还没检查
黑色:已被发现,且它引用的对象都已检查
算法骨架很简单:
// 伪代码,展示经典三色标记的核心循环
func markLoop(gcw *gcWork) {
for {
obj := gcw.tryGet() // 从工作缓冲区取一个灰色对象
if obj == 0 {
break
}
scanObject(obj, gcw) // 扫描它的每一个指针字段
}
}
func scanObject(obj uintptr, gcw *gcWork) {
// 1. 查这个对象在哪个 span(一次内存访问,很可能 cache miss)
span := spanOf(obj)
// 2. 查这个对象的指针位图(又一次内存访问)
bits := heapBitsForAddr(obj, span.elemsize)
// 3. 遍历每个指针字段
for i := 0; i < span.elemsize; i += ptrSize {
if bits.hasPointer(i) {
p := *(*uintptr)(unsafe.Pointer(obj + i))
if p != 0 && !isMarked(p) {
markObject(p) // 又要查 p 的 span 和 mark bits
gcw.put(p) // 入队
}
}
}
}
问题出在哪?这个循环对内存位置毫无概念。
gcw.tryGet() 拿到的下一个对象,可能在堆的任意位置。上一个对象在 0x00c000010000,下一个在 0x00c0a3f80000,物理上隔着几百 MB。CPU 的预取器(prefetcher)完全失效——它依赖顺序或跨步访问模式,而 GC 给它的是纯随机地址流。
更糟的是元数据访问。每扫描一个对象,至少要碰三处内存:
- 对象自身的数据(要读指针字段)
- 对象所属 span 的元数据(要知道 elemsize、size class)
- 对象的指针位图和 mark bits(在 span 尾部或 heapArena 里)
对于一个 32 字节的小对象,扫描它可能只需要读 4 个指针字,但为了这 4 个字,你可能触发了 3 次 cache miss。元数据访问的开销完全没被摊薄。
设计文档里那句话说得很准:
it exhibits extremely poor spatial locality—jumping between completely different parts of memory—poor temporal locality—blithely spreading repeated accesses to the same memory across the GC cycle—and no concern for topology.
空间局部性差、时间局部性差、对拓扑完全无感知。三样全占。
1.3 workbuf 的 LIFO 与多核竞争
老 GC 的工作分发也有问题。每个 P(processor)维护一个本地的 gcWork,里面是固定大小的 workbuf(可以理解为一个 256 或 512 项的指针栈)。为了保证并行度,worker 会频繁地把本地 workbuf 推到全局的 work.full 链表,或从全局拿。
// runtime/mgcwork.go 的大致结构
type gcWork struct {
wbuf1, wbuf2 *workbuf // 两个本地缓冲区,交替使用
bytesMarked uint64
heapScanWork int64
// ...
}
// 满了就推到全局
func (w *gcWork) put(obj uintptr) {
wbuf := w.wbuf1
if wbuf.nobj == len(wbuf.obj) {
w.balance() // 触发全局操作,加锁
wbuf = w.wbuf1
}
wbuf.obj[wbuf.nobj] = obj
wbuf.nobj++
}
w.balance() 和全局链表的操作要走 lock(&work.wbufSpans.lock)。在 8 核机器上这没什么,但在 88 核的 Sapphire Rapids 上,这就是实打实的竞争热点——所有核都在抢同一个锁的 cache line,false sharing 拉满。
这也解释了为什么后面 Green Tea 的收益随核数增加而变大。
二、Green Tea 的核心思想:延迟扫描,攒一波再干
2.1 一句话概括
源码文件开头那段注释,是整个设计最精炼的表达:
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.
翻译成人话:别一发现指针就急着扫,先记下来;等同一块内存区域里攒够了几个待扫对象,一次性全扫了。
这个想法为什么成立?因为程序本身是有局部性的。一棵树、一个 slice of struct、一个 map 的 bucket 数组,这些数据结构里相邻的节点大概率被分配在相邻的内存。老 GC 把它们打散成随机访问,Green Tea 试图把这个局部性「捡回来」。
2.2 从「对象队列」到「span 队列」
关键的数据结构变更:工作队列里放的不再是对象指针,而是 span。
Go 的堆内存按 span 组织:一个 span 是 8 KiB 的整数倍、8 KiB 对齐、里面全是同一个 size class 的对象。Green Tea 的原型只处理「小对象 span」——正好 8 KiB、对象 ≤ 512 字节的那些。
这个「全是同一 size class」的特性太关键了。因为:
- 对象大小固定 → 从地址反推对象下标只需要一次乘法和移位,不用查表
- 8 KiB 对齐 →
alignDown(p, 8192)就能拿到 span 基址,不用走spanOf()的多级查找 - 布局规整 → 后续 SIMD 优化才有可能
看源码里怎么算对象下标的:
// runtime/mgcmark_greenteagc.go
base := alignDown(p, gc.PageSize)
q := spanInlineMarkBitsFromBase(base)
objIndex := uint16((uint64(p-base) * uint64(gc.SizeClassToDivMagic[q.class.sizeclass()])) >> 32)
SizeClassToDivMagic 是预计算的「魔数除法」表。用乘法 + 右移代替除法,三条指令搞定,而且全程不需要访问 mspan 结构体——size class 直接存在 span 尾部的 inline mark bits 里。这就砍掉了一次依赖加载(dependent load),对流水线极其友好。
2.3 双位图:marks 和 scans
要实现「延迟扫描 + 批量处理」,需要区分两种状态:
- marks:这个对象被发现了(有指针指向它),需要扫描
- scans:这个对象已经扫描完了
对应三色抽象:
| marks | scans | 颜色 | 含义 |
|---|---|---|---|
| 0 | 0 | 白 | 没被发现 |
| 1 | 0 | 灰 | 已发现,排队等扫 |
| 1 | 1 | 黑 | 已扫描完 |
这两组位图直接内联在 span 的尾部:
type spanInlineMarkBits struct {
scans [63]uint8 // 已扫描位
owned spanScanOwnership // 所有权标记
marks [63]uint8 // 标记位
class spanClass // size class,避免访问 mspan
}
注意这个结构体的大小:63 + 1 + 63 + 1 = 128 字节,正好两个 cache line。这不是巧合,源码注释里明确提到:
We know that imb is both aligned and a nice power-of-two size that works well for wider SIMD instructions.
63 字节 × 8 = 504 位,足够覆盖 8 KiB span 里最小 16 字节对象的 512 个槽位(实际最小是 16 字节 → 512 个对象,用 504 位 + class 字段挤一挤刚好)。
拿到位置的方式也很直接:
func spanInlineMarkBitsFromBase(base uintptr) *spanInlineMarkBits {
return (*spanInlineMarkBits)(unsafe.Pointer(base + gc.PageSize - unsafe.Sizeof(spanInlineMarkBits{})))
}
span 基址 + 8192 - 128。一次算术,零查表。
2.4 完整流程走一遍
第一步:发现指针时(tryDeferToSpanScan)
func tryDeferToSpanScan(p uintptr, gcw *gcWork) bool {
// 1. 这个页是小对象 span 吗?查一个全局位图(每 8 KiB 一位,缓存友好)
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 // 大对象,走老路径
}
// 2. 算 span 基址和对象下标
base := alignDown(p, gc.PageSize)
q := spanInlineMarkBitsFromBase(base)
objIndex := uint16((uint64(p-base) * uint64(gc.SizeClassToDivMagic[q.class.sizeclass()])) >> 32)
// 3. 设置 mark 位
idx, mask := objIndex/8, uint8(1)<<(objIndex%8)
if atomic.Load8(&q.marks[idx])&mask != 0 {
return true // 已经标记过,直接返回,连队列都不用碰
}
atomic.Or8(&q.marks[idx], mask)
// 4. noscan 对象(不含指针)快速通道:标记完就结束
if q.class.noscan() {
gcw.bytesMarked += uint64(gc.SizeClassToSize[q.class.sizeclass()])
return true
}
// 5. 尝试把整个 span 入队(一个 span 同时只入队一次)
if q.tryAcquire() {
if gcw.spanq.put(makeObjPtr(base, objIndex)) {
// ... 唤醒 worker 的逻辑
}
}
return true
}
注意第 3 步的双重检查:先 Load8 再 Or8。这是典型的「读优先」优化——原子读比原子读改写便宜得多,而在真实负载里大量指针是重复指向已标记对象的。
注意第 4 步:noscan 对象根本不入队。像 []byte、string 的底层数组、纯 int 结构体,标记完就完事了,不产生任何队列压力。这在实际堆里是很大一块。
第二步:入队时的所有权协议(tryAcquire)
一个 span 可能被多个 worker 同时发现,但只能入队一次。这用一个 3 状态的所有权标记解决:
const (
spanScanUnowned spanScanOwnership = 0 // 没人占
spanScanOneMark = 1 << iota // 只有一个 mark 位被设置
spanScanManyMark // 可能有多个
)
func (imb *spanInlineMarkBits) tryAcquire() bool {
switch imb.owned.load() {
case spanScanUnowned:
if imb.owned.or(spanScanOneMark) == spanScanUnowned {
return true // 我是第一个,我负责入队
}
fallthrough
case spanScanOneMark:
// 已经有人入队了,但我也标记了对象,升级为 ManyMark
return imb.owned.or(spanScanManyMark) == spanScanUnowned
}
return false
}
OneMark 和 ManyMark 的区分是为了后面的单对象优化——如果一个 span 出队时只有一个待扫对象,那 Green Tea 的批量机制反而是纯开销,不如直接扫那一个对象。
有个实现细节值得一提。or 方法的注释解释了为什么要用 Or32 而不是 Or8:
func (o *spanScanOwnership) or(v spanScanOwnership) spanScanOwnership {
// Or8 不返回旧值,而这个协议必须要旧值
// 所以把地址向下对齐到 4 字节,用 Or32
o32 := (*uint32)(unsafe.Pointer(uintptr(unsafe.Pointer(o)) &^ 0b11))
off := (uintptr(unsafe.Pointer(o)) & 0b11) * 8
if goarch.BigEndian {
off = 32 - off - 8
}
return spanScanOwnership(atomic.Or32(o32, uint32(v)<<off) >> off)
}
为了不给 runtime 里其他热点路径引入「返回旧值的 Or8」(很多平台上没有这条原生指令),这里手工做了地址对齐和位移。这种抠细节的程度,是 runtime 代码的常态。
第三步:出队扫描时
出队时做三件事:
- 计算
marks和scans的差集(marks & ^scans)→ 这些是需要扫的对象 - 把
marks并入scans(scans |= marks)→ 标记为已扫 - 扫描差集里的每个对象
用位运算一次性处理 8 个对象(一个 uint8)、64 个对象(一个 uint64),比逐对象检查 mark bits 快得多。而且这些对象在物理内存上是连续的——同一个 8 KiB span 内——CPU 预取器终于能派上用场了。
2.5 为什么是 FIFO 而不是 LIFO
这是我觉得最有意思的一个设计决策。老 GC 的 workbuf 是 LIFO(栈),Green Tea 的 span 队列是 FIFO(环形缓冲)。
源码注释:
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.
设计文档里说得更细,他们试过 FIFO、LIFO、sparsest-first(最稀疏优先)、densest-first(最密优先)、random、address-ordered,最后 FIFO 在出队时平均密度最高。
道理其实不难想:FIFO 意味着 span 在队列里待的时间最长,等待期间就有最多的机会积累更多待扫对象。LIFO 是「刚入队就被取走」,几乎没有积累时间,退化成老 GC 的行为。
这是一个典型的「反直觉但对」的设计——LIFO 在传统 GC 里被偏爱是因为深度优先遍历的栈局部性更好,但 Green Tea 要的不是遍历局部性,是批量积累。目标变了,最优策略就变了。
2.6 单对象扫描优化
如果 FIFO 排了半天,出队时发现 span 上只有一个待扫对象,那所有的位图合并、差集计算都白做了。设计文档给了两个补救措施:
- 记住代表对象:入队时把触发入队的那个对象下标记在
objptr里(makeObjPtr(base, objIndex)) - hit flag:也就是前面的
spanScanManyMark。如果出队时 owned 只有OneMark,说明期间没有第二个对象被标记,直接扫代表对象,跳过整套合并流程
设计文档明确说,这个优化对 bleve-index 这种「低扇出二叉树 + 频繁旋转」的负载是决定性的——没有它,16 核 amd64 上这个 benchmark 会明显退化。
三、工作分发:从全局锁到分层 SPMC 队列
Green Tea 另一半的工程量在工作窃取上。
3.1 三层结构
type spanQueue struct {
// 第一层:P 本地环形缓冲,非线程安全,256 项
head, tail uint32
ring [256]objptr
putsSinceDrain int
// 第二层 + 第三层:SPMC 链
chain struct {
head *spanSPMC // 生产端(只有本 P 访问)
tail atomic.UnsafePointer // 消费端(其他 P 窃取)
}
}
- L1:本地 ring(256 项)——完全无同步,最快路径
- L2:spanSPMC(单生产者多消费者环形缓冲,初始 1024 项)——本 P 生产,其他 P 窃取
- L3:SPMC 链——L2 满了就翻倍扩容,挂成链表,最大
1<<20 / ptrSize= 128K 项
这个结构直接借鉴了 sync.Pool 的 dequeue 实现和 goroutine 调度器的 runq 设计。
3.2 溢出策略:主动制造并行度
最有意思的是 put 的逻辑:
func (q *spanQueue) put(s objptr) bool {
const (
spillPeriod = 64 // 每 64 次 put 检查一次是否要溢出
spillMax = 16 // 每次最多溢出 16 个
)
if q.putFast(s) {
q.putsSinceDrain++
if q.putsSinceDrain >= spillPeriod {
q.putsSinceDrain = 0
n := min((q.tail-q.head)/2, spillMax)
if n > 4 && q.chainEmpty() {
q.drain(n) // 主动把一半(上限 16 个)推到 SPMC
return true // 返回 true = 建议启动新 worker
}
}
return false
}
// 满了,强制溢出一半
q.drain(uint32(len(q.ring)) / 2)
if !q.putFast(s) {
throw("failed putFast after drain")
}
return true
}
注意那个约束:spillPeriod (64) > spillMax (16)。注释解释得很清楚:
spillPeriod must be > spillMax, otherwise that sets the effective maximum size of our local span queue.
如果每 K 次 put 就溢出 K 个,那本地队列的有效容量就永远是 K,256 项的 ring 白搭。必须溢出速率慢于积累速率,本地队列才能真正蓄水。
而 n > 4 && q.chainEmpty() 这个条件是说:只有本地攒够了、而且外面确实没活干的时候,才主动分享。这避免了「一边溢出一边被偷走」的抖动。
3.3 扩容策略:指数增长 + 上限
const maxCap = 1 << 20 / goarch.PtrSize // 64 位下 = 131072 项
newCap := q.chain.head.cap * 2
if newCap > maxCap {
newCap = maxCap
}
每次翻倍是为了「以后别再走这条慢路径」(分配新 SPMC 要拿全局锁)。设上限是为了避免单个 worker 排队 O(heap) 的工作量,然后永久持有那么大的内存。
注释里那句「we want to hit a steady-state where the deepest any queue goes during a mark phase can fit in the ring」——目标是稳态下一次都不用扩容。
四、SIMD:Green Tea 真正的杀手锏
前面所有的设计,其实都在为一件事铺路:让 GC 能用上向量指令。
4.1 为什么老 GC 用不了 SIMD
SIMD 要求数据布局规整、访问模式可预测。老 GC 的 scan loop 每次处理一个任意大小、任意位置的对象,指针位图每个对象都不一样——根本没法向量化。
Green Tea 改变了这个前提:
- 一个 span 里所有对象大小相同(同一 size class)
- 对象在 span 里紧密排列,地址是等差数列
- Go 对小对象用打包的指针/标量位图,同 size class 的位图模式完全一致
于是可以为每个 size class 生成一个专用的扫描内核(scanning kernel),用 SIMD 的 load / mask / swizzle / pack 指令,一口气从一批对象里把所有指针提取出来。
设计文档里的原话:
The core idea is to generate a unique scanning kernel for each size class and use SIMD bit manipulation and permutation instructions to load, mask, swizzle, pack, and enqueue pointers.
4.2 收益数字
Austin Clements 做的 AVX512 原型,在已经受益的 benchmark 上再降 15~20% 的 GC 开销。
Go 1.26 的 release notes 也确认了这一点:
Further improvements, on the order of 10% in garbage collection overhead, are expected when running on newer amd64-based CPU platforms (Intel Ice Lake or AMD Zen 4 and newer), as the garbage collector now leverages vector instructions for scanning small objects when possible.
所以现在有个很实际的结论:同样跑 Go 1.26,Ice Lake / Zen 4 以上的机器比老 CPU 多吃 10% 的 GC 红利。如果你在做机器选型,这是一个可以算进 TCO 的因素。
代码上,这部分逻辑在 internal/runtime/gc/scan 包里(源码 import 列表可以看到)。
4.3 一个副产品:simd/archsimd
Go 1.26 同时引入了实验性的 simd/archsimd 包(GOEXPERIMENT=simd 启用):
//go:build goexperiment.simd
import "simd/archsimd"
func sumFloat64x8(data []float64) float64 {
var acc archsimd.Float64x8
for i := 0; i+8 <= len(data); i += 8 {
v := archsimd.LoadFloat64x8(&data[i])
acc = acc.Add(v)
}
// 水平求和 + 处理尾巴
var out [8]float64
acc.Store(&out)
s := out[0] + out[1] + out[2] + out[3] + out[4] + out[5] + out[6] + out[7]
for i := len(data) &^ 7; i < len(data); i++ {
s += data[i]
}
return s
}
目前只支持 amd64,API 明确标注不稳定,而且是架构特定、不可移植的。官方说未来会做一个高层的可移植 SIMD 包。
我的判断:这个包短期内别在生产用,但它的存在说明 Go 团队终于认真对待「Go 不适合做数值计算」这个长期批评了。GC 用上 SIMD 是第一个内部客户,先把编译器和 runtime 的基础设施打通。
五、实测:怎么在自己的服务上验证
理论讲完了,说点能动手的。
5.1 最小可复现的对比实验
先写一个 GC 压力测试。关键是要造出「大量小对象 + 有一定局部性的引用结构」:
package main
import (
"fmt"
"runtime"
"runtime/debug"
"time"
)
type Node struct {
Left, Right *Node
Payload [3]int64 // 凑到 48 字节,落在小对象 span
}
func build(depth int) *Node {
if depth == 0 {
return &Node{}
}
return &Node{
Left: build(depth - 1),
Right: build(depth - 1),
}
}
func walk(n *Node) int {
if n == nil {
return 0
}
return 1 + walk(n.Left) + walk(n.Right)
}
func main() {
debug.SetGCPercent(100)
// 造一棵常驻的深树,制造持续的标记压力
root := build(21) // 约 200 万节点,~100 MB
fmt.Println("nodes:", walk(root))
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
start := time.Now()
const rounds = 30
for i := 0; i < rounds; i++ {
// 每轮制造一批临时垃圾,触发 GC
tmp := build(16)
_ = walk(tmp)
}
elapsed := time.Since(start)
runtime.ReadMemStats(&after)
fmt.Printf("wall: %v\n", elapsed)
fmt.Printf("num GC: %d\n", after.NumGC-before.NumGC)
fmt.Printf("GC pause: %v\n", time.Duration(after.PauseTotalNs-before.PauseTotalNs))
fmt.Printf("GC CPU frac: %.4f\n", after.GCCPUFraction)
runtime.KeepAlive(root)
}
跑两遍对比:
# Green Tea(Go 1.26 默认)
go build -o bench_gt . && ./bench_gt
# 关掉 Green Tea,回到老 GC
GOEXPERIMENT=nogreenteagc go build -o bench_old . && ./bench_old
注意 GOEXPERIMENT 是编译期变量,不是运行期环境变量。必须重新 build,不能 GOEXPERIMENT=nogreenteagc ./bench 这么跑——这是我第一次测的时候踩的坑,两个数据一模一样,纳闷了半天。
5.2 用 gctrace 看细节
GODEBUG=gctrace=1 ./bench_gt 2>&1 | head -20
输出格式:
gc 1 @0.012s 2%: 0.018+1.2+0.003 ms clock, 0.29+0.31/1.1/0.42+0.048 ms cpu, 4->4->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 16 P
拆开看:
2%— 从程序启动到现在,GC 占用的总 CPU 百分比。这是最该盯的数字0.018+1.2+0.003 ms clock— STW 扫描准备 + 并发标记 + STW 标记终止0.29+0.31/1.1/0.42+0.048 ms cpu— 对应的 CPU 时间。中间三段是assist/background/idle三种标记工作的 CPU4->4->2 MB— GC 开始时堆 / GC 结束时堆 / 存活堆16 P— GOMAXPROCS
对比 Green Tea 前后,重点看两个:
- 总 GC CPU 百分比(那个
2%) - background 标记的 CPU 时间(
0.31/1.1/0.42里的中间那个1.1)
如果 Green Tea 生效,background 标记时间应该明显下降。
5.3 用 runtime/metrics 做生产环境监控
生产环境别用 ReadMemStats(它会 STW)。用 runtime/metrics:
package gcmetrics
import (
"runtime/metrics"
"sync"
"time"
)
var (
once sync.Once
samples []metrics.Sample
)
const (
keyGCCPU = "/cpu/classes/gc/total:cpu-seconds"
keyGCMark = "/cpu/classes/gc/mark/assist:cpu-seconds"
keyGCMarkBG = "/cpu/classes/gc/mark/dedicated:cpu-seconds"
keyGCMarkIdl = "/cpu/classes/gc/mark/idle:cpu-seconds"
keyGCPause = "/gc/pauses:seconds"
keyHeapLive = "/memory/classes/heap/objects:bytes"
keyGCCycles = "/gc/cycles/total:gc-cycles"
)
type Snapshot struct {
GCCPUSeconds float64
MarkAssistCPU float64
MarkDedicatedCPU float64
MarkIdleCPU float64
HeapLiveBytes uint64
GCCycles uint64
PauseP99 time.Duration
}
func Read() Snapshot {
once.Do(func() {
for _, k := range []string{
keyGCCPU, keyGCMark, keyGCMarkBG, keyGCMarkIdl,
keyGCPause, keyHeapLive, keyGCCycles,
} {
samples = append(samples, metrics.Sample{Name: k})
}
})
metrics.Read(samples)
var s Snapshot
for _, smp := range samples {
switch smp.Name {
case keyGCCPU:
s.GCCPUSeconds = smp.Value.Float64()
case keyGCMark:
s.MarkAssistCPU = smp.Value.Float64()
case keyGCMarkBG:
s.MarkDedicatedCPU = smp.Value.Float64()
case keyGCMarkIdl:
s.MarkIdleCPU = smp.Value.Float64()
case keyHeapLive:
s.HeapLiveBytes = smp.Value.Uint64()
case keyGCCycles:
s.GCCycles = smp.Value.Uint64()
case keyGCPause:
s.PauseP99 = percentile(smp.Value.Float64Histogram(), 0.99)
}
}
return s
}
func percentile(h *metrics.Float64Histogram, p float64) time.Duration {
var total uint64
for _, c := range h.Counts {
total += c
}
if total == 0 {
return 0
}
target := uint64(float64(total) * p)
var cum uint64
for i, c := range h.Counts {
cum += c
if cum >= target {
// Buckets 比 Counts 多一个元素
return time.Duration(h.Buckets[i+1] * float64(time.Second))
}
}
return 0
}
把 MarkDedicatedCPU(后台专用标记 worker 的 CPU)单独打成指标,这是 Green Tea 影响最直接的部分。MarkAssistCPU(用户 goroutine 被迫帮忙做的标记)如果下降,说明 GC 整体跟得上分配速率了,用户请求延迟会跟着改善。
5.4 perf 看 cache miss
如果你在 Linux 上,想验证「局部性确实改善了」这个因果链:
perf stat -e cache-references,cache-misses,L1-dcache-load-misses,LLC-load-misses \
./bench_gt
perf stat -e cache-references,cache-misses,L1-dcache-load-misses,LLC-load-misses \
./bench_old
设计文档里说 GC-heavy 微基准上 L1 和 L2 的 miss 数减半。如果你的负载局部性好,应该能看到类似的量级。如果 miss 数没怎么变,那大概率你的堆拓扑不适合 Green Tea(后面讲)。
再进一步,看具体是哪个函数在 stall:
perf record -e cycles:pp -g ./bench_gt
perf report --sort=symbol | grep -E "runtime\.(scan|gcDrain|markroot)"
六、哪些负载受益,哪些不受益
这是最实用的一节。Green Tea 不是免费午餐,设计文档对此非常诚实。
6.1 明确受益:高扇出、局部性好的堆
tile38(内存地理空间数据库)的结果最漂亮:吞吐、延迟、内存占用全面改善,GC 开销降低 35%。
原因:堆的主体是一棵高扇出的树(R-tree)。高扇出意味着扫描一个节点能一次性产生几十个待扫子节点,而这些子节点往往在相邻内存——Green Tea 能迅速积累出高密度的 span。
对号入座,这类负载包括:
- 内存索引结构:B-tree、R-tree、trie、跳表
- 大批量的
[]*T或map[K]*V,且 T 是小结构体 - 缓存层:大量小对象 + 引用关系稳定
- 序列化/反序列化密集的 API 网关(大量短命小对象,同批分配)
6.2 明确不受益:低扇出 + 频繁重排
bleve-index 的结果是「整体打平」,16 核 amd64 上甚至有约 2% 的退化。
原因说得很透彻:
Most of the heap consists of a low-fanout binary tree that is rapidly mutated by the benchmark. Green Tea struggles to generate locality in this benchmark, and half of all span scans only scan a single object.
低扇出二叉树 + 频繁旋转 → 树的结构在 100+ MB 的堆上被彻底打散 → 一半的 span 扫描只扫到一个对象 → 批量机制变成纯开销。
然后是那句我认为整篇设计文档里最重要的话:
Green Tea has good locality when the application itself has good locality, but Green Tea can't create locality out of nothing.
Green Tea 能保留你的局部性,但造不出局部性。
对号入座,这类负载包括:
- 频繁 rebalance 的低扇出树(AVL、红黑树,且节点小)
- 长期运行、对象生命周期高度混杂的堆(新老对象交错分配)
- 大量通过指针间接跳转的链表结构
6.3 「变快了但看起来变慢了」的怪现象
设计文档提到一个反直觉的情况:有些 benchmark 明明 GC CPU 时间降了,端到端反而退化。两个原因:
浮动垃圾变少了。标记阶段更短 → 标记期间新分配的对象里,被误判为存活的(floating garbage)更少 → 堆更小 → 但某些 benchmark 靠这部分「压舱物」维持了较低的 GC 频率。堆变小了,GC 反而触发得更频繁。
瓶颈转移。GC 不占 CPU 了,用户代码和 runtime 其他部分的可扩展性瓶颈就暴露出来了(比如 mutex 竞争、channel 争用)。总时间没变,但责任人换了。
这个现象在生产环境很常见。升级到 Go 1.26 后如果发现 QPS 没涨,先看 GC CPU 是不是真的降了。如果降了但 QPS 没动,去 profile 找新的瓶颈,别怪 GC。
第 1 种情况的应对办法是调 GOGC:
// 如果 Green Tea 让堆变小、GC 变频繁,适当调高 GOGC 换取更低的 GC 频率
debug.SetGCPercent(200)
// 更推荐的做法:用 GOMEMLIMIT 做软上限,GOGC 设为 off 或很大
debug.SetMemoryLimit(6 << 30) // 6 GiB
debug.SetGCPercent(-1) // 关闭比例触发,完全由内存上限驱动
GOGC=off + GOMEMLIMIT 这个组合在容器环境里越来越流行:把内存吃到接近 limit 才回收,最大化利用已付费的内存,同时靠软上限兜底不 OOM。Green Tea 让标记更便宜,这个组合的性价比更高了。
6.4 怎么写代码去配合 Green Tea
既然 Green Tea 保留但不创造局部性,那创造局部性就成了应用层的事。几条实操建议:
(1)值切片 > 指针切片
// 差:1000 个独立的小对象,分散在堆上
type Registry struct {
items []*Item
}
// 好:一块连续内存,扫描时天然高密度
type Registry struct {
items []Item
}
如果 Item 里有指针字段,[]Item 的所有指针都在一块连续内存里,Green Tea 扫起来是顺序访问。[]*Item 则要跳 1000 次。
(2)批量分配:手工 arena
type NodeArena struct {
chunks [][]Node
idx int
}
const chunkSize = 1024
func (a *NodeArena) New() *Node {
if len(a.chunks) == 0 || a.idx == chunkSize {
a.chunks = append(a.chunks, make([]Node, chunkSize))
a.idx = 0
}
n := &a.chunks[len(a.chunks)-1][a.idx]
a.idx++
return n
}
同一批分配的节点物理相邻。建树的时候用 arena,树的局部性会好非常多。注意这不是真 arena(不能整块释放,GC 仍然管理),但对 Green Tea 的批量扫描很友好。
(3)指针稀疏化:能用索引就别用指针
// 差:GC 要扫描每个 Left/Right
type Node struct {
Left, Right *Node
Value int64
}
// 好:整个 []Node 对 GC 来说是 noscan,一次都不用扫
type Node struct {
Left, Right int32 // 索引,不是指针
Value int64
}
type Tree struct {
nodes []Node
}
func (t *Tree) Left(i int32) *Node {
if t.nodes[i].Left < 0 {
return nil
}
return &t.nodes[int(t.nodes[i].Left)]
}
这招是极端手段,但收益也极端。回忆前面 tryDeferToSpanScan 的第 4 步:noscan 对象连队列都不入。把一棵百万节点的树变成 noscan,GC 标记成本直接归零。
代价是失去类型安全和 nil 检查,代码可读性下降。适合真的成为瓶颈的核心数据结构,别到处乱用。
(4)别把小对象撑过 512 字节
Green Tea 只处理 ≤ 512 字节的小对象(小对象 span)。如果你的结构体是 520 字节,它走的还是老路径。
// 检查你的结构体大小
fmt.Println(unsafe.Sizeof(MyStruct{}))
如果卡在 512 边缘,考虑把冷字段拆到一个单独的指针字段里:
type Session struct {
ID uint64
UserID uint64
Expires int64
// ... 热字段
cold *SessionCold // 冷字段挪出去,主体压到 512 以内
}
七、Go 1.26 其他值得注意的 runtime 变化
Green Tea 抢了所有风头,但 1.26 的 runtime 还有几个改动值得单独说。
7.1 cgo 调用开销降低 ~30%
release notes 只有一句话,但这个数字很实在。如果你的服务大量走 cgo(SQLite、图像处理、加密库、各种 C 库绑定),升级就能白拿。
不过要提醒:cgo 调用的绝对开销本来就在几十到上百纳秒量级,降 30% 是从比如 60ns 到 42ns。如果你一次 cgo 调用要跑 10ms 的 C 代码,这 18ns 毫无意义。只有高频小粒度 cgo 调用才能感知到。
7.2 堆基址随机化
64 位平台上,runtime 现在在启动时随机化堆基址。这是纯安全加固——让攻击者更难预测内存地址,尤其是在有 cgo 的场景下(Go 自身的内存安全能挡住大部分,cgo 是缺口)。
# 如果有兼容性问题可以关掉(预计未来版本会移除这个开关)
GOEXPERIMENT=norandomizedheapbase64 go build .
我能想到会受影响的场景:某些做内存快照对比、或者硬编码了地址假设的调试工具。正常业务代码不用管。
7.3 goroutine 泄漏 profile(实验性)
这个我觉得比 Green Tea 更能直接帮到日常开发。
GOEXPERIMENT=goroutineleakprofile go build .
启用后,runtime/pprof 多一个 goroutineleak profile,net/http/pprof 多一个 /debug/pprof/goroutineleak 端点。
原理很聪明:复用 GC 的可达性分析。如果 goroutine G 阻塞在同步原语 P 上,而 P 从任何可运行的 goroutine(以及它们能唤醒的 goroutine)都不可达,那 P 永远不会被唤醒,G 就永久泄漏了。
release notes 给的例子非常经典,我敢说几乎每个 Go 项目里都写过:
type result struct {
res workResult
err error
}
func processWorkItems(ws []workItem) ([]workResult, error) {
ch := make(chan result) // 无缓冲!
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err} // 这里会永久阻塞
}()
}
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
return nil, r.err // 提前返回 → 剩下的 goroutine 全泄漏
}
results = append(results, r.res)
}
return results, nil
}
只要有一个 item 出错,剩下所有还没发送的 goroutine 全部卡死在 ch <- result{...} 上。ch 随后变得不可达,runtime 就能检测出来。
正确写法(顺手贴一下):
func processWorkItems(ctx context.Context, ws []workItem) ([]workResult, error) {
ch := make(chan result, len(ws)) // 修复 1:带缓冲,发送永不阻塞
ctx, cancel := context.WithCancel(ctx)
defer cancel() // 修复 2:提前返回时通知所有 worker
for _, w := range ws {
go func(w workItem) {
res, err := processWorkItem(ctx, w)
select {
case ch <- result{res, err}:
case <-ctx.Done(): // 修复 3:双保险
}
}(w)
}
results := make([]workResult, 0, len(ws))
for range ws {
r := <-ch
if r.err != nil {
return nil, r.err
}
results = append(results, r.res)
}
return results, nil
}
在 CI 里挂上这个 profile,是我看到的最值得做的事:
//go:build goexperiment.goroutineleakprofile
package main
import (
"os"
"runtime/pprof"
"testing"
)
func TestMain(m *testing.M) {
code := m.Run()
// 跑完所有测试后 dump 泄漏 profile
if p := pprof.Lookup("goroutineleak"); p != nil {
f, err := os.Create("goroutineleak.pprof")
if err == nil {
_ = p.WriteTo(f, 0)
_ = f.Close()
}
// 也可以直接断言泄漏数为 0
if n := p.Count(); n > 0 {
println("LEAKED GOROUTINES:", n)
code = 1
}
}
os.Exit(code)
}
局限性 release notes 也说了:如果同步原语通过全局变量或可运行 goroutine 的局部变量可达,检测不到。所以这不是万能的,但它抓的是最常见的那一类——「局部 channel + 提前返回」。
设计者说「实现已经是 production-ready,标为实验只是为了收集 API 反馈」,并且计划在 Go 1.27 默认开启。开销方面,「不使用时不产生任何额外运行时开销」。
7.4 编译器:更多切片分配到栈上
The compiler can now allocate the backing store for slices on the stack in more situations.
这个改动可能会影响你现有代码的行为——不是语义,是性能特征和内存布局。如果升级后遇到诡异问题:
# 用 bisect 定位是哪个分配出的问题
go install golang.org/x/tools/cmd/bisect@latest
bisect -compile=variablemake go test ./...
# 或者全局关掉这个优化
go build -gcflags=all=-d=variablemakehash=n .
同时可以用 escape analysis 看效果:
go build -gcflags='-m -m' ./... 2>&1 | grep -i "make.*does not escape"
7.5 go fix 变成了「现代化工具」
这个和 GC 无关,但顺手提一下,因为它会实际改变你的工作流。
老 go fix 是给 Go 1.0 之前的代码做 API 迁移的,早就没用了。1.26 把它整个重写,建在 go vet 同一套 analysis 框架上,变成了 modernizer 的集合:
go fix ./...
它会自动把代码改写成现代惯用法,比如:
for i := 0; i < n; i++→for i := range n(Go 1.22+)interface{}→anysort.Slice→slices.SortFunc- 手写的 min/max → 内置
min/max
还支持 //go:fix inline 指令来自动化你自己的 API 迁移:
// Deprecated: use NewClientWithOptions.
//
//go:fix inline
func NewClient(addr string) *Client {
return NewClientWithOptions(addr, DefaultOptions())
}
打上这个标记后,go fix 会把所有 NewClient(x) 的调用点自动展开成 NewClientWithOptions(x, DefaultOptions())。对于维护公共库的人,这是个很实用的工具——废弃 API 不用再靠文档求着用户改。
在 CI 里加一道检查:
go fix ./... && git diff --exit-code
八、升级实操清单
8.1 升级前
# 1. bootstrap 要求:Go 1.26 需要 Go 1.24.6+ 来编译自身
go version
# 2. 先在当前版本记录基线
GODEBUG=gctrace=1 ./your-service 2>&1 | tee baseline-gctrace.log
# 3. 采集一份 CPU profile 做对比
curl -o baseline-cpu.pprof 'http://localhost:6060/debug/pprof/profile?seconds=60'
8.2 升级后的验证顺序
# 1. 默认(Green Tea 开启)跑一遍
GODEBUG=gctrace=1 ./your-service-126 2>&1 | tee gt-gctrace.log
# 2. 关掉 Green Tea 跑一遍做对照(注意必须重新 build)
GOEXPERIMENT=nogreenteagc go build -o your-service-126-nogt .
GODEBUG=gctrace=1 ./your-service-126-nogt 2>&1 | tee nogt-gctrace.log
# 3. 提取 GC CPU 百分比对比
grep -oP '@[\d.]+s \K\d+(?=%)' gt-gctrace.log | tail -50 | awk '{s+=$1} END {print "GreenTea avg:", s/NR"%"}'
grep -oP '@[\d.]+s \K\d+(?=%)' nogt-gctrace.log | tail -50 | awk '{s+=$1} END {print "Old GC avg:", s/NR"%"}'
8.3 判断标准
| 现象 | 判断 | 行动 |
|---|---|---|
| GC CPU 降 10~40%,QPS 涨 | Green Tea 生效 | 收工 |
| GC CPU 降,QPS 没变 | 瓶颈转移了 | 重新 profile 找新瓶颈 |
| GC CPU 降,但 GC 次数变多 | 浮动垃圾减少导致堆变小 | 调高 GOGC 或用 GOMEMLIMIT |
| GC CPU 基本没变 | 堆拓扑不适合(低扇出/频繁重排) | 考虑第六节的代码改造 |
| GC CPU 上升 | 罕见,可能命中已知退化场景 | 去 golang/go 提 issue,附上堆结构描述 |
8.4 回退
GOEXPERIMENT=nogreenteagc go build .
但要注意:这个开关计划在 Go 1.27 移除。所以如果你真的因为性能退化不得不关掉它,别当成长期方案——去提 issue,Go 团队明确说了「If you disable the new garbage collector for any reason related to its performance or behavior, please file an issue」。他们需要反面案例来继续优化。
九、我的一些判断
写到这里,说几个源码和文档之外的想法。
第一,这是 Go 运行时「硬件感知」转向的开始。
过去 Go 的 runtime 设计哲学是「抽象掉硬件」——GOMAXPROCS 抽象掉核数,GC 抽象掉内存管理,调度器抽象掉线程。Green Tea 是第一个明确针对 cache 层级和 NUMA 拓扑做优化的核心组件,而且明说了「在新 CPU 上有额外 10% 收益」。配合 simd/archsimd 的引入,Go 正在承认:在 2026 年,完全无视硬件已经拿不到性能了。
第二,「不能凭空创造局部性」这句话,是给应用开发者的一封信。
Green Tea 把球踢回给了应用层。runtime 已经做到「你有多少局部性,我就利用多少」,剩下的就看你的数据结构怎么设计了。我预感未来两年会看到更多 Go 库开始做「GC 友好」的重构——arena 化分配、索引代替指针、SoA 布局。这些在 C++/Rust 社区是常识,在 Go 社区一直不太被重视,因为「GC 会帮你搞定」。现在 GC 说:我帮你搞定一半,另一半你自己来。
第三,goroutine 泄漏 profile 被低估了。
Green Tea 是给 GC-heavy 服务省 CPU 的,受众有限。但 goroutine 泄漏是每个 Go 项目都会遇到的问题,而且极难排查——通常表现为内存缓慢增长,pprof goroutine 一看几万个 goroutine 卡在同一行,然后你得手工推理是哪条路径漏的。现在 runtime 直接告诉你「这些是永远不会醒的」,排查成本从小时级降到分钟级。1.27 默认开启后,这会成为 Go 工程实践的标配。
第四,FIFO 击败 LIFO 这个细节值得单独玩味。
它提醒我们:任何「最佳实践」都绑定在特定的目标函数上。LIFO 在传统 GC 里更优,是因为目标是遍历局部性;Green Tea 的目标变成了批量积累密度,LIFO 立刻变成最差选择。做性能优化的时候,先确认你在优化什么,再去查「最佳实践」,顺序反了就会抄错答案。
十、七条可以直接用的经验
升级 Go 1.26 前后各测一次,用
GOEXPERIMENT=nogreenteagc做对照组。 记住这是编译期变量,必须重新 build,不是运行时 env。盯
gctrace里的 GC CPU 百分比和 background 标记 CPU 时间,这两个是 Green Tea 影响最直接的指标。生产环境用runtime/metrics的/cpu/classes/gc/mark/dedicated:cpu-seconds,别用会 STW 的ReadMemStats。GC CPU 降了但 QPS 没涨很正常,说明瓶颈转移了。重新 profile,别怪 GC。如果发现 GC 次数反而变多,是浮动垃圾减少导致的,调高 GOGC 或改用
GOMEMLIMIT + GOGC=off。高扇出、局部性好的堆(B-tree、R-tree、
[]Struct)受益最大;低扇出 + 频繁重排的二叉树基本没收益甚至微退化。 先判断自己属于哪一类,再决定要不要投入优化。想吃满红利,应用层要主动创造局部性:值切片代替指针切片、arena 批量分配、核心数据结构用索引代替指针(把对象变成 noscan 是核弹级优化)、控制结构体在 512 字节以内。
机器选型可以把 CPU 代次算进去:Ice Lake / Zen 4 及以上有额外约 10% 的 GC 收益,因为 Green Tea 会用向量指令扫描小对象。老机器拿不到这部分。
立刻在 CI 里挂上 goroutine 泄漏检测(
GOEXPERIMENT=goroutineleakprofile),这是 1.26 里 ROI 最高的东西。1.27 会默认开启,早用早受益,而且它「不使用时零开销」。
尾声
Go 的 GC 从 1.5 引入并发标记以来,主线剧情一直是「降低 STW 时间」——从几百毫秒降到亚毫秒,这个仗打赢了。Green Tea 打的是另一场仗:降低 GC 的 CPU 总成本。
这场仗的对手不是算法复杂度,是物理定律——光速有限,DRAM 追不上 CPU 的时钟,核越多带宽越紧张。你没法通过更聪明的算法绕过内存墙,只能通过更好的访问模式去适应它。
Green Tea 的做法说到底就一句话:别再随机跳内存了,攒一批一起扫。简单得像句废话,但要在一个已经跑了十年、被亿万行代码依赖的运行时里实现它,需要重写标记循环、发明双位图协议、重做工作窃取队列、手工对齐到 cache line、为每个 size class 生成 SIMD 内核——两千多行代码,一年多的迭代,Google 内部先跑一整个版本周期收集反馈。
这就是基础设施工程的样子:想法可以简单,落地必须偏执。
下次你的 Go 服务 GC 占用降了那么十几个百分点,可以想想背后是这么一套东西在跑。喝口茶🍵。