并发 GC 要解决一个核心问题:程序在标记期间还在改指针,怎么保证活的对象不被漏标?答案是写屏障。这篇用 gctrace 看一次 GC 的真实形态,读运行时源码里的设计描述,在汇编里找到屏障,量出屏障的代价,并对比 Go 1.27.1 默认的新 GC 实现。
共 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.400gctrace 的每行是一次 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,标记阶段占了几乎所有时间,是最坏情形;实际程序里标记阶段只占一部分时间。
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 的细节。