三色标记并发 GC 原理

5 阅读5分钟

三色标记并发 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 周期被拆分为四个严格有序的阶段,对延迟影响差别极大:

  1. 标记准备(Mark Setup, STW):开启写屏障、暂停与 GC 辅助相关初始化。需要极短停顿(典型 10–100 微秒)。
  2. 并发标记(Concurrent Mark):后台标记协程与用户 goroutine 同时跑,靠写屏障保证正确性。不暂停用户程序
  3. 标记终止(Mark Termination, STW):关闭写屏障、刷新根扫描结果,再次触发短停。
  4. 并发清扫(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,无锁本地工作窃取
根集合手动传入 Aglobals + 每个 goroutine 栈 + finalizer 队列
正确性保障不变的人工约束混合写屏障(deletion + insertion)
并发性完全串行用户 goroutine 与 mark worker 同时跑

尽管实现细节天差地别,算法骨架(三色 + 队列 + 不变性)完全一致。理解本例后再去读 runtime/mgcmark.go 会顺畅很多。

5. 小结

概念一句话总结
三色抽象用白/灰/黑记录"是否被扫描过"以表达标记进度
不变性黑色对象不能直指白色对象,保证垃圾识别的正确性
阶段拆分标记准备 STW + 并发标记 + 标记终止 STW + 并发清扫
延迟来源两个 STW 阶段各 10–100 微秒,是 Go GC 延迟瓶颈
与下一篇衔接不变性靠"写屏障"维护,下面进入写屏障机制