内存回收源码(三):三色、写屏障
前言
本文接前两篇:在 触发与阶段、Worker/Assist 之后,从实现角度收束 三色抽象(位图 + gcWork)、混合写屏障。arena / mspan:堆内存划成带元数据的 arena,按 span(一段连续对象槽)交给分配器;不必先啃内存分配章节,只要知道「对象在堆里、bitmap 贴在堆的元数据里」即可。
三色标记法
1. 要解决什么问题
标记阶段要从根(全局变量、各 G 栈等)出发,沿指针走可达闭包。并发时 mutator 还在改图,所以需要一套机制边标边走;三色是教材里描述「标没标、孩子扫没扫」的说法,用来推理不会漏标,不是用来在 runtime 里找三条链表。
2. 白灰黑在 Go 里怎样落地
教材里的三色可以落在 runtime 里的两件独立机制上:
为了省内存,Go 并没有给每个对象分配一个 bool 字段来表示对象的标记状态,而是使用了 Bitmap(位图)
- 堆 GC 位图:对象体旁边的元数据里
- 极度节省空间:如果用 bool,每个对象至少多占 1 个字节(8 bits)。用位图,每个对象只占 1 bit。内存开销直接降到 1/8
- 这些位物理上放在哪? 在每个 mspan 的 gcmarkBits 里按 objIndex 定位(mspan:内存管理的基本单位)
- 「这块堆内存本轮被发现为可达」首先体现为 mark 位,而不是维护三条「白链表/灰链表/黑链表」。
type markBits struct {
bytep *uint8
mask uint8
index uintptr
}
// 判断「标过没」,避免重复着色/重复入队
func (m markBits) isMarked() bool {
return *m.bytep&m.mask != 0
}
func (m markBits) setMarked() {
atomic.Or8(m.bytep, m.mask)
}
// 一个 mspan 里可能有很多对象(小对象按 size class 切成很多个等长槽位,nelems 可以很大)。objIndex 就是「这个 span 里的第几个对象」
func (s *mspan) markBitsForIndex(objIndex uintptr) markBits {
bytep, mask := s.gcmarkBits.bitp(objIndex)
return markBits{bytep, mask, objIndex}
}
- 标记工作队列(gcWork + 全局 workbuf):标记过程不能靠无限递归深搜(会炸栈),所以 Go 用**队列(FIFO)**把“递归”变成了“迭代”
- 双缓冲机制:当 wbuf1 填满了,就把它换到全局队列,再把空的 wbuf2 顶上来
- workbuf:“灰色对象”的集合。存的每一个 uintptr 都是一个堆对象的起始地址。
// runtime/runtime2.go — 每个 P 一份本地缓存;写屏障往里生产,assist/mark worker 往外消费
type p struct {
...
// gcw is this P's GC work buffer cache.
gcw gcWork
...
}
// runtime/mgcwork.go
type gcWork struct {
// 双缓冲:始终在 wbuf1 上 push/pop
wbuf1, wbuf2 *workbuf
// Heap scan work performed on this gcWork. This is aggregated into
// gcController by dispose and may also be flushed by callers.
heapScanWork int64
...
}
type workbufhdr struct {
node lfnode // must be first,用于挂到全局链表
nobj int // 当前 obj 数组里有效指针个数
}
type workbuf struct {
_ sys.NotInHeap
workbufhdr
obj [(_WorkbufSize - unsafe.Sizeof(workbufhdr{})) / goarch.PtrSize]uintptr
}
两件事叠在一起读三色:mark 位回答「还是不是白」;队列里还有没有这个对象的扫描任务(noscan 除外)回答「灰还是黑」。
| 说法 | 对应实现 |
|---|---|
| 白 | mark 位未立。mark 结束时仍白的对象,sweep 当垃圾回收。 |
| 灰 | mark 位已立,且体内指针还没扫完;在代码里就是对象起始地址还在 当前 P 的 gcw / 全局 workbuf 里排队。 |
| 黑 | mark 位已立,且 scanobject 已按位图扫过该扫的槽;或 span 的 noscan 类型(对象体内没有需要再跟的指针):greyobject 里只 setMarked、更新字节统计就 return,不入队,等价于立刻变黑。 |
noscan从来没有进入过队列,所以它在 greyobject 执行完的那一秒,就直接从白色跳过灰色,瞬间变黑。
3. 具体步骤
1.种子来源:Root 扫描(发生在 STW 切换到并发标记瞬间)
在第一个 STW 快结束时,计算好所有的「根」(栈、全局变量、finalizer/cleanup 等固定根、按 arena 切片的 span specials)。开启世界后,worker / assist 在 gcDrain 第一段里按任务号调用 markroot。
**调用次序(仍在 STW、启世界之前)**见 runtime/mgc.go gcStart 片段:setGCPhase(_GCmark) 后在 startTheWorldWithSema 之前执行 gcPrepareMarkRoots()(若注释写「第一个 STW」指混合模式里进入并发标记前那一次停世界,与这里对齐)。
// runtime/mgc.go — gcStart(节选,并发标记前)
setGCPhase(_GCmark)
gcBgMarkPrepare()
gcPrepareMarkRoots()
gcMarkTinyAllocs()
atomic.Store(&gcBlackenEnabled, 1)
systemstack(func() {
now = startTheWorldWithSema(0, stw)
// ...
})
gcPrepareMarkRoots()在进入并发标记前、世界仍停着(assertWorldStopped)时,把「后面要扫多少类根、怎么编号」全部准备好
// runtime/mgcmark.go
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
...
// 把 gcPrepareMarkRoots 事先排好的「根扫描任务」一个个做完
if work.markrootNext < work.markrootJobs {
// Stop if we're preemptible, if someone wants to STW, or if
// someone is calling forEachP.
for !(gp.preempt && (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {
job := atomic.Xadd(&work.markrootNext, +1) - 1
// 来晚了,活已经分完
if job >= work.markrootJobs {
break
}
markroot(gcw, job, flushBgCredit)
...
}
}
...
}
// 给定任务编号 i,做完这一类/这一块根上的扫描,把发现的堆指针交给 gcw(最终会 greyobject)
func markroot(gcw *gcWork, i uint32, flushBgCredit bool) int64 {
switch {
case work.baseData <= i && i < work.baseBSS:
// .data 全局:markrootBlock(..., gcdatamask, ...)
case work.baseBSS <= i && i < work.baseSpans:
// .bss:markrootBlock(..., gcbssmask, ...)
case i == fixedRootFinalizers:
// ...
case i == fixedRootFreeGStacks:
systemstack(markrootFreeGStacks)
case i == fixedRootCleanups:
// ...
case work.baseSpans <= i && i < work.baseStacks:
markrootSpans(gcw, int(i-work.baseSpans))
default:
// 各 G 的栈:suspendG → scanstack(gp, gcw)
}
}
这里的「根」是 GC 追踪的第一批种子地址:不经过其它堆对象,runtime 就能直接拿到并开始扫描。结合 markroot 分支,主要有四类:
- 全局区根(
.data/.bss):按模块的gcdatamask/gcbssmask分块扫描(markrootBlock)。
- 全局变量、静态变量
- 进程级静态变量区里指向堆对象的指针,程序一运行就常驻。
- 固定根(runtime 维护):例如
fixedRootFinalizers、fixedRootCleanups、fixedRootFreeGStacks。
- runtime 自己维护的全局管理结构里的指针入口,不属于业务对象字段,但同样能摸到堆对象。
- span specials 根:
markrootSpans这一路处理。
- 挂在
mspan.specials上的特殊记录链(如终结器/可达性相关记录)携带的引用关系。
- 各 G 栈根:其余 root job 最终会走到
suspendG -> scanstack。
- 每个 goroutine 当前调用栈上的局部变量/参数里的堆指针,会随函数调用实时变化
- suspendG -> scanstack:因为 goroutine 正在跑时,栈上的值会变。GC 要拿到稳定快照,就先把目标 G 挂起(或进入可扫描状态),再扫描它的栈,找出所有“从栈直接指向堆”的引用。
所以根并不是「所有活对象」,而是并发标记最开始那批入口;后续整张对象图的扩展,靠 gcDrain -> scanobject -> greyobject 持续完成。
2.核心循环:消费与再生产(发生在并发标记阶段)
进入gcDrain函数循环:从队列里取地址、扫对象体、再发现子指针·再入队——即「消费灰 / 扩灰」闭环;与 mutator、写屏障并发,直到本 P 本地和全局暂时都取不到活(break)或由 check 提前结束整段 gcDrain。
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
...
// Drain heap marking jobs.
//
// Stop if we're preemptible, if someone wants to STW, or if
// someone is calling forEachP.
for !(gp.preempt && (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {
...
// See mgcwork.go for the rationale behind the order in which we check these queues.
var b uintptr
var s objptr
if b = gcw.tryGetObjFast(); b == 0 {
// 先看 本 P 的 span 队列
if s = gcw.tryGetSpan(false); s == 0 {
// 本地没有了,允许从全局 work.full 拉满块等
if b = gcw.tryGetObj(); b == 0 {
// 队列里暂时没对象,但 mutator 的写屏障可能还把「新灰指针」压在 P 本地的 barrier buffer 里
// flush 把它们推进 gcw,往往能造出新活。
wbBufFlush()
if b = gcw.tryGetObj(); b == 0 {
s = gcw.tryGetSpan(true)
}
}
}
}
if b != 0 {
// 拿到的是某个堆对象(或 oblet)的起始地址
scanobject(b, gcw)
} else if s != 0 {
// 拿到的是 span 级扫描任务
scanSpan(s, gcw)
} else {
// 没取出活
break
}
...
}
}
scanobject:
- 消费(灰变黑):
- 从 gcw 队列取出一个灰色对象地址 b。
- 调用 scanobject(b):翻开 b 的类型位图,挨个检查它肚子里的指针槽。
- 结果:扫完后,b 不在队里了,它正式由灰变黑。
- 再生产(白变灰):
- 在扫 b 的过程中,发现它引用了子对象 c。
- 调用 greyobject(c):如果 c 没标过,就给它 置位并入队。
- 结果:产生了新“灰”,循环继续,像滚雪球一样把所有存活对象都标记到。
- 特殊捷径(瞬间变黑):
- 如果 c 是 noscan(没指针),greyobject 只置位、不入队。它直接跳过灰色,瞬间变黑。
// runtime/mgcmark.go
func scanobject(b uintptr, gcw *gcWork) {
...
obj := *(*uintptr)(unsafe.Pointer(addr))
if obj != 0 && obj-b >= n {
if !tryDeferToSpanScan(obj, gcw) {
if obj, span, objIndex := findObject(obj, b, addr-b); obj != 0 {
greyobject(obj, b, addr-b, span, gcw, objIndex)
}
}
}
...
}
// 置位 / noscan 立刻黑 / 否则入队成灰
func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) {
mbits := span.markBitsForIndex(objIndex)
if mbits.isMarked() {
return
}
mbits.setMarked()
// pageMarks …略
if span.spanclass.noscan() {
gcw.bytesMarked += uint64(span.elemsize)
return
}
if !gcw.putObjFast(obj) {
gcw.putObj(obj)
}
}
3. 防漏补课:写屏障冲刷(发生在标记快结束时)
也就是上面的wbBufFlush()。这些新对象会被写屏障捕获存在 wbBuf 缓冲区里,wbBufFlush()刷进 gcw 队列
4. 结案陈词:分布式终止(发生在标记终止 STW 前)
判断标准:
- 所有 P 的本地队列 gcw 都空了。
- 全局 workbuf 缓冲区也空了。
- 写屏障缓冲区 wbBuf 也没活了。
- 没有正在打工的 Assist(辅助回收)。
// gcBgMarkWorker详情见第二篇
func gcBgMarkWorker(ready chan struct{}) {
...
// 从 worker 视角看,标记可以收摊了
pp.gcMarkWorkerMode = gcMarkWorkerNotWorker
if incnwait == work.nproc && !gcMarkWorkAvailable(nil) {
releasem(node.m.ptr())
node.m.set(nil)
gcMarkDone()
}
...
}
func gcMarkWorkAvailable(p *p) bool {
if p != nil && !p.gcw.empty() {
return true
}
if !work.full.empty() || !work.spanq.empty() {
return true // global work available
}
if work.markrootNext < work.markrootJobs {
return true // root scan work available
}
return false
}
写屏障
1. 并发标记时为何要屏障
在并发标记时,业务代码(Mutator)会修改对象引用。如果没有屏障,会出现以下致死场景:
- 场景:一个黑色对象(已扫完)突然指向了一个白色对象(未扫描),而原本指向这个白色对象的唯一引用(来自灰色或栈)被断开了。
- 后果:GC 以为这个白色对象没用了,将其回收,但实际上黑色对象正引用着它。这会导致野指针崩溃。
- 核心逻辑:写屏障通过在“写操作”瞬间拦截,破坏掉上述场景发生的条件
2. Go 用的是哪种屏障
Go 1.8+ 使用的是一种融合了 Yuasa(删除屏障) 和 Dijkstra(插入屏障) 优点的混合机制。
// 伪代码
// writePointer(slot, ptr):
// shade(*slot) // 1. 保护旧值:Yuasa 风格。防止旧引用被删除后,对象丢失。
// if current stack grey:
// shade(ptr) // 2. 保护新值:Dijkstra 风格。如果当前栈还没扫黑,新值也得标灰。
// *slot = ptr // 3. 正式写入
下面是伪代码与实现直觉的对照,建议与 §1「黑指向白、唯一旧边断开」那一段连读。
Yuasa 一侧:shade(*slot)(删屏障,保护被覆盖掉的旧指针)
| 角色 | 含义 |
|---|---|
| A | 黑色:已扫完,collector 不会再扫 A 的槽。 |
| B | 灰色:尚未扫完,当前 B.next → C。 |
| C | 白色:教学场景下假定只有经 B 可达。 |
序列与后果(无屏障时):
B.next = nil:从 B 到 C 的边没了,C 又未进队,frontier 可能永远碰不到 C。A.next = C:边进了「已黑」的 A,collector 同样不会为这次写回头扫 A。- 结果:C 可能被错收,而 A 仍指着 C → §1 里的野指针类问题。
屏障在步骤 1:在真正把 nil 写进槽前,先读旧值(C),shade(C),标灰并进 gcw。于是无论步骤 2 是否发生,并发标记阶段都会扫到 C。
「宁错杀不放过」与浮动垃圾:若 C 其实已不可达、本该本轮回收,保守 shade 会让 C 多活一轮,即 floating garbage——多一点内存,下一轮再收。换的是:不因「覆盖槽丢掉唯一露在图里的边」而漏标。
栈与 Dijkstra 一侧:shade(ptr)(插屏障,补「栈 → 堆」的新边)
Go 不为栈写普遍插屏障。根与栈可晚于部分堆对象被扫到,故常说当前 goroutine 栈仍「灰」(实现里是运行期条件,不是给栈位图三色)。
执行 A.next = C 且槽里旧值为 nil 时,shade(*slot) 碰不到 C(没有旧堆指针)。若 C 此前只从栈可得、栈尚未扫完,仅靠删屏障仍可能漏这条新来的堆边。
故伪代码里:栈仍灰时对新值 ptr(即 C)再 shade(ptr),让「从栈旁路塞进黑对象槽」的目标先进灰队列,再走 scanobject。
3. 谁来执行:writeBarrier.enabled 与编译器
写屏障不是全时段开启的,也不是对所有变量都生效。
开关切换:setGCPhase 在进入 _GCmark 阶段时,会将 writeBarrier.enabled 设为 true。此时,编译器预埋在代码里的屏障逻辑才真正激活。
编译器优化:
- 逃逸分析:编译器知道哪些写操作是在栈内完成的(比如局部变量赋值),这些操作不会触发屏障调用,保证了热点代码的性能。
- 汇编加速:单指针赋值会调用汇编实现的 gcWriteBarrier,而批量拷贝(如 copy(slice))则通过 typedmemmove 调用 bulkBarrierPreWrite 进行批量“补票”(runtime/mbarrier.go):
func typedmemmove(typ *abi.Type, dst, src unsafe.Pointer) {
if dst == src {
return
}
if writeBarrier.enabled && typ.Pointers() {
bulkBarrierPreWrite(uintptr(dst), uintptr(src), typ.PtrBytes, typ)
}
memmove(dst, src, typ.Size_)
}
4. 写屏障缓冲与 wbBufFlush
如果每次写屏障都要执行复杂的“标灰+入队”逻辑,业务性能会暴跌。
- 机制:每个 P 拥有一个 wbBuf(写屏障缓冲区)。
- 过程:写屏障产生的“待标灰地址”先丢进这个数组。
- 冲刷(Flush):
- 缓冲区满了。
- GC Worker 发现活干完了(gcDrain 取不到对象),会调用 wbBufFlush() 将这些地址一次性转入 gcWork 队列。
- 意义:这实现了批量处理,将原本昂贵的同步操作转变为低开销的异步记录。
5. 与三色一节的关系
静态扫描(扫图):scanobject 沿着 GC 开始那一刻的“内存快照”稳步推进。
动态补漏(屏障):写屏障紧盯 Mutator 的“实时动作”,捕捉快照之后产生的所有引用变化。