在这里插入图片描述

每日一句正能量

在当今的年代,做一个人极其不易,做一个有血有肉的人,而不是东拼西凑地糅合起一些人格特质,仿佛从没完没了的自动售货机里挑选出种种个性。


一、为什么又写 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”两步:

  1. soft stop:抢占所有运行 G,但允许 M 继续执行系统调用;
  2. 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 条调优范式

  1. 把 GC 当成“协程调度”的一部分
    GODEBUG=gctrace=1 日常观测,而不是出事故再抓 dump。

  2. 栈扫描时间 ∝ 活跃 goroutine 数量
    对长连接服务,限制 GOMAXPROCS 比调大内存更有用。

  3. 减少指针密度 = 降低写屏障压力
    []int64[]*int64 在 GC 期间 CPU 降低 15%。

  4. 1.23 的 softmax 不是万能药
    对实时性要求 < 200 µs 的 RPC,考虑 runtime.GOMAXPROCS()+pprof.Labels 做隔离。


八、一句话总结

GC 不是黑魔法,它只是 300 MB 源码 + 30 年论文的工程实现。
把源码读薄,把 trace 读厚,尖刺就会变成直尺。

如果这篇笔记对你有用,欢迎 star demo 仓库,也欢迎在评论区贴出你的 trace 截图,一起把 GC 拆成乐高!

欢迎 👍点赞✍评论⭐收藏

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐