bigywater.tech
Go · 49

三色标记与写屏障:并发标记期间新指针怎么不丢对象

并发 GC 要解决一个核心问题:程序在标记期间还在改指针,怎么保证活的对象不被漏标?答案是写屏障。这篇用 gctrace 看一次 GC 的真实形态,读运行时源码里的设计描述,在汇编里找到屏障,量出屏障的代价,并对比 Go 1.27.1 默认的新 GC 实现。

17.59 ns
标记阶段里一次指针写的耗时(GC 空闲时是 2.37 ns);整数写不受影响
0.12 ms
460 次 GC 里标记终止 STW 的最大值
-23%
greenteagc 对这个分配循环 GC CPU 时间的节省(1.68 s 对 2.17 s)

三个阶段

实测 · 本机执行输出
  共 460 次 GC;前 4 行:
    gc 1 @0.002s 4%: 0.028+1.0+0.020 ms clock, 0.28+0/1.3/0.10+0.20 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
    gc 2 @0.004s 5%: 0.020+0.66+0.021 ms clock, 0.20+0.088/0.74/0.001+0.21 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
    gc 3 @0.005s 5%: 0.016+0.75+0.007 ms clock, 0.16+0.13/0.83/0.029+0.073 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
    gc 4 @0.006s 6%: 0.024+0.86+0.009 ms clock, 0.24+0.080/1.1/0.28+0.092 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
  逐段解释第 1 行:@0.002s 时开始;GC 占总 CPU 的 4%;墙钟时间 三段 = 0.028 + 1.0 + 0.020 ms(清扫终止 STW + 并发标记 + 标记终止 STW);CPU 时间 = 0.28 + 0/1.3/0.10 + 0.20 ms(STW + 辅助/后台/空闲标记 + STW);堆 3→4→1 MB(GC 开始时 → 标记结束时 → 存活),目标 4 MB,10 个 P
  460 次 GC 的两次 STW 耗时(ms):清扫终止最大 0.045、标记终止最大 0.120;并发标记墙钟时间中位 1.400

gctrace 的每行是一次 GC:两个 STW(清扫终止、标记终止)都只有几十微秒,中间的并发标记占大部分墙钟时间,但那段时间里程序在继续运行。3->4->1 MB 是 GC 开始时的堆、标记结束时的堆、存活的堆。源码开头也说明了总体设计:并发、标记清扫、非分代、非压缩。

为什么需要写屏障

实测 · 本机执行输出
  mgc.go:8-12
    // GC threads to run in parallel. It is a concurrent mark and sweep that uses a write barrier. It is
    // non-generational and non-compacting. Allocation is done using size segregated per P allocation
    // areas to minimize fragmentation while eliminating locks in the common case.
    //
    // The algorithm decomposes into several steps.
  mgc.go:25-27
    //
    //    a. Stop the world. This causes all Ps to reach a GC safe-point.
    //
  mgc.go:36-46
    //    scanned until all Ps have enabled the write barrier, which is
    //    accomplished using STW.
    //
    //    b. Start the world. From this point, GC work is done by mark
    //    workers started by the scheduler and by assists performed as
    //    part of allocation. The write barrier shades both the
    //    overwritten pointer and the new pointer value for any pointer
    //    writes (see mbarrier.go for details). Newly allocated objects
    //    are immediately marked black.
    //
    //    c. GC performs root marking jobs. This includes scanning all
  mbarrier.go:24-34
    // Go uses a hybrid barrier that combines a Yuasa-style deletion
    // barrier—which shades the object whose reference is being
    // overwritten—with Dijkstra insertion barrier—which shades the object
    // whose reference is being written. The insertion part of the barrier
    // is necessary while the calling goroutine's stack is grey. In
    // pseudocode, the barrier is:
    //
    //     writePointer(slot, ptr):
    //         shade(*slot)
    //         if current stack is grey:
    //             shade(ptr)

三色标记里,标记完成的对象是黑色,待扫描的灰色,未访问的白色;白色最后被回收。如果程序在标记期间把一个白色对象的唯一引用写进一个已经变黑的对象、同时又删掉了原来的引用,这个对象就会被漏掉。写屏障拦截每一次指针写入,保证这种情况不会发生。源码里的描述是:混合屏障,既 shade 被覆盖的旧指针,也(在当前栈是灰色时)shade 新写入的指针;并且标记期间新分配的对象直接标黑。

写屏障在汇编里

实测 · 本机执行输出
  main.storePtr 的汇编(与写屏障有关的指令):
    (<gtmp>/gc2/main.go:9)	MOVD	16(g), R16
    (<gtmp>/gc2/main.go:9)	MOVD.W	R30, -16(RSP)
    (<gtmp>/gc2/main.go:9)	MOVD	R29, -8(RSP)
    (<gtmp>/gc2/main.go:9)	MOVWU	runtime.writeBarrier(SB), R2
    (<gtmp>/gc2/main.go:9)	MOVD	(R0), R2
    (<gtmp>/gc2/main.go:9)	CALL	runtime.gcWriteBarrier2(SB)
    (<gtmp>/gc2/main.go:9)	STP	(R1, R2), (R25)
    (<gtmp>/gc2/main.go:9)	MOVD	R1, (R0)
    (<gtmp>/gc2/main.go:9)	MOVD	-8(RSP), R29
  main.storeInt 的汇编(与写屏障有关的指令):
    (<gtmp>/gc2/main.go:12)	MOVD	R1, 8(R0)
  storePtr 里对 runtime.writeBarrier 标志的检查与 runtime.gcWriteBarrier 调用的个数:2

汇编里的指针写入先读一个全局标志:GC 不在标记时标志是关的,代价只是一次比较;标记阶段标志打开,每次指针写要调用 gcWriteBarrier2 记录指针。整数写没有屏障,只有一条存储指令。

屏障的代价只在标记时

实测 · 本机执行输出
  GC 空闲(没有后台 GC)                     指针写 a.next = b 中位 2.37 ns/次;整数写 a.val[0] = i 中位 1.01 ns/次
  后台不停地 runtime.GC()                 指针写 a.next = b 中位 17.59 ns/次;整数写 a.val[0] = i 中位 1.11 ns/次;这段时间后台 runtime.GC() 完成了 143 轮

实测指针写在 GC 空闲时 2.37 ns,标记阶段升到 17.59 ns(约 7.4 倍);整数写不受影响。这个实验里后台连续不停地 GC,标记阶段占了几乎所有时间,是最坏情形;实际程序里标记阶段只占一部分时间。

默认的 GC 已经不是旧实现

实测 · 本机执行输出
  go env GOEXPERIMENT = 
  go list 的 ToolTags 里与 GC 有关的:goexperiment.greenteagc
  默认(greenteagc 开):3 次运行的 GC 次数 [459, 457, 463];每次运行 GC 的 CPU 时间合计(ms)[1677.6, 1691.0, 1666.2];两次 STW 之和的最大值(ms)[0.191, 0.192, 0.188]
  GOEXPERIMENT=nogreenteagc:3 次运行的 GC 次数 [553, 554, 559];每次运行 GC 的 CPU 时间合计(ms)[2170.4, 2163.0, 2240.2];两次 STW 之和的最大值(ms)[0.191, 0.189, 0.185]

这个工具链的 ToolTags 里有 goexperiment.greenteagc,说明默认启用了新的标记实现。同一个分配循环里关掉它(GOEXPERIMENT=nogreenteagc)GC 次数更多、GC CPU 时间多约 30%,STW 暂停没有变化。新实现为什么更省我没有深入(只看了现象),上面源码摘录描述的是整体设计,不是 greenteagc 的细节。

分步演示

三色标记与写屏障
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。gctrace 来自 GODEBUG=gctrace=1 的真实输出;源码摘录来自 runtime/mgc.go 与 runtime/mbarrier.go(原样);汇编来自 go build -gcflags=-S;写屏障代价的存活堆是 200 万个节点(每个 56 字节),后台线程不停调用 runtime.GC();greenteagc 对比是同一份源码用 GOEXPERIMENT=nogreenteagc 重新构建,各跑 3 次
    • 没有验证:greenteagc 与旧实现的差异细节、不同堆大小与 GOGC 下的暂停、mark assist 对程序延迟的影响(见 GC 触发一篇)、amd64 上的屏障代价、对象颜色与屏障内部缓冲(wbBuf)的实现、栈扫描与屏障的交互
    京ICP备2026067502号-1京公网安备11010502063487号