Go 内存回收(3):GC 源码-三色与写屏障

5 阅读11分钟

内存回收源码(三):三色、写屏障

前言

本文接前两篇:在 触发与阶段、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 分支,主要有四类:

  1. 全局区根(.data/.bss):按模块的 gcdatamask/gcbssmask 分块扫描(markrootBlock)。
  • 全局变量、静态变量
  • 进程级静态变量区里指向堆对象的指针,程序一运行就常驻。
  1. 固定根(runtime 维护):例如 fixedRootFinalizersfixedRootCleanupsfixedRootFreeGStacks
  • runtime 自己维护的全局管理结构里的指针入口,不属于业务对象字段,但同样能摸到堆对象。
  1. span specials 根:markrootSpans 这一路处理。
  • 挂在 mspan.specials 上的特殊记录链(如终结器/可达性相关记录)携带的引用关系。
  1. 各 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:

  1. 消费(灰变黑):
    • 从 gcw 队列取出一个灰色对象地址 b。
    • 调用 scanobject(b):翻开 b 的类型位图,挨个检查它肚子里的指针槽。
    • 结果:扫完后,b 不在队里了,它正式由灰变黑。
  2. 再生产(白变灰):
    • 在扫 b 的过程中,发现它引用了子对象 c。
    • 调用 greyobject(c):如果 c 没标过,就给它 置位并入队。
    • 结果:产生了新“灰”,循环继续,像滚雪球一样把所有存活对象都标记到。
  3. 特殊捷径(瞬间变黑):
    • 如果 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 前)

判断标准:

  1. 所有 P 的本地队列 gcw 都空了。
  2. 全局 workbuf 缓冲区也空了。
  3. 写屏障缓冲区 wbBuf 也没活了。
  4. 没有正在打工的 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 可达。

序列与后果(无屏障时):

  1. B.next = nil:从 B 到 C 的边没了,C 又未进队,frontier 可能永远碰不到 C。
  2. A.next = C:边进了「已黑」的 A,collector 同样不会为这次写回头扫 A。
  3. 结果: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):
    1. 缓冲区满了。
    2. GC Worker 发现活干完了(gcDrain 取不到对象),会调用 wbBufFlush() 将这些地址一次性转入 gcWork 队列。
  • 意义:这实现了批量处理,将原本昂贵的同步操作转变为低开销的异步记录。

5. 与三色一节的关系

静态扫描(扫图):scanobject 沿着 GC 开始那一刻的“内存快照”稳步推进。

动态补漏(屏障):写屏障紧盯 Mutator 的“实时动作”,捕捉快照之后产生的所有引用变化。