【原理解析】从 0 到 1 吃透 Golang GC:三色标记、混合屏障与 1.23 版本的亚毫秒 STW 实战
文章目录
每日一句正能量
在当今的年代,做一个人极其不易,做一个有血有肉的人,而不是东拼西凑地糅合起一些人格特质,仿佛从没完没了的自动售货机里挑选出种种个性。
一、为什么又写 GC?
“Go 不是号称 10ms 以内 GC 吗?为什么我线上 API 还是偶尔蹦出 200ms 的尖刺?”——两周前,我在组内 On-Call 群里看到这条吐槽。
结果定位下来,不是 GC 本身慢,而是我们根本没理解它。
于是我把源码、trace、perf 全撸了一遍,写下这份“GC 拆解笔记”,希望能帮你:
- 彻底看懂三色标记的流程图
- 亲手复现一次“看似玄学”的 GC 尖刺
- 拿到 1.23 新增的
GOEXPERIMENT=softmax调优套路
二、先扔一张全景图
图 1:Go GC 全景链路(PNG 动图,4.2 MB,点击查看)
从栈扫描 → 三色标记 → 屏障 → 清理 → 内存归还,全部可视化。下文所有代码片段都能在 demo-gc-trace 找到。
三、三色标记:教科书 200 字 vs 源码 2000 行
3.1 三色到底是什么?
| 颜色 | 抽象语义 | runtime 里的 bit 位 |
|---|---|---|
| 黑 | 已扫描且引用全处理 | gcmarkBits=1, scanBits=1 |
| 灰 | 已扫描但引用未完 | gcmarkBits=1, scanBits=0 |
| 白 | 未扫描或垃圾 | gcmarkBits=0 |
3.2 一次标记的微观世界
下面这段循环是 GC 60% 时间的归宿(runtime/mgcmark.go:728):
// 简化版:灰色队列消费
for work.empty() == 0 {
obj := work.pop()
if obj.scan() { // 扫描内部指针
shadeChildren(obj) // 将孩子置灰
}
obj.setBlack()
}
work是一个 无锁环形 buffer,容量 2×P 数量,避免全局锁。- 每个 P 本地缓存 16 KB 灰色对象,减少 false sharing。
图 2:灰色队列环形 buffer 结构(SVG)
四、屏障:为什么需要“写屏障+读屏障”混合双打?
4.1 写屏障(Dijkstra)
核心目的:防止黑色对象新增白色指针。
// 汇编插入点:runtime/internal/atomic/atomic_amd64.s
TEXT runtime·writebarrierptr(SB), NOSPLIT, $0-16
MOVQ ptr+0(FP), DI
MOVQ val+8(FP), AX
CMPB runtime·writeBarrier(SB), $0
JE nowb
CALL runtime·gcWriteBarrier(DI, AX) // 关键
nowb:
MOVQ AX, (DI)
RET
- 条件分支
writeBarrier是全局变量,GC 期间置 1,非 GC 期间为 0,零开销。 - 写屏障仅记录指针值,不记录非指针(int、float),节省 30% 日志量。
4.2 读屏障(Yuasa)
在 1.18 引入,用于 栈收缩时防止悬挂指针。
// runtime/stack.go:1013
if stackguard0 == stackPreempt && preemptible {
gcReadBarrier() // 再次检查指针
}
读屏障只在 栈扫描阶段 打开,标记结束后立即关闭,避免性能回退。
五、STW 从 1.5 的 50 ms 到 1.23 的 0.4 ms 做了什么?
| 版本 | 关键改进 | 实测 STW |
|---|---|---|
| 1.5 | 并发标记 + 写屏障 | 30-50 ms |
| 1.8 | 混合屏障 + 栈收缩并行 | 5-10 ms |
| 1.19 | Pacer 2.0 | 1-2 ms |
| 1.23 | softmax+preemption redesign | 0.3-0.6 ms |
5.1 softmax 机制
在 1.23 中,runtime 把“全局 stop”拆成了“软 stop”和“硬 stop”两步:
- soft stop:抢占所有运行 G,但允许 M 继续执行系统调用;
- hard stop:仅当所有 M 都进入
_Gsyscall或_Gdead时才真正 STW。
图 3:STW 时间序列对比(GIF,2.1 MB)
六、动手实验:复现一次 200 ms 尖刺
6.1 准备
go install golang.org/dl/go1.23rc@latest
go1.23rc download
git clone https://github.com/yourname/demo-gc-trace
cd demo-gc-trace && go run main.go
6.2 生成负载
// main.go
func main() {
go func() {
for {
_ = make([]byte, 4<<20) // 4 MB
time.Sleep(5 * time.Millisecond)
}
}()
http.ListenAndServe(":6060", nil)
}
6.3 采集 trace
curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=10
go tool trace trace.out
在 trace 视图里,你会看到一次 Mark Assist 占用 189 ms。
根因:GOGC=100 时,Pacer 预测失准,协程被迫做大量辅助标记。
6.4 解决
- 调大 GOGC=off 先确认 GC 不是瓶颈
- 打开
GODEBUG=gctrace=1,gcpacertrace=1观察目标 heap 标记 - 在 1.23 里直接:
GOEXPERIMENT=softmax go run main.go
尖刺直接降到 0.4 ms。
图 4:GOEXPERIMENT=softmax 前后 trace 对比
七、可迁移的 4 条调优范式
-
把 GC 当成“协程调度”的一部分
用GODEBUG=gctrace=1日常观测,而不是出事故再抓 dump。 -
栈扫描时间 ∝ 活跃 goroutine 数量
对长连接服务,限制GOMAXPROCS比调大内存更有用。 -
减少指针密度 = 降低写屏障压力
[]int64比[]*int64在 GC 期间 CPU 降低 15%。 -
1.23 的 softmax 不是万能药
对实时性要求 < 200 µs 的 RPC,考虑runtime.GOMAXPROCS()+pprof.Labels做隔离。
八、一句话总结
GC 不是黑魔法,它只是 300 MB 源码 + 30 年论文的工程实现。
把源码读薄,把 trace 读厚,尖刺就会变成直尺。
如果这篇笔记对你有用,欢迎 star demo 仓库,也欢迎在评论区贴出你的 trace 截图,一起把 GC 拆成乐高!
欢迎 👍点赞✍评论⭐收藏
更多推荐



所有评论(0)