Go 内存回收(2):GC 源码-并发标记与 Assist

7 阅读6分钟

内存回收源码(二):并发标记与 Assist

前言

本文接第一篇中的 II. 并发标记阶段,展开 1)WorkergcBgMarkWorker、三种 gcDrainMarkWorker*)与 2)AssistgcAssistBytesgcAssistAlloc 链、gcDrainNgcParkAssist)。三色与写屏障见第三篇。


II. 并发标记阶段(续)

1)Worker

调度器通过 gcController.findRunnableGCWorker 唤醒每个 P 上的 gcBgMarkWorker。真正「吃根任务、从 gcWork 取址、scanobject」的活都由底层的 gcDrain 完成(详见「三色标记法」);三种 gcDrainMarkWorker* 只是给它套上不同的抢占与退出条件,好控制 CPU 占用和调度公平性。

runtime/mgc.go

type gcMarkWorkerMode int

const (
	gcMarkWorkerNotWorker gcMarkWorkerMode = iota

	// 这颗 P 专门跑标记,尽量把活干满 
	gcMarkWorkerDedicatedMode

	// 让 GC 标记工作占用 CPU 的比例严格等于 25%
	gcMarkWorkerFractionalMode

	// P 上没有普通 G 可跑时,用空档帮 GC.
	gcMarkWorkerIdleMode
)

Dedicated是只要 GC 没完,它就占死这个 CPU 核心,不被抢占。 Go GC 的设计目标是消耗全站 25% 的 CPU 预算。假设你的电脑是 6 核(GOMAXPROCS = 6),那么6 * 25% = 1.5 个核心。 如果开 1 个 Dedicated Worker(专职),只用了 1/6,干活慢了。如果开 2 个 Dedicated Worker(专职),用了 2/6,业务受影响了。 这时候,那个 “0.5” 的缺口,就由 Fractional 来补齐。

gcBgMarkWorker 在做什么

  1. 初始化:这个专门跑后台标记的 goroutine 已经完成自己的初始化,并通过 ready channel 通知「我可以进入后面的 gopark 等待被调度了」。
  2. 大循环:
    1. 本轮开始干活前:在真正把 G 挂起之前多做两件事:放还 M、把 node 还回池子。
    2. 记账 nwait:对 work.nwait 做原子减一,表示有一个 worker 从「闲着」变成「正在 drain」。
    3. 在 systemstack 上:把当前 G 切成可被挂起扫描的状态,再调用三种 gcDrainMarkWorker 之一。
    4. 收尾:对 nwait 原子加一,表示这一轮 drain 做完、槽位还回去。
    5. 若此时 nwait 已等于 nproc(大家都还完槽),且 gcMarkWorkAvailable 认为全局也没有更多标记活,就 releasem 并调用 gcMarkDone,进入分布式收尾。

摘录骨架(省略 trace、limiter 等,runtime/mgc.go):

func gcBgMarkWorker(ready chan struct{}) {
	// 第一步:初始化
	gp := getg()
	// ... 短暂 preemptoff,堆上 new node,node.gp / node.m ...
	// 标记 G 已经就绪
	ready <- struct{}{}

	// 第二步:大循环
	for {
		// 平时在 gopark 里睡眠,入睡前要执行的收尾
		gopark(func(g *g, nodep unsafe.Pointer) bool {
			node := (*gcBgMarkWorkerNode)(nodep)
			if mp := node.m.ptr(); mp != nil {
				releasem(mp)
			}
			gcBgMarkWorkerPool.push(&node.node)
			return true
		}, unsafe.Pointer(node), waitReasonGCWorkerIdle, traceBlockSystemGoroutine, 0)

		// ... 
		// 有一个 worker 从闲着变成正在 drain
		atomic.Xadd(&work.nwait, -1)
		systemstack(func() {
			// 切状态
			casGToWaitingForSuspendG(gp, _Grunning, waitReasonGCWorkerActive)
			// 调用对应的worker
			switch pp.gcMarkWorkerMode {
			case gcMarkWorkerDedicatedMode:
				gcDrainMarkWorkerDedicated(&pp.gcw, true)
				// 若 preempt:runqdrain → globrunqputbatch
				gcDrainMarkWorkerDedicated(&pp.gcw, false)
			case gcMarkWorkerFractionalMode:
				gcDrainMarkWorkerFractional(&pp.gcw)
			case gcMarkWorkerIdleMode:
				gcDrainMarkWorkerIdle(&pp.gcw)
			}
			casgstatus(gp, _Gwaiting, _Grunning)
		})

		// ...

		// 这一轮 drain 结束,这个 worker 还槽
		incnwait := atomic.Xadd(&work.nwait, +1)
		
		// ...
		
		// 从 worker 视角看,标记可以收摊了
		pp.gcMarkWorkerMode = gcMarkWorkerNotWorker
		if incnwait == work.nproc && !gcMarkWorkAvailable(nil) {
			releasem(node.m.ptr())
			node.m.set(nil)
			gcMarkDone()
		}
	}
}

2)Assist

如果说 Worker 是在按部就班地「打扫卫生」,那么 Assist(辅助回收) 就是一种强行的「交通管制」。

1. 为什么需要 Assist?

在并发标记阶段,Worker(后台工人)按 25% CPU 预算扫描。如果 Mutator(业务 Goroutine)分配太快,Worker 忙不过来,堆就会炸。 Assist 的本质:强制让“跑得快”的 G 停下来,把分配内存的时间用来做标记,实现谁污染谁治理。

2. 怎么运作?
  1. 账户

g 上有字段 gcAssistBytes:正数可分配额度,负数要先做标记还债。 bgScanCredit:后台标记线程已经「干完」、但还没被 assist 立刻领走的「扫描工作量余额」

  • 每轮标记前清零gcAssistBytes
  • gcAssistBytes变多:assist 或偷 bgScanCredit 会攒正数
  • bgScanCredit变多:G 退出时正余额可 flush 到全局 bgScanCredit。
// runtime/mgc.go — gcResetMarkState
forEachG(func(gp *g) {
	gp.gcscandone = false
	gp.gcAssistBytes = 0
})
// runtime/proc.go — goexit 片段
if gcBlackenEnabled != 0 && gp.gcAssistBytes > 0 {
	assistWorkPerByte := gcController.assistWorkPerByte.Load()
	scanCredit := int64(assistWorkPerByte * float64(gp.gcAssistBytes))
	gcController.bgScanCredit.Add(scanCredit)
	gp.gcAssistBytes = 0
}
  1. deductAssistCredit

分配记账,扣成负数就调 gcAssistAlloc。

// runtime/malloc.go
func deductAssistCredit(size uintptr) {
	assistG := getg()
	if assistG.m.curg != nil {
		assistG = assistG.m.curg
	}
	assistG.gcAssistBytes -= int64(size)
	if assistG.gcAssistBytes < 0 {
		gcAssistAlloc(assistG)
	}
}
  1. gcAssistAlloc
  • 把 gp.gcAssistBytes(协助额度,负的表示欠债)算成要完成的 scan work
  • 欠债很小时,会故意让你多干一点点活(gcOverAssistWork),否则「每次只还一点点」,进进出出 assist 本身的开销反而比干活还大。
  • 能蹭后台:偷 bgScanCredit 则只改账户不扫堆
  • 否则自己干: systemstack 进 gcAssistAlloc1。在系统栈上真的去扫一阵(gcDrainN 那种),把剩下的 scanWork 做掉
// runtime/mgcmark.go — gcAssistAlloc(节选)
func gcAssistAlloc(gp *g) {
	// ... 
retry:
	// ... 

	// 从 gcController 取「每分配 1 字节应对应多少 scan work」及「每单位 work 可折算多少字节额度」
	assistWorkPerByte := gcController.assistWorkPerByte.Load()
	assistBytesPerWork := gcController.assistBytesPerWork.Load()
	debtBytes := -gp.gcAssistBytes // 当前欠债(分配记账扣成负的绝对值)
	scanWork := int64(assistWorkPerByte * float64(debtBytes))

	// 过小则抬到 gcOverAssistWork,并反算 debtBytes:多 assist 一点,摊薄进 assist 的固定开销
	if scanWork < gcOverAssistWork {
		scanWork = gcOverAssistWork
		debtBytes = int64(assistBytesPerWork * float64(scanWork))
	}

	// 先用后台标记线程积累的 bgScanCredit「抵债」,只改账户,不一定真的扫堆
	bgScanCredit := gcController.bgScanCredit.Load()
	stolen := int64(0)
	if bgScanCredit > 0 {
		if bgScanCredit < scanWork {
			stolen = bgScanCredit
			gp.gcAssistBytes += 1 + int64(assistBytesPerWork*float64(stolen))
		} else {
			stolen = scanWork
			gp.gcAssistBytes += debtBytes
		}
		gcController.bgScanCredit.Add(-stolen)
		scanWork -= stolen
		if scanWork == 0 {
			return // 偷来的信用已覆盖本轮 scanWork,无需 gcAssistAlloc1
		}
	}

	// 剩余 scanWork 在系统栈上 gcDrainN;避免用户栈在标记过程中移动
	systemstack(func() {
		gcAssistAlloc1(gp, scanWork)
	})

	// gcAssistAlloc1 若让本轮 mark 全局收尾,会置 gp.param
	completed := gp.param != nil
	gp.param = nil
	if completed {
		gcMarkDone()
	}
	// 仍欠债:抢占点则让出后再 retry;否则进 assist 队列等后台 credit(或周期结束)
	if gp.gcAssistBytes < 0 {
		// 不立刻去排队睡觉,而是 Gosched() 主动让出
		if gp.preempt {
			Gosched()
			goto retry
		}
		if !gcParkAssist() {
			goto retry
		}
	}
	// ... trace GCMarkAssistDone ...
}

gcParkAssist()见下文

  1. gcAssistAlloc1 与 gcDrainN

在系统栈上 确认还在不在 blacken

  • gcBlackenEnabled != 0 :这轮 GC 还在并发标记涂色阶段
  • gcBlackenEnabled == 0 :这轮标记阶段已经结束(或未到标记段)

短暂把自己当成并发 marker 的一员 用 gcDrainN 做定额扫描 按 workDone 还额度 若刚好 全员收工且无剩余 work 则 置 gp.param 让外层 gcMarkDone

// runtime/mgcmark.go — gcAssistAlloc1(节选)
func gcAssistAlloc1(gp *g, scanWork int64) {
	gp.param = nil // 先清「本轮是否收尾整段 mark」标记;若下面置位,gcAssistAlloc 会 gcMarkDone
	if atomic.Load(&gcBlackenEnabled) == 0 {
		// 与 malloc 里对 gcBlackenEnabled 的判读竞态:在不可抢占的系统栈上再确认一次;标记阶段已结束则债一笔勾销
		gp.gcAssistBytes = 0
		return
	}
	// ... 

	// 参与「并发标记 worker」计数:暂离等待集合,gcDrainN 期间需可抢占
	decnwait := atomic.Xadd(&work.nwait, -1)
	casGToWaitingForSuspendG(gp, _Grunning, waitReasonGCAssistMarking)
	gcw := &getg().m.p.ptr().gcw
	workDone := gcDrainN(gcw, scanWork) // 在本 P 的 gcWork 上最多刷够 scanWork 的扫描量
	casgstatus(gp, _Gwaiting, _Grunning)

	// 按实际 workDone 折算成「分配额度」加回 gcAssistBytes;+1 为防 assistBytesPerWork 极小时加不出信用
	assistBytesPerWork := gcController.assistBytesPerWork.Load()
	gp.gcAssistBytes += 1 + int64(assistBytesPerWork*float64(workDone))
	incnwait := atomic.Xadd(&work.nwait, +1)
	// ... 
	// 若自己是「最后一个归队的 worker」且全局已没有更多 mark work,则通知外层:可走 gcMarkDone 收尾
	if incnwait == work.nproc && !gcMarkWorkAvailable(nil) {
		gp.param = unsafe.Pointer(gp) // 非 nil 即可,gcAssistAlloc 里据此调 gcMarkDone
	}
	// ... 
}

gcDrainN 消费本 P 上 gcWork 里的「待扫」队列(抽象上的对象/frontier),后台 marker 的 gcDrain 也是同类循环。三色不变式与写屏障为何能并发成立放在《内存回收源码(3)》。

  1. gcParkAssist

仍欠账且未因抢占让出时,进 assist 队列 gopark;周期结束或 bgScanCredit 来了会醒。

  • true:要么 mark 已结束不用睡,要么 睡完被正常唤醒,assist 队列这一程结束。
  • false:已入队但又发现 bgScanCredit > 0,撤销入队,让调用方 retry 去偷信用
// runtime/mgcmark.go — gcParkAssist(节选)
func gcParkAssist() bool {
	lock(&work.assistQueue.lock)
	if atomic.Load(&gcBlackenEnabled) == 0 {
		unlock(&work.assistQueue.lock)
		return true // GC 已结束,assist 无需再睡,上层可视为已还清
	}
	gp := getg()
	oldList := work.assistQueue.q
	work.assistQueue.q.pushBack(gp) // 先挂进队列
	// 入队后若已有 bgScanCredit,撤销入队并返回 false,让调用方 retry(与后台 flush 竞态)
	if gcController.bgScanCredit.Load() > 0 {
		work.assistQueue.q = oldList
		if oldList.tail != 0 {
			oldList.tail.ptr().schedlink.set(nil)
		}
		unlock(&work.assistQueue.lock)
		return false
	}
	goparkunlock(&work.assistQueue.lock, waitReasonGCAssistWait, traceBlockGCMarkAssist, 2)
	return true // 被唤醒:要么 credit 到了,要么周期结束
}

链:malloc 扣 gcAssistBytes 到 gcAssistAlloc 算账与偷信用,到 gcAssistAlloc1 调 gcDrainN,不够则 gcParkAssist。