Go 内存回收(4):GC 源码-清扫

3 阅读8分钟

清扫与归还

1. sweep 在干什么

标记结束时仍未被标到的堆对象当作本轮垃圾;清扫阶段把这些对象占的槽位回收给分配器,必要时整 span 归还,并清好 GC 位图,保证下一轮标记前堆里状态一致。此时 gcphase 已是 _GCoff,写屏障关闭,新对象又是「白」的起点。

2. runtime 里两类 reclaim(文件头注释)

mcentral:按对象大小类挂链表的「中心仓库」,分配器从这儿 cacheSpan 取 mspan。sweepone:异步从各 mcentral 的待扫链里揪一个 span 清一遍。

先把「清扫时机」放在前面看,会更容易理解后面的源码:

  • 后台清扫bgsweep 线程会循环调用 sweepone,持续吃全局未扫队列。
  • GC 启动前补尾巴gcStart 里在真正 STW 前会 for trigger.test() && sweepone()...,尽量把上一轮没扫完的尾巴补掉。
  • 分配路径同步清扫(懒惰清扫)mcentral.cacheSpan 取货时,可能直接对 unswept span 做 s.sweep(true);谁急着要 span,谁顺手先扫。
  • 分配前按页补回收mheap.alloc 里如果 !isSweepDone() 会先 h.reclaim(npages),避免一边还有大量未扫页,一边继续猛涨堆。

一句话:清扫不是只在一个后台点发生,而是后台持续扫 + 前台按需帮扫 + 新一轮前补扫尾巴三条线并行。

runtime/mgcsweep.go 开头概括了分工:

// The sweeper consists of two different algorithms:
//
// * The object reclaimer finds and frees unmarked slots in spans. It
//   can free a whole span if none of the objects are marked, but that
//   isn't its goal. This can be driven either synchronously by
//   mcentral.cacheSpan for mcentral spans, or asynchronously by
//   sweepone, which looks at all the mcentral lists.
//
// * The span reclaimer looks for spans that contain no marked objects
//   and frees whole spans. This is a separate algorithm because
//   freeing whole spans is the hardest task for the object reclaimer,
//   but is critical when allocating new spans. The entry point for
//   this is mheap_.reclaim and it's driven by a sequential scan of
//   the page marks bitmap in the heap arenas.
//
// Both algorithms ultimately call mspan.sweep, which sweeps a single
// heap span.

译文:

清扫器由两种不同算法组成:

  • 对象回收器:在 span 里查找并释放未被标记的槽位。若某个 span 里对象都未标记,也可以释放整段 span,但那不是它的主要目标。对 mcentral 管理的 span,它可以由 mcentral.cacheSpan 同步驱动;也可以由 sweepone 异步驱动,后者会查看所有 mcentral 链表。

  • span 回收器:寻找没有任何被标记对象的 span,并整段释放这些 span。单独做成一套算法,是因为「释放整 span」对对象回收器来说是最难的一类工作,但在需要分配新 span时又非常关键。入口是 mheap_.reclaim,通过顺序扫描堆 arena 中的 page marks 位图来驱动。

两种算法最终都会调用 mspan.sweep,即清扫单个堆 span。

一句话:按 span 扫未标槽、按堆元数据找可扔的整 span,最后都落到 mspan.sweep。

3. sweepone:异步地扫一个 span

后台 bgsweep 和 gcStart 里 for 循环的尾巴都会反复调用 sweepone。它从 central 的「未扫」列表里拿 span,加锁后 sweep,返回本轮释放的页数或 ^uintptr(0) 表示暂时没有要扫的(runtime/mgcsweep.go):

// sweepone sweeps some unswept heap span and returns the number of pages returned
// to the heap, or ^uintptr(0) if there was nothing to sweep.
func sweepone() uintptr {
	// ...
	for {
		s := mheap_.nextSpanForSweep()
		// 当前这一轮里已经没有任何待扫的 span
		if s == nil {
			// 标记「清扫工作基本耗尽」
			noMoreWork = sweep.active.markDrained()
			break
		}
		// ...
		if s, ok := sl.tryAcquire(s); ok {
			npages = s.npages
			if s.sweep(false) {
				mheap_.reclaimCredit.Add(npages)
			} else {
				npages = 0
			}
			break
		}
	}
	// ...
}

// nextSpanForSweep:从各尺寸档位的 mcentral 里「未扫」链表中 pop 一个 span,交给 sweepone;没有则返回 nil。
// numSweepClasses = 每种 spanClass 对应两条扫表索引(full / partial 未扫链表),见 sweepClass.split。
func (h *mheap) nextSpanForSweep() *mspan {
	sg := h.sweepgen // 当前代 sweepgen,未扫链表按代区分
	// centralIndex:全局游标,记录上次找到 span 的位置;从此处往后扫,减少每次都从 0 空转。
	for sc := sweep.centralIndex.load(); sc < numSweepClasses; sc++ {
		spc, full := sc.split() // spc=尺寸档;full=true 从「满」未扫链取,false 从「不满」未扫链取
		c := &h.central[spc].mcentral
		var s *mspan
		if full {
			s = c.fullUnswept(sg).pop()
		} else {
			s = c.partialUnswept(sg).pop()
		}
		if s != nil {
			// 记下当前 sc,后续 sweeper 可从附近接着找(索引单调前移)
			sweep.centralIndex.update(sc)
			return s
		}
		// 本档本条链表空,sc++ 试下一档或另一条 full/partial 组合
	}
	// 所有组合都空:标记扫表走完
	sweep.centralIndex.update(sweepClassDone)
	return nil
}

4. (*sweepLocked).sweep:按标记位回收槽位(runtime/mgcsweep.go)

nextSpanForSweep 只负责取出待扫 span;「哪些槽是本轮垃圾」在这里处理:allocBits 表示哪些槽在本轮已分配,gcmarkBits(经 markBitsForIndex)表示是否被标活;未标记且确为已分配的对象会释放,最后 gcmarkBits 与 allocBits 交换角色,为下一轮标记清好位图。

// preserve 表示:扫完这个 span 之后,要不要由 sweep 自己负责「挂回 mcentral / 还给堆」。
func (sl *sweepLocked) sweep(preserve bool) bool {
	// --- 前置:须在不可抢占上下文;取出 mspan;preserve 时保留 sl.mspan 供调用方 ---
	...
	s := sl.mspan
	if !preserve {
		sl.mspan = nil
	}
	sweepgen := mheap_.sweepgen
	...

	// 1. Specials(finalizer / weak / reachable 等)
	// 未标记:有 finalizer → 复活 mark + freeSpecial 入队;无 finalizer → 清掉 specials。
	// 已标记:specialReachable → 设 reachable + freeSpecial;其它 kind 保留链表节点。
	hadSpecials := s.specials != nil
	siter := newSpecialsIter(s)
	for siter.valid() {
		...
	}
	if hadSpecials && s.specials == nil {
		spanHasNoSpecials(s)
	}

	...

	// 2. Zombie:gcmark &^ alloc 非 0 → alloc 认为 free 但 mark 仍标存活 → reportZombies
	if s.freeindex < s.nelems {
		obj := uintptr(s.freeindex)
		if (*s.gcmarkBits.bytep(obj / 8)&^*s.allocBits.bytep(obj / 8))>>(obj%8) != 0 {
			s.reportZombies()
		}
		// Check remaining bytes.
		for i := obj/8 + 1; i < divRoundUp(uintptr(s.nelems), 8); i++ {
			if *s.gcmarkBits.bytep(i)&^*s.allocBits.bytep(i) != 0 {
				s.reportZombies()
			}
		}
	}

	// 3. 统计空闲对象、更新 span 元数据
	nalloc := uint16(s.countAlloc())
	nfreed := s.allocCount - nalloc
	if nalloc > s.allocCount {
		throw("sweep increased allocation count")
	}
	s.allocCount = nalloc
	s.freeindex = 0
	s.freeIndexForScan = 0

	// 4. 位图交接 + sweepgen
	// 旧 gcmarkBits → 新 allocBits;新分配全 0 的 gcmarkBits 给下一轮标记。
	s.allocBits = s.gcmarkBits
	s.gcmarkBits = newMarkBits(uintptr(s.nelems))
	if s.pinnerBits != nil {
		s.refreshPinnerBits()
	}
	s.refillAllocCache(0)

	// 再次校验 span 状态未被并发破坏
	...
	atomic.Store(&s.sweepgen, sweepgen)

	// 5. 小对象 / 大对象 / user arena / preserve
	// user arena 跟着 arena 自己的生命周期(隔离区、回收、复用)
	if s.isUserArenaChunk {
		// arena chunk 不允许 preserve:不能仿 cacheSpan 扫完再交给调用方挂链
		if preserve {
			throw("sweep: tried to preserve a user arena span")
		}
		if nalloc > 0 {
			// chunk 里仍有对象:还不能回收整块,先挂 fullSwept 等后续周期
			mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
			return false // 非普通 freeSpan,返回 false
		}
		// nalloc==0:chunk 已空,可交给 arena 运行时回收;不再走 mcentral 常规路径
		mheap_.pagesInUse.Add(-s.npages) // 统计上不再占用这些页
		s.state.set(mSpanDead)
		systemstack(func() {
			// quarantineList → readyList,供 arena 复用,不进普通 sweep 链
			...
		})
		return false
	}

	if spc.sizeclass() != 0 {
		// 小对象 span
		if nfreed > 0 {
			s.needzero = 1 // 释放过槽位,后续分配可能需清零
			// memstats / gcController.totalFree ...
			...
		}
		if !preserve {
			// preserve==false:由本函数负责挂链或还堆
			if nalloc == 0 {
				// 槽全空:整段还给堆(sweepone 里对应 reclaimCredit 那类)
				mheap_.freeSpan(s)
				return true
			}
			if nalloc == s.nelems {
				// 全满,无空闲槽
				mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
			} else {
				// 仍有空闲槽
				mheap_.central[spc].mcentral.partialSwept(sweepgen).push(s)
			}
		}
		// preserve==true:调用方(如 cacheSpan)自己拿 span 去分配或再 push
	} else if !preserve {
		// 大对象 span(sizeclass==0);preserve 时同样不在这里挂链
		if nfreed != 0 {
			// 大对象已死:largeFree 统计;efence 时 sysFault 否则 freeSpan
			...
			return true
		}
		// 大对象仍活:整段还在用,挂 fullSwept
		mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
	}
	return false // 未整段 freeSpan 还给堆,或仅挂回 swept 链
}
  1. 参数preserve
  • preserve == true:不要在 sweep 里把 span freeSpan 还给堆,也不要按惯例 fullSwept / partialSwept 挂链——调用方会接着处理这个 span。
  • preserve == false:正常清扫路径(例如 sweepone),sweep 里要根据 nalloc / nfreed 自己决定 freeSpan 或 推到哪个 swept 链表。
  1. Specials(finalizer 等) 遍历 specials:对 未标记 的对象,若有 finalizer 则 复活标记 并入队 finalizer;否则 freeSpecial 清掉各类 special。 已标记 的对象上处理 specialReachable 等。

**finalizer(终结器)**指的是:你给某个堆对象绑定的、在它被 GC 真正回收之前(对象已经「不可达」之后)由 runtime 异步调用一次的函数。

在扫 span 时,先把 finalizer / weak / reachable 等特殊语义处理完,避免和后面的批量释放逻辑打架

  1. Zombie 检查 检查 「已 free 但 mark 位仍标成存活」 的不一致,有问题则 reportZombies。

  2. 统计空闲对象、更新 span 元数据 countAlloc() 得到当前存活数,更新 allocCount、freeindex,统计 nfreed。

  3. 位图交接(核心)

当前 gcmarkBits 变成 allocBits(表示哪些 slot 已占用); 分配新的 gcmarkBits 供 下一轮 标记使用。 也就是说:上一轮 GC 的 mark 位图,清扫后变成 分配器用的 alloc 位图;新的 mark 位图 全清等待下次 GC。

  1. 小对象 / 大对象 / user arena / preserve 分支
  • 小对象:needzero、统计 free;若 !preserve 且 nalloc==0 则 mheap_.freeSpan(返回 true);否则 fullSwept / partialSwept。
  • 大对象:若释放了大对象则 freeSpan(返回 true);否则 fullSwept。
  • User arena:有单独分支(quarantine/ready 等)。

分配路径在 mcentral.cacheSpan 等函数里也会同步扫、保证用到的 span 已是 swept 代,这就是懒惰清扫:不全靠后台 goroutine,谁要领内存谁可能先帮忙扫。

5. 比例清扫与「下一轮」

控制器按 GOGC 等算「这一轮该回收多少、下一轮何时再涨 trigger」,清扫进度与分配挂钩(比例 sweep),避免堆上攒大量未扫 span 还去要新内存。gcStart 里在进入新一轮 STW 前 for trigger.test() && sweepone() ...,是在补强制 GC 或误差下还没扫完的尾巴,和本章「阶段 I」叙述一致。

下面为 src/runtime 中的对应摘录。

GOGC 与「下一轮」堆目标runtime/mgc.go 文件顶部注释;具体 heapGoal 等在 gcControllerruntime/mgcpacer.go):

// GC rate.
// Next GC is after we've allocated an extra amount of memory proportional to
// the amount already in use. The proportion is controlled by GOGC environment variable
// (100 by default). If GOGC=100 and we're using 4M, we'll GC again when we get to 8M
// (this mark is computed by the gcController.heapGoal method). This keeps the GC cost in
// linear proportion to the allocation cost. Adjusting GOGC just changes the linear constant
// (and also the amount of extra memory used).

分配 n 页前先 reclaim(比例清扫与分配挂钩)runtime/mheap.go(*mheap).alloc):

func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
	var s *mspan
	systemstack(func() {
		// To prevent excessive heap growth, before allocating n pages
		// we need to sweep and reclaim at least n pages.
		if !isSweepDone() {
			h.reclaim(npages)
		}
		s = h.allocSpan(npages, spanAllocHeap, spanclass)
	})
	return s
}

gcStart:进入本轮 GC、真正 STW 之前,先把剩余未扫 span 尽量扫掉runtime/mgc.gogcStart):

	// Pick up the remaining unswept/not being swept spans concurrently
	//
	// This shouldn't happen if we're being invoked in background
	// mode since proportional sweep should have just finished
	// sweeping everything, but rounding errors, etc, may leave a
	// few spans unswept. In forced mode, this is necessary since
	// GC can be forced at any point in the sweeping cycle.
	//
	// We check the transition condition continuously here in case
	// this G gets delayed in to the next GC cycle.
	for trigger.test() && sweepone() != ^uintptr(0) {
	}

6. 小结

标记决定谁该活;清扫把死对象的空间还回去;_GCoff 下并发 sweep 与 mutator 并行,直到全局未扫队列耗尽,周期在逻辑上收尾,等待下次触发再进 gcStart。