编程 Go 1.26 Green Tea GC 深度拆解:从 runtime 源码看「延迟扫描 + span 批处理」如何砍掉 40% GC 开销

2026-07-31 04:18:59 +0800 CST views 7

一、开场:一个被忽略了十年的性能黑洞

如果你在 2015 年之后写过 Go,大概率听过这句话:「Go 的 GC 已经很好了,STW 只有几百微秒。」

这句话没错,但它掩盖了一个事实:STW 时间短 ≠ GC 开销小

Go 从 1.5 开始把 GC 改成并发三色标记 + 混合写屏障之后,"停顿时间"这个指标基本被打穿了——亚毫秒级,绝大多数在线服务都能接受。于是社区的注意力就转移了,大家开始讨论 GOGC 怎么调、GOMEMLIMIT 怎么设、对象池要不要上。

但你有没有算过另一笔账:GC 到底吃掉了你多少 CPU?

在我经手的几个中等规模 Go 服务里,GODEBUG=gctrace=1 打出来的 GC CPU 占比长期在 8%~15% 之间徘徊。极端情况——一个做 JSON 解析和协议转换的网关服务,堆上全是几十字节到几百字节的小对象——GC 能吃掉 25% 的 CPU。这不是停顿问题,这是吞吐问题。你花钱买的机器,四分之一在给垃圾回收器打工。

更扎心的是,这部分开销你几乎调不动。GOGC 调大能减少 GC 频率,但代价是内存翻倍;对象池能减少分配,但侵入性极强而且容易踩 sync.Pool 的坑;换 arena 分配器?那玩意儿的提案至今还躺在 issue 里。

Go 1.26 在 2026 年 2 月发布,距离 Go 1.25 正好六个月。这个版本干了一件事:把 Green Tea GC 从实验特性变成默认。官方给的数字是——重度使用 GC 的真实程序,GC 开销降低 10%~40%;如果你跑在较新的 amd64 平台上(Intel Ice Lake 或 AMD Zen 4 及以后),还能再多拿 约 10%,因为标记器开始用向量指令扫小对象了。

10%40% 是什么概念?如果你的服务 GC 占 15% CPU,砍掉 30% 就是省下 4.5 个百分点的整机 CPU。一个 100 台机器的集群,这是 45 台机器的钱,而你什么代码都不用改。

这篇文章我会做三件事:

  1. 讲清楚 Green Tea 到底改了什么——不是"局部性变好了"这种废话,而是从 runtime 源码层面看它的数据结构和算法;
  2. 给出可复现的验证方法——怎么在你自己的服务上量出这 30% 到底存不存在;
  3. 说清楚它的边界——哪些场景收益接近 0,哪些场景甚至可能变差。

顺带,Go 1.26 还悄悄塞了一个我个人认为比 Green Tea 更值钱的东西:goroutine 泄漏 profile。这个后面单独展开。


二、问题定义:为什么小对象标记这么慢

2.1 老 GC 的工作方式

先把老的标记算法(Go 1.5 ~ 1.25 的默认路径)过一遍,不然没法理解 Green Tea 改了什么。

传统的 Go 标记器基于 workbuf(工作缓冲区):

一个 workbuf = 一个装 512 个指针的数组

标记流程:
1. 从 workbuf 里 pop 一个指针 p        ← LIFO
2. 找到 p 属于哪个 span、哪个对象槽位
3. 读 span 的元数据(size class、指针位图)
4. 设置 mark bit
5. 按位图扫描 p 指向的对象,把里面的指针 push 回 workbuf
6. 回到 1

看起来很正常。问题出在第 2、3、4 步。

2.2 三次缓存未命中

假设你的堆上有 1000 万个 48 字节的小对象(典型的 struct、slice header、map bucket entry)。标记器从 workbuf 里拿到一个指针 p = 0x7f2a3c001830,接下来它要干这些事:

第一次访问:定位 span。 Go 的堆按 arena(64MB)组织,每个 arena 有 heapArena 元数据结构,里面存着 page 到 span 的映射。所以要先算 arena 索引、读 mheap_.arenas、再读 heapArena.spans[pageIdx]。这是一次指针追逐,大概率 cache miss

第二次访问:读 span 元数据。 拿到 *mspan 之后要读 elemsizespanclassgcmarkBits 指针。mspan 结构体本身不在对象附近,又一次 cache miss

第三次访问:写 mark bit。 gcmarkBits 是一个单独分配的位图,跟对象数据、跟 mspan 都不在一起。第三次 cache miss

第四次访问:读对象本身。 这次才是真正有用的工作——把 48 字节的对象读进来,按位图找里面的指针。

也就是说,为了扫描 48 字节的有效数据,你可能付出了 4 次 cache line 填充(256 字节的内存带宽)。有效带宽利用率不到 20%。

2.3 LIFO 让情况更糟

更要命的是 workbuf 用的是 LIFO(后进先出,本质是个栈)。这是为了模拟深度优先遍历,好处是工作集小、栈深度可控。

但 LIFO 意味着:你刚从对象 A 里挖出来的三个指针 B、C、D,会立刻被弹出来处理。而 B、C、D 在内存里的位置是完全随机的——它们可能分属三个不同的 span,三个不同的 arena,甚至三个不同的 NUMA 节点。

于是标记器的内存访问模式变成了纯随机跳跃。CPU 的硬件预取器(prefetcher)在这种模式下完全失效——它擅长的是顺序流和固定步长,不是指针追逐。

这就是 Green Tea 要解决的核心问题。用源码注释里的原话:

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.

翻译过来:先别急着扫,攒一攒;等同一个 span 上攒够了对象,一起扫。


三、Green Tea 的核心设计:三个关键决策

Green Tea 的实现主要在 src/runtime/mgcmark_greenteagc.go(构建标签 //go:build goexperiment.greenteagc)。我把它的设计拆成三个关键决策来讲。

3.1 决策一:双位图(marks + scans)

传统 GC 只有一套 mark bit。Green Tea 搞了两套

type spanInlineMarkBits struct {
	scans [63]uint8         // 已扫描位
	owned spanScanOwnership // 所有权状态
	marks [63]uint8         // 标记位
	class spanClass
}

两套位图的语义是分开的:

  • marks:这个对象被"发现"了(有指针指向它)。第一次看到指针时立刻置位。
  • scans:这个对象被"扫描"了(它内部的指针已经被遍历过)。

这个拆分是整个设计的地基。因为有了它,"标记"和"扫描"可以在时间上解耦

看到指针 p → 设置 p 的 mark bit → 把 p 所在的 span 塞进队列 → 立刻返回

(时间流逝,同一个 span 上的其他对象陆续被标记)

从队列取出 span →
    新增待扫描集合 = marks & ^scans      ← 差集
    scans |= marks                        ← 合并
    对差集里的每个对象,批量扫描

源码注释里描述得很精确:

Later, when we dequeue the span, we find both the union and intersection of the mark and scan bitsets. The union is then written back into the scan bits, while the intersection is used to decide which objects need scanning, such that the GC is still precise.

关键在最后半句:such that the GC is still precise(GC 依然是精确的)。这不是一个近似算法、不是一个概率性优化——它在语义上和原来完全等价,只是把扫描时机推迟了。

这一点非常重要。很多"性能优化"是拿正确性换速度(比如各种保守式 GC),Green Tea 不是。它换的是延迟,不是精度。

3.2 决策二:位图内联到 span 尾部

第二个关键决策:把这 128 字节的位图直接塞进 span 内存的末尾

func spanInlineMarkBitsFromBase(base uintptr) *spanInlineMarkBits {
	return (*spanInlineMarkBits)(unsafe.Pointer(
		base + gc.PageSize - unsafe.Sizeof(spanInlineMarkBits{}),
	))
}

注意这个 spanInlineMarkBits 的大小:63 + 1 + 63 + 1 = 128 字节,正好是两个 cache line,而且因为放在 page 末尾,天然 128 字节对齐。这不是巧合,源码里明说了:

We know that imb is both aligned and a nice power-of-two size that works well for wider SIMD instructions. The compiler likely has no idea that imb is aligned to 128 bytes.

内联带来的收益是元数据访问的坍缩

操作老 GCGreen Tea
定位 span查 arena → 查 spans 数组alignDown(p, PageSize) 一次算术
读 size classmspan.spanclass读 span 尾部的 imb.class
置 mark bitmspan.gcmarkBits 指针 → 写位图直接写 span 尾部的 imb.marks
cache line 数3~4 条,分散1~2 条,且跟对象数据在同一个 page

看这段源码,tryDeferToSpanScan 的实现:

func tryDeferToSpanScan(p uintptr, gcw *gcWork) bool {
	if useCheckmark {
		return false
	}

	// 快速判断:这个 page 用不用内联 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
	}

	// 从内联位图里存的 span class 反推对象索引
	base := alignDown(p, gc.PageSize)
	q := spanInlineMarkBitsFromBase(base)
	objIndex := uint16(
		(uint64(p-base) * uint64(gc.SizeClassToDivMagic[q.class.sizeclass()])) >> 32,
	)

	// 设置 mark bit
	idx, mask := objIndex/8, uint8(1)<<(objIndex%8)
	if atomic.Load8(&q.marks[idx])&mask != 0 {
		return true   // 已经标记过,直接走人
	}
	atomic.Or8(&q.marks[idx], mask)

	// noscan 对象走快速通道:标记完就结束,根本不用扫
	if q.class.noscan() {
		gcw.bytesMarked += uint64(gc.SizeClassToSize[q.class.sizeclass()])
		return true
	}

	// 把 span 塞进队列
	if q.tryAcquire() {
		if gcw.spanq.put(makeObjPtr(base, objIndex)) {
			// ...唤醒 worker 的逻辑
		}
	}
	return true
}

几个值得注意的细节:

细节一:SizeClassToDivMagic 魔数除法。 算对象索引本来需要一次除法((p - base) / elemsize),整数除法在现代 CPU 上要 2040 个周期。Green Tea 用魔数乘法 + 右移 32 位替代,压到 34 个周期。这是编译器常用的技巧,这里手工用在了 GC 热路径上。

细节二:noscan 快速通道。 如果对象不含指针([]bytestring 数据、纯数值 struct),标记完就直接返回,连队列都不进。这个优化的实际收益很大——很多服务堆上一半以上的字节是 noscan 的。

细节三:heapBitsInSpan(size) && size >= 16 的适用范围。

func gcUsesSpanInlineMarkBits(size uintptr) bool {
	return heapBitsInSpan(size) && size >= 16
}

Green Tea 只对小对象生效。太小(<16 字节)的不做,因为位图开销占比过高;太大的(超过 heapBitsInSpan 阈值,通常是 512 字节)也不做,因为大对象本身就有足够的扫描工作量来摊薄元数据访问成本。

这是理解 Green Tea 收益边界的第一把钥匙:你的堆上 16~512 字节的含指针小对象越多,收益越大。

3.3 决策三:FIFO 队列 + 三态所有权协议

第三个决策,也是最容易被忽略的:队列策略从 LIFO 换成了 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 更好?因为 Green Tea 的目标是攒批。LIFO 会让刚入队的 span 立刻被处理,那就攒不到东西了;FIFO 给了 span 一段"冷却期",在这段时间里,其他 worker 发现的、指向同一个 span 的指针,都会累加到同一份 marks 位图上。等这个 span 真的被取出来时,可能已经攒了几十个待扫描对象。

那么问题来了:一个 span 可能被多个 worker 同时发现,怎么保证它只入队一次?

答案是 spanScanOwnership 三态协议:

const (
	spanScanUnowned  spanScanOwnership = 0
	spanScanOneMark                    = 1 << iota  // 相对 scans 只多了一个 mark
	spanScanManyMark                                // 多了一个以上
)

func (imb *spanInlineMarkBits) tryAcquire() bool {
	switch imb.owned.load() {
	case spanScanUnowned:
		if imb.owned.or(spanScanOneMark) == spanScanUnowned {
			return true    // 我是第一个,我负责入队
		}
		fallthrough
	case spanScanOneMark:
		return imb.owned.or(spanScanManyMark) == spanScanUnowned
	}
	return false
}

这个协议同时干了两件事:

  1. 入队去重:只有把 ownedUnowned 翻上去的那个 worker 返回 true,负责入队。
  2. 记录"攒了多少"OneMark vs ManyMark 是给出队时用的提示。

出队时:

func (imb *spanInlineMarkBits) release() spanScanOwnership {
	return spanScanOwnership(atomic.Xchg8(
		(*uint8)(unsafe.Pointer(&imb.owned)), uint8(spanScanUnowned),
	))
}

如果 release() 返回 spanScanOneMark,说明从入队到现在只有一个 mark bit 被设置,那就不用做"合并 marks 到 scans + 求差集"这套相对昂贵的位图运算了,直接扫那一个对象就行。源码注释里叫 fast-track span scans that only apply to a single mark

这是典型的为常见情况优化:大部分 span 确实只攒到一个对象(尤其是标记后期),那就别为它付批处理的固定成本。

还有一个 or() 的实现细节值得一提:

func (o *spanScanOwnership) or(v spanScanOwnership) spanScanOwnership {
	// Or8 不返回结果,所以要用 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)
}

为了拿到原子 OR 的返回值,把地址向下对齐到 4 字节边界,用 Or32 操作整个 word,再把结果移位取出来。为什么不给 Or8 加个返回值?注释里解释了:an Or8 that returns a result is not a common instruction——在大多数平台上带返回值的 8 位原子 OR 不是原生指令,实现出来还是这套东西,而 Or8 在 runtime 里到处都在用,加返回值反而拖慢别的地方。

这种取舍在 runtime 代码里随处可见:不追求 API 优雅,只追求生成的机器码最优。


四、并行扩展:spanQueue 与工作窃取

Green Tea 官方宣传的第二个卖点是 CPU 可扩展性。这部分体现在队列实现上。

4.1 P-local 环形队列

type spanQueue struct {
	head, tail uint32
	ring       [256]objptr

	putsSinceDrain int

	chain struct {
		head *spanSPMC              // 生产者写入端(只有本 P 访问)
		tail atomic.UnsafePointer   // 消费者窃取端(其他 P 访问)
	}
}

每个 P 有一个本地的 256 槽环形缓冲区,无锁、非线程安全——因为只有拥有它的 P 会读写。

func (q *spanQueue) putFast(s objptr) (ok bool) {
	if q.tail-q.head == uint32(len(q.ring)) {
		return false
	}
	q.ring[q.tail%uint32(len(q.ring))] = s
	q.tail++
	return true
}

极简。没有原子操作,没有内存屏障,就是数组写 + 计数器自增。这是热路径上最快的形式。

4.2 溢出策略:既要局部性,又要并行度

但纯本地队列有个问题:如果一个 P 攒了一堆活,其他 P 闲着,整体就串行化了。所以需要定期把一部分工作"发布"出去。

const (
	spillPeriod = 64   // 每 64 次 put 检查一次是否要溢出
	spillMax    = 16   // 每次最多溢出 16 个 span
)

func (q *spanQueue) put(s objptr) bool {
	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)
				return true    // 提示调用方:可以起新 worker 了
			}
		}
		return false
	}

	// 队列满了,强制溢出一半
	q.drain(uint32(len(q.ring)) / 2)
	if !q.putFast(s) {
		throw("failed putFast after drain")
	}
	return true
}

注释里对 spillPeriod > spillMax 这个约束的解释很到位:

spillPeriod must be > spillMax, otherwise that sets the effective maximum size of our local span queue. ... When spillPeriod > spillMax, then we're always spilling spans at a slower rate than we're accumulating them.

如果每 16 次 put 就溢出 16 个,那本地队列的有效长度就永远是 16,环形缓冲区的 256 个槽位白开了。必须让溢出速率低于积累速率,本地队列才能真的攒住东西。

64 > 16 这个比例保证了:稳态下大约每 4 次 put 溢出 1 个 span,本地保留 3/4。既维持了局部性,又持续给其他 P 喂活。

再看 put 的返回值——whether the caller should spin up a new worker。溢出发生时返回 true,上层就知道"有可窃取的活了,值得多起一个 GC worker"。这是队列状态直接驱动并行度决策,而不是靠定时器或者启发式采样。

4.3 spanSPMC 链:借鉴 sync.Pool

跨 P 共享的部分用的是链式 SPMC(单生产者多消费者)环形缓冲:

This linked chain structure is based off the sync.Pool dequeue.

sync.Pool 内部的 poolDequeue 是一个经过多年打磨的无锁数据结构:生产者从头部 push/pop(无需 CAS),消费者从尾部 popTail(需要 CAS)。多个固定大小的环用链表串起来,容量不够就挂新环,避免了动态扩容时的复杂同步。

Green Tea 直接复用了这个设计。这是个很务实的选择——runtime 里已经验证过 10 年的并发结构,比重新发明一个可靠得多。

窃取逻辑:

func (w *gcWork) tryGetSpan() objptr {
	if s := w.spanq.tryGetFast(); s != 0 {
		return s
	}
	// "从自己这儿偷" —— 从本 P 的 spmc chain 里拿
	if s := w.spanq.steal(&w.spanq); s != 0 {
		return s
	}
	// 彻底没活了,清掉自己在全局位图里的标志
	if work.spanqMask.read(uint32(w.id)) {
		work.spanqMask.clear(w.id)
	}
	return 0
}

work.spanqMask 是一个全局位图,标记"哪些 P 身上还有可窃取的 span"。窃取者不用挨个遍历所有 P 的队列(那是 O(P) 次 cache miss),而是先读这个位图,直接定位到有活的 P。

有意思的是入队时对这个位图的处理:

if gcphase == _GCmark {
	// This is intentionally racy; the bit set here might get
	// stomped on by a stealing P.
	if !work.spanqMask.read(uint32(gcw.id)) {
		work.spanqMask.set(gcw.id)
	}
	gcw.mayNeedWorker = true
}

故意的数据竞争。设置位图的操作可能被并发的窃取者覆盖掉,导致"明明有活但位图说没有"。runtime 接受了这个不精确——因为这只是一个提示(hint),漏掉一次窃取机会的代价,远小于加锁保证精确的代价。正确性由其他机制(标记终止阶段的全局检查)兜底。

这是高性能并发代码的常见模式:把精确性要求从热路径挪到冷路径。

4.4 SIMD:最后 10%

Go 1.26 的 release notes 提到,在 Intel Ice Lake / AMD Zen 4 及更新的 amd64 平台上,还能额外拿到约 10% 的 GC 开销降低,因为标记器开始用向量指令扫描小对象。

这个能力的前提,正是前面讲的两个设计:

  1. 位图 128 字节对齐——可以直接用 AVX-512 的 512 位(64 字节)加载指令,两条指令读完整个 spanInlineMarkBits
  2. 同 span 批处理——一个 span 上的对象 size class 相同、指针位图模式相同,可以用同一套向量掩码并行处理多个对象。

如果还是老的"一次一个指针、随机跳"的模式,SIMD 根本没有用武之地——向量指令需要规整的、批量的、同构的数据,而 Green Tea 恰好把标记工作整理成了这个形状。

对应的包是 internal/runtime/gc/scan,源码里 import 了它但具体实现在另一个文件。顺带一提,Go 1.26 也开放了实验性的用户态 SIMD 包 simd/archsimdGOEXPERIMENT=simd),提供 128/256/512 位向量类型如 Int8x16Float64x8。这两件事显然是同一波基础设施建设的产物。


五、实战:怎么在你的服务上量出真实收益

讲了这么多原理,现在回到最实际的问题:你的服务能省多少?

不要信任何博客给的数字(包括这篇)。GC 收益跟堆的形状强相关,必须自己测。

5.1 构造一个能放大差异的基准

先写一个能凸显小对象标记开销的 benchmark:

package gcbench

import (
	"runtime"
	"testing"
)

// Node 是典型的"含指针小对象":40 字节左右,含 2 个指针
type Node struct {
	ID       int64
	Name     string   // 16 字节,含 1 个指针
	Next     *Node    // 8 字节指针
	Payload  []byte   // 24 字节,含 1 个指针
}

// buildGraph 构造一个存活的对象图,模拟长生命周期缓存
func buildGraph(n int) []*Node {
	nodes := make([]*Node, n)
	for i := range nodes {
		nodes[i] = &Node{
			ID:      int64(i),
			Name:    "node-payload-string",
			Payload: make([]byte, 32),
		}
	}
	// 建立随机引用,制造指针追逐
	for i := range nodes {
		nodes[i].Next = nodes[(i*2654435761)%uint64Len(n)]
	}
	return nodes
}

func uint64Len(n int) int { return n }

func BenchmarkMarkSmallObjects(b *testing.B) {
	// 常驻堆:500 万个小对象
	live := buildGraph(5_000_000)
	runtime.GC()

	b.ResetTimer()
	b.ReportAllocs()

	for i := 0; i < b.N; i++ {
		// 制造垃圾,触发 GC
		garbage := make([]*Node, 100_000)
		for j := range garbage {
			garbage[j] = &Node{
				ID:      int64(j),
				Name:    "tmp",
				Payload: make([]byte, 16),
			}
		}
		_ = garbage
	}

	b.StopTimer()
	runtime.KeepAlive(live)
}

关键设计点:

  • 大量存活的小对象(500 万 × ~80 字节 ≈ 400MB 活跃堆)。GC 的标记成本正比于存活对象数量,不是垃圾数量。
  • 随机指针引用。顺序引用会被预取器救回来,掩盖真实差异。
  • 持续产生垃圾触发 GC 循环。

5.2 对照测试

# 开启 Green Tea(Go 1.26 默认)
go test -run='^$' -bench=BenchmarkMarkSmallObjects -benchtime=30x -count=5 \
  > /tmp/greentea.txt

# 关闭 Green Tea
GOEXPERIMENT=nogreenteagc \
go test -run='^$' -bench=BenchmarkMarkSmallObjects -benchtime=30x -count=5 \
  > /tmp/classic.txt

# 用 benchstat 做统计显著性检验
go install golang.org/x/perf/cmd/benchstat@latest
benchstat /tmp/classic.txt /tmp/greentea.txt

-count=5 + benchstat 是必须的。单次跑出来的差异有一半是噪声,benchstat 会给出 p 值和置信区间,只有 p < 0.05 的差异才值得讨论。

5.3 直接看 GC 的 CPU 账单

Benchmark 的墙钟时间混杂了分配、业务逻辑、GC 三部分。要单看 GC,用 gctrace

GODEBUG=gctrace=1 ./your-service 2>&1 | tee gc.log

输出长这样:

gc 42 @31.234s 8%: 0.089+412+0.031 ms clock, 1.4+156/823/0+0.50 ms cpu, 
   1843->1851->912 MB, 1901 MB goal, 0 MB stacks, 0 MB globals, 16 P

要盯的是第三个字段那个百分比(这里是 8%)——从程序启动到现在,GC 累计消耗的 CPU 时间占总可用 CPU 时间的比例。这就是你的 GC 税率。

写个脚本对比两种模式下这个数字的收敛值:

#!/usr/bin/env bash
# gc-tax.sh —— 提取 gctrace 里的 GC CPU 占比
grep -oE '^gc [0-9]+ @[0-9.]+s [0-9]+%' "$1" \
  | awk '{gsub(/%/,"",$4); print $4}' \
  | tail -50 \
  | awk '{s+=$1; n++} END {printf "avg GC CPU tax (last 50 cycles): %.2f%%\n", s/n}'

用法:

GODEBUG=gctrace=1 ./svc 2>gc-new.log &   sleep 300; kill %1
GOEXPERIMENT=nogreenteagc go build -o svc-old . 
GODEBUG=gctrace=1 ./svc-old 2>gc-old.log & sleep 300; kill %1

./gc-tax.sh gc-old.log
./gc-tax.sh gc-new.log

注意:GOEXPERIMENT 是构建期变量,不是运行期变量。 必须在 go build 时设置,运行时设置无效。这是我见过最多人踩的坑。

5.4 用 runtime/metrics 做生产监控

线上服务不能一直开 gctrace(日志量太大)。用 runtime/metrics 采集结构化指标:

package gcmetrics

import (
	"runtime/metrics"
	"sync"
	"time"
)

var (
	descs = []string{
		"/gc/cycles/total:gc-cycles",
		"/gc/heap/live:bytes",
		"/gc/heap/objects:objects",
		"/gc/pauses:seconds",             // 直方图
		"/cpu/classes/gc/total:cpu-seconds",
		"/cpu/classes/total:cpu-seconds",
	}
	samples []metrics.Sample
	once    sync.Once
)

type Snapshot struct {
	Cycles      uint64
	LiveBytes   uint64
	LiveObjects uint64
	GCCPUShare  float64 // GC 占总 CPU 的比例
	P99PauseMs  float64
}

func Read() Snapshot {
	once.Do(func() {
		samples = make([]metrics.Sample, len(descs))
		for i, d := range descs {
			samples[i].Name = d
		}
	})
	metrics.Read(samples)

	var s Snapshot
	s.Cycles = samples[0].Value.Uint64()
	s.LiveBytes = samples[1].Value.Uint64()
	s.LiveObjects = samples[2].Value.Uint64()

	// GC CPU 占比 —— 这是最该上监控大盘的指标
	gcCPU := samples[4].Value.Float64()
	totalCPU := samples[5].Value.Float64()
	if totalCPU > 0 {
		s.GCCPUShare = gcCPU / totalCPU
	}

	// 从直方图算 p99 暂停
	h := samples[3].Value.Float64Histogram()
	s.P99PauseMs = histQuantile(h, 0.99) * 1000
	return s
}

func histQuantile(h *metrics.Float64Histogram, q float64) float64 {
	var total uint64
	for _, c := range h.Counts {
		total += c
	}
	if total == 0 {
		return 0
	}
	target := uint64(float64(total) * q)
	var cum uint64
	for i, c := range h.Counts {
		cum += c
		if cum >= target {
			// Buckets 比 Counts 多一个元素
			return h.Buckets[i+1]
		}
	}
	return h.Buckets[len(h.Buckets)-1]
}

// StartReporter 每 30 秒上报一次
func StartReporter(report func(Snapshot)) {
	go func() {
		t := time.NewTicker(30 * time.Second)
		defer t.Stop()
		for range t.C {
			report(Read())
		}
	}()
}

上线后重点观察 GCCPUShare。这个指标比 pause 时间有意义得多——pause 只有几百微秒,早就不是瓶颈了;CPU 占比才是真金白银。

5.5 定位"谁在制造小对象"

如果测出来 Green Tea 收益不大,很可能是你的堆形状不对(小对象不够多,或者根本不是 GC 瓶颈)。用 heap profile 看清楚:

# 抓 inuse_objects(存活对象数,不是字节数)
go tool pprof -inuse_objects http://localhost:6060/debug/pprof/heap

# 在 pprof 交互界面里
(pprof) top20
(pprof) traces

inuse_objects 而不是 inuse_space。GC 标记成本正比于对象个数,不是总字节数。一个 100MB 的 []byte 只算 1 个对象(而且是 noscan,几乎零成本);100 万个 100 字节的 struct 才是 GC 的噩梦。

还有个手工统计的小工具,直接读 span 分布:

//go:build debug

package main

import (
	"fmt"
	"runtime"
	"sort"
)

// DumpSizeClassDistribution 打印各 size class 的分配分布,
// 帮你判断堆上小对象的占比
func DumpSizeClassDistribution() {
	var ms runtime.MemStats
	runtime.ReadMemStats(&ms)

	type row struct {
		size       uint32
		mallocs    uint64
		frees      uint64
		live       uint64
	}
	var rows []row
	for _, b := range ms.BySize {
		if b.Mallocs == 0 {
			continue
		}
		rows = append(rows, row{b.Size, b.Mallocs, b.Frees, b.Mallocs - b.Frees})
	}
	sort.Slice(rows, func(i, j int) bool { return rows[i].live > rows[j].live })

	fmt.Printf("%-8s %-14s %-14s %-14s %s\n",
		"SIZE", "MALLOCS", "FREES", "LIVE", "GREENTEA?")
	var greenteaLive, totalLive uint64
	for _, r := range rows {
		eligible := r.size >= 16 && r.size <= 512
		mark := "-"
		if eligible {
			mark = "YES"
			greenteaLive += r.live
		}
		totalLive += r.live
		fmt.Printf("%-8d %-14d %-14d %-14d %s\n",
			r.size, r.mallocs, r.frees, r.live, mark)
	}
	fmt.Printf("\nGreen Tea 覆盖的存活对象占比: %.1f%% (%d / %d)\n",
		100*float64(greenteaLive)/float64(totalLive), greenteaLive, totalLive)
}

跑一下,如果最后那个覆盖率低于 30%,Green Tea 对你的收益就有限;高于 70%,那就值得期待。


六、调优:Green Tea 之后,GOGC 该怎么设

Green Tea 改变了 GC 的成本结构,之前的一些调优经验需要更新。

6.1 GOGC 的最优点会往下移

传统调优的常见做法是把 GOGC 调大(比如从 100 调到 300),用内存换 CPU——GC 跑得少了,CPU 就省了。

Green Tea 之后,单次 GC 的成本降低了,这意味着同样的 GOGC 值下,GC 的 CPU 税率下降;反过来说,你可以用更小的 GOGC 达到之前的税率

举个例子(数字是示意,实际要自己测):

GOGC老 GC 税率Green Tea 税率峰值内存
10014%9%2.0 GB
2009%6%3.0 GB
4006%4%5.0 GB

如果你之前为了把税率压到 9% 而设了 GOGC=200、吃了 3GB 内存,现在 GOGC=100 就能达到同样的 9%,内存直接砍到 2GB。

升级 Go 1.26 后,值得重新做一次 GOGC 扫描。 很多服务的 GOGC 是三年前拍脑袋定的,现在的最优点已经变了。

扫描脚本:

#!/usr/bin/env bash
# gogc-sweep.sh
set -euo pipefail

BIN=${1:?usage: gogc-sweep.sh <binary>}
DURATION=${2:-180}

echo "GOGC,gc_cpu_pct,peak_rss_mb"
for gogc in 50 75 100 150 200 300 400 600; do
    GOGC=$gogc GODEBUG=gctrace=1 "$BIN" 2>/tmp/gc-$gogc.log &
    pid=$!
    sleep "$DURATION"

    peak=$(ps -o rss= -p "$pid" | awk '{print int($1/1024)}')
    kill "$pid" 2>/dev/null || true
    wait "$pid" 2>/dev/null || true

    tax=$(grep -oE '^gc [0-9]+ @[0-9.]+s [0-9]+%' /tmp/gc-$gogc.log \
          | awk '{gsub(/%/,"",$4); print $4}' | tail -20 \
          | awk '{s+=$1;n++} END{if(n)printf "%.2f", s/n; else print "NA"}')

    echo "$gogc,$tax,$peak"
done

6.2 GOMEMLIMIT 依然是安全网

Green Tea 不改变 GOMEMLIMIT 的语义。生产环境的推荐配置还是那套:

package main

import (
	"os"
	"runtime/debug"
	"strconv"
)

func init() {
	// 容器场景:读 cgroup 限额,留 15% 余量给非堆内存
	// (goroutine 栈、mmap、CGO、runtime 自身开销)
	if limit := detectCgroupMemLimit(); limit > 0 {
		debug.SetMemoryLimit(int64(float64(limit) * 0.85))
	}

	// GOGC 交给环境变量,方便运维调整
	if v := os.Getenv("GOGC"); v == "" {
		debug.SetGCPercent(100)
	} else if n, err := strconv.Atoi(v); err == nil {
		debug.SetGCPercent(n)
	}
}

func detectCgroupMemLimit() uint64 {
	// cgroup v2
	for _, p := range []string{
		"/sys/fs/cgroup/memory.max",
		"/sys/fs/cgroup/memory/memory.limit_in_bytes", // v1
	} {
		b, err := os.ReadFile(p)
		if err != nil {
			continue
		}
		s := string(b)
		// 去掉换行
		for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == ' ') {
			s = s[:len(s)-1]
		}
		if s == "max" {
			continue
		}
		if n, err := strconv.ParseUint(s, 10, 64); err == nil && n < (1<<62) {
			return n
		}
	}
	return 0
}

注意 0.85 这个系数。Go 堆之外还有 goroutine 栈、runtime 元数据、CGO 分配、mmap 的文件映射。我见过太多人把 GOMEMLIMIT 设成等于容器 limit,然后被 OOMKilled——GOMEMLIMIT 只管 Go 堆,管不了别的。

6.3 对象池:还有必要吗?

这是个好问题。sync.Pool 的价值来自两方面:减少分配次数(省 malloc 开销)、减少 GC 压力(省标记开销)。Green Tea 削弱了第二部分。

我的判断:

  • 大对象池(buffer、[]byte):继续用。这些本来就是 noscan 的,Green Tea 对它们没影响,池化省的是分配和内存峰值。
  • 小对象池(struct):可以重新评估。如果你为了减少 GC 压力而池化了一堆小 struct,代码复杂度高、还容易漏 Reset 导致数据串台,现在收益变小了,可能值得删掉换回简单直接的分配

具体做法:把池化路径和直接分配路径都实现出来,用 build tag 切换,跑对比 benchmark。别凭感觉。

//go:build !nopool

package worker

import "sync"

var reqPool = sync.Pool{New: func() any { return new(Request) }}

func getRequest() *Request  { return reqPool.Get().(*Request) }
func putRequest(r *Request) { r.Reset(); reqPool.Put(r) }
//go:build nopool

package worker

func getRequest() *Request  { return new(Request) }
func putRequest(r *Request) {}
go test -bench=. -count=5 > with-pool.txt
go test -tags=nopool -bench=. -count=5 > no-pool.txt
benchstat with-pool.txt no-pool.txt

七、Go 1.26 的另一个宝藏:goroutine 泄漏 profile

聊完 Green Tea,必须说说这个。我认为对日常排障来说,它的价值比 Green Tea 更大。

7.1 goroutine 泄漏为什么难查

Go 程序最常见的资源泄漏就是 goroutine 泄漏。传统的排查方式是抓 /debug/pprof/goroutine,看哪个栈的 goroutine 数量异常多。

问题是:这个 profile 会把所有 goroutine 都列出来,包括正常阻塞等待的。一个有 5000 个连接的服务,天然就有 5000 个 goroutine 阻塞在 net.Read 上。你要在这堆噪声里找出真正泄漏的那 200 个,全靠经验和肉眼。

7.2 新 profile 怎么工作

Go 1.26 引入了实验性的 goroutineleak profile:

# 构建时开启
GOEXPERIMENT=goroutineleakprofile go build -o svc .

# 运行后访问
curl http://localhost:6060/debug/pprof/goroutineleak > leak.pb.gz
go tool pprof -http=:8080 leak.pb.gz

它的判定逻辑非常巧妙——复用 GC 的可达性分析

如果 goroutine G 阻塞在并发原语 P 上,而 P 从任何可运行的 goroutine、以及那些 goroutine 能唤醒的 goroutine 出发都不可达,那么 P 永远不可能被唤醒,所以 G 永远醒不过来。

换句话说:没人能碰到那个 channel 了,等在上面的 goroutine 就是死的。 这是一个可以由 GC 顺手算出来的结论,因为 GC 本来就在做可达性遍历。

Release notes 里给的例子非常典型:

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
}

这段代码我敢说 90% 的 Go 项目里都能找到同构的版本。ch 无缓冲,一旦某个 item 出错提前 return,剩下还没发送完的 goroutine 就永久阻塞在 ch <- result{...} 上。而 chprocessWorkItems 返回后就不可达了,于是 runtime 能检测到。

7.3 修法

三种正确写法,按推荐度排序:

方案一:用带足够缓冲的 channel。

func processWorkItems(ws []workItem) ([]workResult, error) {
	ch := make(chan result, len(ws))   // 缓冲 = 任务数,发送永不阻塞
	for _, w := range ws {
		go func() {
			res, err := processWorkItem(w)
			ch <- result{res, err}
		}()
	}

	var results []workResult
	var firstErr error
	for range len(ws) {
		r := <-ch
		if r.err != nil && firstErr == nil {
			firstErr = r.err
			continue        // 不提前返回,把所有结果收完
		}
		if r.err == nil {
			results = append(results, r.res)
		}
	}
	if firstErr != nil {
		return nil, firstErr
	}
	return results, nil
}

方案二:用 errgroup + context,让 worker 能感知取消。

import "golang.org/x/sync/errgroup"

func processWorkItems(ctx context.Context, ws []workItem) ([]workResult, error) {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(runtime.GOMAXPROCS(0) * 2)   // 别无限起 goroutine

	results := make([]workResult, len(ws))
	for i, w := range ws {
		g.Go(func() error {
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
			}
			res, err := processWorkItem(w)
			if err != nil {
				return err
			}
			results[i] = res    // 每个 goroutine 写自己的槽位,无需 channel
			return nil
		})
	}
	if err := g.Wait(); err != nil {
		return nil, err
	}
	return results, nil
}

这个写法最干净:每个 goroutine 写独立的数组槽位,完全不需要 channel,也就没有 channel 阻塞泄漏的可能。errgroupctx 会在第一个错误发生时取消,其他 worker 能主动退出。

方案三:defer 排空。 最丑但有时不得不用(比如 channel 是外部传进来的):

defer func() {
	go func() {
		for range remaining {
			<-ch     // 把剩下的都收掉,让发送方能返回
		}
	}()
}()

7.4 把它接进 CI

这个 profile 最好的用法不是线上排障,而是放进 CI 当门禁。因为它「不使用时零开销」(release notes 明确说了 designed to not incur any additional run-time overhead unless it is actively in-use)。

//go:build goexperiment.goroutineleakprofile

package leaktest

import (
	"runtime"
	"runtime/pprof"
	"testing"
	"time"
)

// AssertNoLeaks 在测试结束时断言没有泄漏的 goroutine。
// 用法:defer leaktest.AssertNoLeaks(t)()
func AssertNoLeaks(t *testing.T) func() {
	return func() {
		t.Helper()

		// 给正在退出的 goroutine 一点时间
		time.Sleep(50 * time.Millisecond)
		// 触发两轮 GC,确保可达性分析跑完整
		runtime.GC()
		runtime.GC()

		p := pprof.Lookup("goroutineleak")
		if p == nil {
			t.Skip("goroutineleak profile 未启用," +
				"请用 GOEXPERIMENT=goroutineleakprofile 构建")
			return
		}
		if n := p.Count(); n > 0 {
			var buf strings.Builder
			_ = p.WriteTo(&buf, 1)   // debug=1,人类可读格式
			t.Errorf("检测到 %d 个泄漏的 goroutine:\n%s", n, buf.String())
		}
	}
}

CI 配置:

# .github/workflows/leak.yml
name: goroutine-leak-check
on: [push, pull_request]
jobs:
  leaktest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.26' }
      - name: Run tests with leak detection
        env:
          GOEXPERIMENT: goroutineleakprofile
        run: go test -race -count=1 ./...

这个 profile 由 Uber 的工程师贡献,背后有正经的学术论文支撑(Saioc et al.,发表在 ACM)。官方说实现已经是 production-ready,标"实验"只是为了收集 API 层面的反馈,目标是 Go 1.27 默认开启

7.5 它的盲区

必须说清楚局限:

the runtime may fail to identify leaks caused by blocking on concurrency primitives reachable through global variables or the local variables of runnable goroutines.

通过全局变量可达的 channel,检测不出来。 因为全局变量是 GC root,永远可达,所以"没人能碰到它"这个前提不成立。

var globalCh = make(chan int)   // ← 全局,永远可达

func leak() {
	go func() { <-globalCh }()   // 泄漏了,但检测不到
}

同理,被某个还在运行的 goroutine 的局部变量持有的 channel,也检测不出来。

所以它是个高精确率、中召回率的工具:报出来的基本都是真泄漏(几乎无误报),但漏报是存在的。当门禁用没问题,别指望它抓全。


八、Go 1.26 其他值得关注的变更

顺手梳理一下这个版本其他跟工程实践相关的改动。

8.1 new 支持表达式

type Person struct {
	Name string `json:"name"`
	Age  *int   `json:"age"`   // 可选字段用指针表示
}

// Go 1.26 之前:
age := yearsSince(born)
p := Person{Name: name, Age: &age}

// Go 1.26:
p := Person{Name: name, Age: new(yearsSince(born))}

这个特性专门为序列化场景的可选字段设计。以前处理 protobuf / JSON 的 optional 字段,要么写一堆 func ptr[T any](v T) *T { return &v } 辅助函数,要么定义临时变量。现在内置了。

小改动,但会明显减少 API 层代码的噪音。

8.2 泛型自引用约束

type Adder[A Adder[A]] interface {
	Add(A) A
}

func algo[A Adder[A]](x, y A) A {
	return x.Add(y)
}

以前第一行的自引用是编译错误。这个模式(相当于 Java 的 <T extends Comparable<T>>、Rust 的 Self: Trait<Self>)在实现 Monoid、Numeric、Builder 之类的抽象时是刚需。Go 泛型自 1.18 落地以来,这是表达能力上比较实质的一次补强。

实际用途,比如一个通用的求和:

type Summable[T Summable[T]] interface {
	Add(T) T
	Zero() T
}

func Sum[T Summable[T]](xs []T) T {
	var z T
	if len(xs) == 0 {
		return z
	}
	acc := xs[0].Zero()
	for _, x := range xs {
		acc = acc.Add(x)
	}
	return acc
}

type Money struct{ Cents int64 }

func (m Money) Add(o Money) Money { return Money{m.Cents + o.Cents} }
func (m Money) Zero() Money       { return Money{} }

// Sum([]Money{{100}, {250}, {75}}) → Money{425}

8.3 go fix 重生为 modernizer

这个改动被严重低估了。老的 go fix 是给 Go 1 之前的代码做迁移的,早就没人用了。Go 1.26 把它整个重写,基于 go vet 同一套 analysis 框架,变成了代码现代化工具

# 把整个代码库更新到最新惯用法
go fix ./...

初始版本自带几十个 fixer,比如:

  • for i := 0; i < n; i++for i := range n(Go 1.22 range over int)
  • interface{}any
  • sort.Sliceslices.SortFunc
  • 各种老 API 到 slices/maps 包的迁移

更有意思的是它支持 source-level inliner + //go:fix inline 指令,你可以给自己的库定义迁移规则:

// Deprecated: 请使用 NewClientWithOptions。
//
//go:fix inline
func NewClient(addr string) *Client {
	return NewClientWithOptions(addr, DefaultOptions())
}

打上这个标记后,库的使用者跑 go fix ./...,所有 NewClient(x) 调用点会自动被重写成 NewClientWithOptions(x, DefaultOptions())

这对库作者是个大杀器——废弃 API 不用再靠文档求人迁移了,给个 go fix 命令就完事。官方明确说这些 fixer 不改变程序行为,可以放心批量跑。

8.4 cgo 调用快 30%

The baseline runtime overhead of cgo calls has been reduced by ~30%.

如果你的项目用 SQLite(mattn/go-sqlite3)、图像处理(libvips)、加密硬件、或者任何 CGO 绑定,这是白捡的性能。cgo 调用的固定开销从原来的 ~50ns 量级降到 ~35ns 左右,高频小调用场景收益明显。

8.5 堆基址随机化

On 64-bit platforms, the runtime now randomizes the heap base address at startup.

安全加固。让攻击者更难预测内存地址,主要针对通过 cgo 引入的内存安全漏洞的利用。可以用 GOEXPERIMENT=norandomizedheapbase64 关掉。

注意副作用:如果你有依赖堆地址稳定性的测试(比如把指针值打印出来做快照对比,或者用地址做 map key 后期望跨进程一致),会挂。这类测试本来就是错的,正好借机会修掉。

8.6 编译器:更多切片分配到栈上

The compiler can now allocate the backing store for slices on the stack in more situations.

make([]T, n) 在 n 是变量但编译器能证明有上界时,现在也能栈分配了(以前只有常量 n 才行)。

如果这个优化引发了问题(罕见,但可能暴露原有的 unsafe 用法错误),排查方法:

# 用 bisect 二分定位是哪个分配点出的问题
go install golang.org/x/tools/cmd/bisect@latest
bisect -compile=variablemake go test ./...

# 全局关闭这个优化
go build -gcflags=all=-d=variablemakehash=n ./...

8.7 其他零碎

  • cmd/docgo tool doc 被删除,用 go doc 替代(参数和行为完全一致)。CI 脚本里有 go tool doc 的记得改。
  • pprof web UI 默认视图改成火焰图。以前默认是调用图,现在要看图得走 "View → Graph" 或 /ui/graph
  • go mod init 默认降低 go 版本:用 1.N.X 工具链跑 go mod init,生成的 go.mod 写的是 go 1.(N-1).0。目的是鼓励新模块兼容更多在维护的 Go 版本。
  • crypto/hpke 新包:实现 RFC 9180 混合公钥加密,支持后量子混合 KEM。
  • crypto/mlkem 快 18%:ML-KEM(Kyber)的封装/解封装性能提升。
  • bytes.Buffer.Peek:读取接下来 n 字节但不推进读指针。写协议解析器时很有用。
  • runtime/secret 实验包GOEXPERIMENT=runtimesecret):安全擦除处理密钥的临时数据(寄存器、栈、新堆分配),用于实现前向保密。目前支持 Linux 上的 amd64 和 arm64。
  • 各种 crypto 包忽略 random 参数ecdsa.SignASN1ecdh.GenerateKeydsa.GenerateKey 等现在一律使用安全随机源,忽略传入的 io.Reader。测试需要确定性的话用 testing/cryptotest.SetGlobalRandom,或者临时设 GODEBUG=cryptocustomrand=1如果你的代码传了自定义 random 做测试,升级后会静默改变行为,务必检查。
  • Bootstrap 要求 Go 1.24.6+。自己编译 Go 工具链的注意。

九、冷思考:Green Tea 的能力边界

吹了这么多,说点实在的。以下场景 Green Tea 收益接近于零

9.1 堆上主要是大对象

前面讲过,gcUsesSpanInlineMarkBits 要求 size >= 16 && heapBitsInSpan(size)。大对象(>512 字节,尤其是超过 32KB 走 large object 路径的)走的还是老逻辑。

典型场景:数据分析服务,堆上全是几 MB 的 []float64;缓存服务,value 是大 blob。这类服务本来 GC 压力就不大(对象个数少),Green Tea 也帮不上忙。

9.2 堆上主要是 noscan 对象

[]bytestring 的底层数组、纯数值 struct 都是 noscan——GC 只需要设个 mark bit,根本不用扫描内容。这类对象在老 GC 下成本就很低,Green Tea 的 batch 优化对它们没意义(源码里直接走 fast-track 返回)。

典型场景:网络代理、CDN 边缘节点,堆上 90% 是 buffer。

9.3 GC 根本不是瓶颈

如果你的服务 CPU 主要花在业务计算、加解密、序列化上,GC 只占 3%,那 Green Tea 省下的 1 个百分点,你在监控上都看不出来。

先量再动。 用前面给的 runtime/metrics 代码看 /cpu/classes/gc/total 占比,低于 5% 的话,别在这上面花时间了,去优化别的。

9.4 单核 / 低并发场景

Green Tea 的 CPU 可扩展性收益来自多 P 并行标记和工作窃取。GOMAXPROCS=1 或者容器 CPU limit 只有 0.5 核的场景,这部分收益归零,只剩下局部性收益。

而且在低核心数下,那套 spanSPMC 链和窃取逻辑还有轻微的固定开销。理论上可能出现微小的负收益——虽然我没实测到过,但如果你的场景极端(比如大量 GOMAXPROCS=1 的小容器),值得跑对照测一下。

9.5 迁移风险与回滚

GOEXPERIMENT=nogreenteagc 目前还能用,但官方说 Go 1.27 就会移除这个开关。也就是说:

  • Go 1.26 是你唯一的"可回滚窗口期"。
  • 如果在这个版本发现 Green Tea 有问题,一定要去提 issue(release notes 里明确请求了:If you disable the new garbage collector for any reason related to its performance or behavior, please file an issue),否则 1.27 就没退路了。

灰度建议:

第 1 周:非核心服务,10% 实例升级到 1.26,观测 GCCPUShare、p99 延迟、RSS
第 2 周:非核心服务全量,同时在核心服务开 10%
第 3 周:核心服务 50%
第 4 周:全量,同时用 gogc-sweep.sh 重新校准 GOGC

重点观测三个指标:

  1. /cpu/classes/gc/total 占比——应该下降,这是主要收益。
  2. RSS 峰值——理论上不变,如果显著上升说明有别的问题。
  3. 业务 p99 延迟——理论上小幅下降(GC 抢占的 CPU 少了),如果上升要立刻排查。

十、总结:一次教科书级的"不改语义只改布局"优化

回头看 Green Tea 这个设计,我觉得最值得学的不是具体技术,而是它的优化思路

它没有改变任何语义:

  • 还是三色标记
  • 还是并发 + 混合写屏障
  • 还是精确 GC(such that the GC is still precise
  • 用户代码零改动,API 零变化

它只改了三件事:

  1. 数据放在哪——把 mark bits 从独立位图挪到 span 内部末尾,让元数据跟数据同 page;
  2. 什么时候做——把"发现"和"扫描"解耦,用双位图换取批处理机会;
  3. 按什么顺序做——LIFO 换 FIFO,牺牲工作集大小换取批次饱和度。

全是布局和调度层面的改动,换来 10%~40% 的开销降低。

这个思路在应用层同样成立。如果你在优化一个热点循环,与其纠结算法复杂度(往往已经是最优了),不如问自己三个问题:

  • 我的数据在内存里是连续的吗?(array of structs vs struct of arrays)
  • 我能不能攒一批再处理,而不是来一个处理一个?(batching)
  • 我的处理顺序,跟数据的物理布局对齐吗?(access pattern)

现代 CPU 的算力早就过剩了,瓶颈在内存墙。一次 L3 miss 是 ~40ns,够执行 100 多条指令。Green Tea 的全部智慧,就是把随机的、分散的、一次一个的内存访问,重整成顺序的、聚集的、批量的——然后 SIMD 才有机会上场。

行动清单

如果你现在就想动手:

今天可以做的:

  1. 用前面的 DumpSizeClassDistribution 看你的堆形状,估算 Green Tea 覆盖率
  2. runtime/metrics 采集 /cpu/classes/gc/total,把 GC 税率上监控大盘

升级 1.26 时做的:
3. 跑 gogc-sweep.sh 重新校准 GOGC,很可能能降内存
4. 用 GOEXPERIMENT=nogreenteagc 做一次对照,量出真实收益(顺便验证没有回归)
5. 检查 CI 里有没有 go tool doc,改成 go doc
6. 检查有没有给 crypto 函数传自定义 random 的测试代码

长期收益最大的:
7. GOEXPERIMENT=goroutineleakprofile 加进 CI。这个东西零成本、零误报,能在合并前拦住 goroutine 泄漏。等 1.27 默认开启就更方便了。
8. 跑一遍 go fix ./...,把代码库现代化。如果你维护公共库,给废弃 API 打上 //go:fix inline

往前看

Go 1.27 大概率会有这些动作:

  • 移除 nogreenteagc 开关(GC 只剩一条路径,代码能简化不少)
  • goroutine 泄漏 profile 默认开启
  • Green Tea 按 size class 特化扫描循环(源码注释里写了 not yet completed),SIMD 覆盖面继续扩大
  • simd/archsimd 从实验转正,以及那个"高层可移植 SIMD 包"

Go 团队这两年的技术路线越来越清晰:不加语法糖,只在 runtime 和工具链上死磕。泛型落地之后语言层面几乎没大动作,但 PGO、Green Tea、SIMD、goroutine 泄漏检测这些东西一个接一个落地。

对写业务代码的人来说,这是最好的状态——升级一下版本,性能白拿


本文技术细节主要来自 Go 1.26 官方 Release Notes 与 src/runtime/mgcmark_greenteagc.go 源码。文中性能数字除官方引用外均为示意,实际收益请以你自己环境的实测为准。

推荐文章

Grid布局的简洁性和高效性
2024-11-18 03:48:02 +0800 CST
Web浏览器的定时器问题思考
2024-11-18 22:19:55 +0800 CST
Redis函数在PHP中的使用方法
2024-11-19 04:42:21 +0800 CST
Python实现Zip文件的暴力破解
2024-11-19 03:48:35 +0800 CST
Shell 里给变量赋值为多行文本
2024-11-18 20:25:45 +0800 CST
程序员茄子在线接单