三色标记并发 GC 原理
1. 核心概念与工作原理
垃圾回收(Garbage Collection, GC)是 Go 语言运行时最核心的基础设施之一。它要解决的根本问题是:当程序运行时如何自动判断"哪些内存对象不再被使用"并安全释放。 手工管理(C 语言的 malloc/free)容易出现内存泄漏和悬垂指针;引用计数方案(如 Objective-C 的 ARC、Python 早期实现)虽然实时但难以处理循环引用;标记-清除(Mark-Sweep)算法通过全局可达性扫描绕开了循环引用难题,但传统实现需要"stop-the-world"——把全部用户 goroutine 暂停,标记完再恢复,这在延迟敏感的服务里是不可接受的。
Go 从 1.5 起改用 三色标记-清除(Tri-color Mark-Sweep) 并配合并发执行,是 Go 延迟表现能跻身主流语言前列的关键。
三色抽象的本质
三色标记法将堆上所有对象按"标记进度"划分为三类:
| 颜色 | 含义 | 处理状态 |
|---|---|---|
| 白 | 尚未被 GC 访问过 | 待考察/可回收候选 |
| 灰 | 自身已被访问,但子引用未扫描 | 待扫描(工作队列) |
| 黑 | 自身已被访问且子引用已扫描 | 本轮存活,标记完成 |
初始状态下所有对象都是白的。GC 从一组根对象(globals、当前 goroutine 栈帧上的指针、finalizer 队列等)出发,把根涂灰,然后反复执行:从灰色队列取一个对象涂黑,同时把它指向的白色子对象涂灰并入队。当灰色集合为空时,所有非黑非白的对象就是不可达的垃圾——可以安全回收。
整个过程只需要保证一条不变性(invariant):
黑色对象不能直接指向白色对象。
只要这条不变性成立,最终扫描结束时留下的白色对象就一定是垃圾。这条不变性正是后续"写屏障"存在的根本原因。
2. 关键阶段划分
Go 的现代 GC 周期被拆分为四个严格有序的阶段,对延迟影响差别极大:
- 标记准备(Mark Setup, STW):开启写屏障、暂停与 GC 辅助相关初始化。需要极短停顿(典型 10–100 微秒)。
- 并发标记(Concurrent Mark):后台标记协程与用户 goroutine 同时跑,靠写屏障保证正确性。不暂停用户程序。
- 标记终止(Mark Termination, STW):关闭写屏障、刷新根扫描结果,再次触发短停。
- 并发清扫(Concurrent Sweep):回收白色对象占用的内存并放回 mcache。同样与用户 goroutine 并发。
两个 STW 阶段加起来在 Go 1.21+ 通常只有几十到几百微秒;标记和清扫大部分时间是与程序并发的,这是 Go GC 延迟可控的根本原因。
3. 深度代码演练(完整可运行示例)
下面用一个纯用户态模拟复现三色标记算法的核心流程。运行时数据结构仅 5 个对象构成的引用图:A 是根,引用 C,C 引用 B;E 是标记过程中由 A 新增引用的"中途加入对象"——这是后续"写屏障"文章的关键案例。
package main
import "fmt"
// 三色对象:0=白 1=灰 2=黑
type Obj struct {
name string
refs []*Obj
color int // 0=白 1=灰 2=黑
}
var colorName = []string{"白", "灰", "黑"}
// 经典的"取一个灰对象、涂黑、把它指向的白对象涂灰"循环
func mark(root *Obj) {
queue := []*Obj{root}
root.color = 1
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
cur.color = 2 // 自己涂黑
for _, r := range cur.refs {
if r.color == 0 { // 仅白对象入队,避免重复
r.color = 1
queue = append(queue, r)
}
}
}
}
func dump(objs []*Obj) {
for _, o := range objs {
fmt.Printf(" %s = %s\n", o.name, colorName[o.color])
}
}
func main() {
// 构建引用图:
// A(root) -- C --> D
// B 与 E 不被任何存活链引用,是"垃圾"
a := &Obj{name: "A"}
b := &Obj{name: "B"}
c := &Obj{name: "C"}
d := &Obj{name: "D"}
e := &Obj{name: "E"}
a.refs = []*Obj{c}
c.refs = []*Obj{d}
_ = b
_ = e
all := []*Obj{a, b, c, d, e}
fmt.Println("标记前(全部白色):")
dump(all)
mark(a)
fmt.Println("标记后:")
dump(all)
fmt.Println("结果:A、C、D 变黑(存活);B、E 仍白(待回收)。")
}
运行结果:
标记前(全部白色):
A = 白
B = 白
C = 白
D = 白
E = 白
标记后:
A = 黑
B = 白
C = 黑
D = 黑
E = 白
结果:A、C、D 变黑(存活);B、E 仍白(待回收)。
4. 关键解读
4.1 为什么 BFS 而非 DFS 模拟?
真实 Go runtime 用的是显式工作队列(源码 runtime/mgcmark.go 里的 gcw 结构),最直观的实现就是上面的 BFS。DFS 用递归栈模拟也可以,但当对象图极深时会爆栈;而 BFS 的队列容量可预测、对 GC 暂停时间更友好。
4.2 为什么不变性"黑不能指白"如此关键?
设想反例:标记过程中,用户 goroutine 把一个已经涂黑的对象 X 的某个字段改写,指向一个尚未扫描的白色对象 Y。如果不加任何同步机制:
- X 已经是黑的,扫描器不会再来扫它
- Y 仍是白的,从未入队
- 扫描结束时 Y 仍为白 → 被当成垃圾回收
- 但 X 还引用着 Y → 悬挂指针,后续访问必然 crash
这就是经典的 "对象丢失"(lost object)问题。解决方案就是写屏障——把这次指针写入"通知"给 GC,让它采取补救措施。下一篇文章会详细拆解 Dijkstra 插入屏障与 Yuasa 删除屏障。
4.3 真实 Go GC 与本模拟的差异
| 维度 | 本模拟(教学) | Go runtime 真实实现 |
|---|---|---|
| 对象表示 | 普通 struct 字段 | heapArena 内的位图 + 指针标记 |
| 队列 | []*Obj slice | 每个 P 私有的 gcw,无锁本地工作窃取 |
| 根集合 | 手动传入 A | globals + 每个 goroutine 栈 + finalizer 队列 |
| 正确性保障 | 不变的人工约束 | 混合写屏障(deletion + insertion) |
| 并发性 | 完全串行 | 用户 goroutine 与 mark worker 同时跑 |
尽管实现细节天差地别,算法骨架(三色 + 队列 + 不变性)完全一致。理解本例后再去读 runtime/mgcmark.go 会顺畅很多。
5. 小结
| 概念 | 一句话总结 |
|---|---|
| 三色抽象 | 用白/灰/黑记录"是否被扫描过"以表达标记进度 |
| 不变性 | 黑色对象不能直指白色对象,保证垃圾识别的正确性 |
| 阶段拆分 | 标记准备 STW + 并发标记 + 标记终止 STW + 并发清扫 |
| 延迟来源 | 两个 STW 阶段各 10–100 微秒,是 Go GC 延迟瓶颈 |
| 与下一篇衔接 | 不变性靠"写屏障"维护,下面进入写屏障机制 |