6.1 三色标记与写屏障
三色标记的核心约束只有一句:并发标记期间,不能有白色对象被黑色对象直接引用——否则那个白对象会被漏标,进而在本轮被错误回收。写屏障就是维持这条约束的机制:每当程序(mutator)写入一个指针,运行时有机会「补记一笔」。
本节不重复三色标记的原理(/posts/golang/ 下已有专题文章讲清了「白灰黑」),而是回答一个更实际的问题:写屏障在生成的机器码里长什么样、它的开销落在哪里、什么时候会拖慢吞吐。
本节要回答:写屏障在汇编层是什么形态、Go 用的是哪种屏障、它的成本结构如何?结论是:Go 1.27 用的是混合屏障(Yuasa 删除屏障 + Dijkstra 插入屏障),编译期在每个指针写入点插入对
writeBarrier.enabled的检查与CALL runtime.gcWriteBarrierN;屏障把旧值和新值记入 512 项的wbBuf缓冲,GC 结束后writeBarrier.enabled置回 false,检查被短路,成本只剩一次 load + 分支。 与/posts/golang/既有三色标记文章的分工:那些讲「为什么需要屏障」,本节只讲「屏障的实现与吞吐影响」。
6.1.1 实验:在汇编里找到写屏障
复现基线:
- Go 工具链
go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0) - 机器:Apple M1 Pro,10 核,32 GiB(
sysctl -n hw.ncpu= 10) - 汇编实验是纯编译期行为;
gctrace实验用GOMAXPROCS=10、GOGC默认
被测代码只有两个写入点:一个写全局指针,一个写结构体指针字段。
type Node struct {
next *Node
val int
}
var globalPtr *Node
//go:noinline
func writeGlobal(p *Node) { globalPtr = p }
//go:noinline
func writeField(n *Node, p *Node) { n.next = p }
用 -S 看生成的汇编,过滤出与写屏障相关的行:
$ GOTOOLCHAIN=go1.27.0 go build -gcflags='-S' -o /dev/null .
0x0018 00024 (/tmp/gbrt2/wb/main.go:11) MOVWU runtime.writeBarrier(SB), R1
0x0020 00032 (/tmp/gbrt2/wb/main.go:11) CBZW R1, 52
0x0024 00036 (/tmp/gbrt2/wb/main.go:11) MOVD main.globalPtr(SB), R1
0x002c 00044 (/tmp/gbrt2/wb/main.go:11) CALL runtime.gcWriteBarrier2(SB)
0x0030 00048 (/tmp/gbrt2/wb/main.go:11) STP (R0, R1), (R25)
0x0034 00052 (/tmp/gbrt2/wb/main.go:11) MOVD R0, main.globalPtr(SB)
...
main.writeField STEXT size=96 align=0x0 args=0x10 locals=0x8 funcid=0x0
0x001c 00028 (/tmp/gbrt2/wb/main.go:14) MOVWU runtime.writeBarrier(SB), R2
0x0024 00036 (/tmp/gbrt2/wb/main.go:14) CBZW R2, 52
0x0028 00040 (/tmp/gbrt2/wb/main.go:14) MOVD (R0), R2
0x002c 00044 (/tmp/gbrt2/wb/main.go:14) CALL runtime.gcWriteBarrier2(SB)
0x0030 00048 (/tmp/gbrt2/wb/main.go:14) STP (R1, R2), (R25)
0x0034 00052 (/tmp/gbrt2/wb/main.go:14) MOVD R1, (R0)
一次指针写入先过一次开关检查,再执行四条屏障指令,读法如下(以 writeField 为例,R0 是 n,R1 是 p):
MOVD (R0), R2:读出旧值n.next。CALL runtime.gcWriteBarrier2(SB):调用写屏障。2表示要记 2 个指针(旧值和新值)。STP (R1, R2), (R25):把新值R1与旧值R2成对存进缓冲(R25是wbBuf的当前写指针,由屏障调用返回)。STP是 arm64 的「store pair」指令,一次写两个寄存器。MOVD R1, (R0):真正的赋值。
最前面的 MOVWU runtime.writeBarrier(SB), R2 与 CBZW R2, 52 是开关检查:writeBarrier.enabled 为 0 时直接跳到赋值,整个屏障被短路。
关键在顺序:屏障在赋值之前执行。这不是编译器的疏忽——如果先赋值、后调用屏障,那么「新指针已可见、但还没被记录」之间存在一个窗口,并发标记可能在这期间扫过 n,从而漏掉新值指向的对象。先记录、后写入,才安全。
再看这个负载的 GC 行为。用 GODEBUG=gctrace=1 跑一个持续制造含指针对象的程序:
$ GOTOOLCHAIN=go1.27.0 GODEBUG=gctrace=1 ./prog
gc 1 @0.003s 3%: 0.041+1.8+0.013 ms clock, 0.41+0/1.8/0.036+0.13 ms cpu, 4->5->3 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 2 @0.006s 5%: 0.004+1.7+0.009 ms clock, 0.046+0/2.4/0.92+0.090 ms cpu, 6->6->4 MB, 7 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 3 @0.008s 7%: 0.008+2.4+0.018 ms clock, 0.086+0/3.6/2.0+0.18 ms cpu, 8->8->6 MB, 9 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 4 @0.012s 8%: 0.011+4.0+0.004 ms clock, 0.11+0.19/6.1/3.0+0.048 ms cpu, 13->13->10 MB, 13 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 5 @0.019s 9%: 0.029+6.2+0.012 ms clock, 0.29+0.67/9.3/4.6+0.12 ms cpu, 20->20->15 MB, 20 MB goal, 0 MB stacks, 0 MB globals, 10 P
每一行里与写屏障最相关的是 CPU 三段 a/b/c(0.19/6.1/3.0):分别是 assist(辅助标记)/ idle(后台空闲标记)/ dedicated(专职标记) 的 CPU 时间。这三段非零就说明写屏障把「并发标记」这条流水线跑起来了;4->5->3 MB 是「本轮开始 → 结束 → 存活」的堆大小。gc 1 的 3% 是 GC 占用的 CPU 比例。
6.1.2 源码:混合屏障与缓冲
Go 用的是混合屏障。src/runtime/mbarrier.go 顶部的注释把伪码写得很清楚:
// 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)
// *slot = ptr
翻译成一句话:写入前,先把「被覆盖的旧值」涂灰;如果当前 goroutine 的栈还没被扫过(灰色),再把「新值」也涂灰。 两个 shade 分别对应删除屏障与插入屏障。
shade 的目标是把对象加进 wbBuf 并标记。开关在 src/runtime/mgc.go:
var writeBarrier struct {
enabled bool // compiler emits a check of this before calling write barrier
pad [3]byte // compiler uses 32-bit load for "enabled" field
alignme uint64 // guarantee alignment so that compiler can use a 32 or 64-bit load
}
const (
_GCoff = iota // GC not running; sweeping in background, write barrier disabled
_GCmark // GC marking roots and workbufs: allocate black, write barrier ENABLED
_GCmarktermination // GC mark termination: allocate black, P's help GC, write barrier ENABLED
)
//go:nosplit
func setGCPhase(x uint32) {
atomic.Store(&gcphase, x)
writeBarrier.enabled = gcphase == _GCmark || gcphase == _GCmarktermination
}
三个细节:
enabled的注释直接说明了编译器的行为:「compiler emits a check of this before calling write barrier」——6.1.1 汇编里那句对writeBarrier.enabled的检查就是它。pad与alignme不是随便加的。注释写明是为了让编译器能用 32 位或 64 位 load 读enabled——用单字节 load 会有更高的对齐与原子性成本。这是一个纯粹的微优化,写在这里是为了提醒:运行时的每个字段布局都是被算过的。- 只有
_GCmark和_GCmarktermination阶段屏障是开的。_GCoff期间屏障关闭,检查短路,指针写入几乎零成本——这就是为什么「GC 关闭时的写屏障开销」约等于 0。
屏障本体在汇编里。src/runtime/asm_arm64.s 为 1..8 个指针各生成了一个入口,2 是最常用的:
TEXT runtime·gcWriteBarrier2<ABIInternal>(SB),NOSPLIT,$0
MOVD $16, R25
JMP gcWriteBarrier<>(SB)
MOVD $16, R25 把「要记 2 个指针」(2 × 8 字节 = 16)放进 R25,再跳到公共实现。不同 N 的入口只差一个常数——编译器按写入点涉及的指针数量选入口,避免在屏障里再算一次。
记录去哪了?src/runtime/mwbbuf.go 的 wbBuf:
type wbBuf struct {
// next points to the next slot in buf. It must not be a
// pointer type because it can point past the end of buf and
// must be updated without write barriers.
next uintptr
// end points to just past the end of buf. It must not be a
// pointer type because it points past the end of buf and must
// be updated without write barriers.
end uintptr
// buf stores a series of pointers to execute write barriers on.
buf [wbBufEntries]uintptr
}
const (
// wbBufEntries is the maximum number of pointers that can be
// stored in the write barrier buffer.
//
// This trades latency for throughput amortization. ...
wbBufEntries = 512
...
)
next/end 刻意不是指针类型——注释说明它们可能指向 buf 之外,必须在没有写屏障的前提下更新。这是一个很典型的运行时自举问题:写屏障本身不能依赖写屏障。
wbBufEntries = 512 是「延迟 vs 吞吐」的取舍:攒满 512 个指针才统一处理,把每次写入的固定开销摊薄;代价是刷新延迟更高、缓存占用更大。注释里留了个 TODO「What is the latency cost of this? Tune this value.」——说明这个值不是理论最优,而是经验选择。
最后一点:屏障只对「可能触发漏标」的写入生效。写一个不含指针的值(如 int、[8]byte)根本不会插入屏障调用;写栈上的局部变量也不插——栈是本 goroutine 私有的,GC 会在安全点整体扫描它,不需要逐次记录。所以 6.1.1 的两个例子都写「堆上的指针槽」(全局变量、结构体字段),才会看到 CALL。
6.1.3 源码:快路径与 flush 路径
CALL runtime.gcWriteBarrier2 跳进的 gcWriteBarrier<> 才是真正的实现。它的注释先声明了调用约定:
// gcWriteBarrier informs the GC about heap pointer writes.
//
// gcWriteBarrier does NOT follow the Go ABI. It accepts the
// number of bytes of buffer needed in R25, and returns a pointer
// to the buffer space in R25.
// It clobbers condition codes.
// It does not clobber any general-purpose registers except R27,
// but may clobber others (e.g., floating point registers)
// The act of CALLing gcWriteBarrier will clobber R30 (LR).
注意第一句:它不遵循 Go ABI。调用方(编译器生成的代码)负责把「需要多少字节」放进 R25,屏障返回「缓冲里的起始位置」也在 R25——这是编译器和运行时之间的一份私有契约,不是普通函数调用。
主体分成快路径和 flush 路径两段。快路径(绝大多数情况)只有几行:
retry:
MOVD g_m(g), R0 // 取当前 g
MOVD m_p(R0), R0 // 取当前 P
MOVD (p_wbBuf+wbBuf_next)(R0), R1 // R1 = wbBuf.next
MOVD (p_wbBuf+wbBuf_end)(R0), R27 // R27 = wbBuf.end
ADD R25, R1 // next += 需要的大小
CMP R27, R1 // 缓冲够吗?
BHI flush // 不够 → 走 flush
MOVD R1, (p_wbBuf+wbBuf_next)(R0) // 提交新的 next
SUB R25, R1, R25 // 返回值 = 旧的 next
LDP 184(RSP), (R0, R1) // 恢复 R0/R1
RET
快路径就是「比较 + 提交」两次写内存:wbBuf 挂在 P 上(p_wbBuf),所以取缓冲不用加锁——每个 P 有自己的缓冲,天然无竞争。这段除了一次可预测的分支外没有额外同步成本。
缓冲满了才走 flush:
flush:
STP (R2, R3), 1*8(RSP)
STP (R4, R5), 3*8(RSP)
...
STP (R23, R24), 19*8(RSP)
STP (R25, R26), 21*8(RSP)
CALL runtime·wbBufFlush(SB)
...
JMP retry
flush 路径把几乎全部通用寄存器压栈(R2–R26),因为 wbBufFlush 是一个普通 Go 函数,会按 ABI 破坏这些寄存器。压完调用 wbBufFlush 清空缓冲,再 JMP retry 回到快路径重试。注释里对每个寄存器为什么保存/不保存都有交代(R16/R17 可能被 linker trampoline 破坏、R18 未使用、R28 是 g……)——这段汇编是手写的,逐条都是设计决策。
成本结构因此很清晰:绝大多数写入命中快路径(约 10 条指令),只有每 512 个指针的最后一个触发 flush(约 40 条指令 + 一次函数调用)。摊销下来每次写入的额外成本很低,但 flush 那一次的延迟尖峰是存在的——wbBufEntries = 512 这个常数就是在「快路径占比」和「flush 频率」之间取平衡。
还有一个容易忽略的常量:wbMaxEntriesPerCall(mwbbuf.go 里等于 8)——单次屏障调用最多记 8 个指针,正好对应 gcWriteBarrier1 到 gcWriteBarrier8 八个入口。编译器把一次语句里涉及的指针数(如 a.b = c 会记旧值和新值两个)映射到对应入口,超过 8 个就拆成多次调用。
6.1.4 决策:写屏障与吞吐的判断清单
| 现象 / 需求 | 机制 | 决策 |
|---|---|---|
| 服务在 GC 期间吞吐下降 | writeBarrier.enabled 在标记期开启,每次指针写入多一次 load + 分支 + 可能的 CALL | 正常现象;若下降剧烈,先看 GC 频率(gctrace 的 GC 间隔),而不是先怪屏障 |
| GC 关闭时也想测写屏障开销 | _GCoff 阶段 enabled=false,检查短路 | 用 GOGC=off 对比,可直接量出「屏障本身的成本」 |
| 想减少屏障次数 | 只有堆上的指针写入才插屏障 | 热路径少写堆指针:用值类型、批量赋值代替逐字段赋值 |
| 指针写入非常密集(如链表/树构建) | 每次写入一条 CALL | 这是屏障成本最高的场景;考虑改数据结构(如用索引代替指针、[]T 代替 []*T,见 5.3) |
| 想知道屏障有没有「拖慢标记」 | wbBuf 攒 512 项才刷新 | 屏障把「逐次记录」变成「批量处理」,成本被摊薄;观察 gctrace 的 assist 段是否异常 |
用 -race 构建测性能 | race 与屏障是两套机制,但都会拖慢 | 性能数字一律不用 -race 构建(与 4.1 的 !raceenabled 同因) |
| 写不含指针的值 | 不插屏障 | 无需担心;屏障只服务于指针图 |
三条判断纪律:
- 先确认「是否在标记期」。写屏障的成本只在
_GCmark/_GCmarktermination存在。看到吞吐抖动,第一件事是看抖动是否与 GC 周期对齐(gctrace的时间戳),而不是直接归因于屏障。 - 屏障的成本是「次数 × 每次」。每次的固定成本很低(一次 load + 分支 + 写缓冲),但指针密集的热循环里「次数」可以很高。降低次数比降低单次成本更有效。
- 别用「减少写屏障」当重构理由。真正的理由应该是数据局部性、对象数、扫描面(5.3)。屏障只是把「指针图维护成本」显式化的地方,它反映的是设计,不是问题本身。
下一节把视角从「单次写入」拉到「一整轮 GC」:标记怎么启动、辅助标记怎么被触发、pacing 怎么决定下一轮什么时候来。
阅读导航:上一节:5.3 内存布局优化实测 · 下一节:6.2 GC 阶段、辅助标记与 pacing 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。