本文将从宏观方面尽可能用白话阐述golang的GC机制,描述GC的整体流程与一些重要的细节,尽量不深究具体实现,为读者节省研读源码的时间。

1. 并发垃圾回收

1.1 三色标记

golang采用标记-清除的垃圾回收算法,标记过程中会将对象抽象为三色:

  • 黑色:已扫描完成的存活对象
  • 灰色:待扫描的存活对象
  • 白色:未扫描的对象,可能是存活的也可能是垃圾

垃圾收集器开始工作后从根对象开始扫描,将正在扫描的对象引用的对象作为待扫描的对象放到工作池中,工作池中都是所谓的灰色对象,然后垃圾收集器从工作池中不断pop待扫描的对象进行扫描,直到工作池中没有待扫描的对象。

1.2 混合写屏障

为了保证并发正确性,go还引入了混合写屏障技术,往代码中插入类似如下的伪代码:

writePointer(slot, ptr):
    shade(*slot) // 原对象标灰,实际就是扔到工作池中
    shade(ptr) // 新对象标灰
    *slot = ptr

2. GC的触发时机

对于GC来说我们首先必须要知道的是GC何时触发。GC的触发时机有三种,分别是

2.1 距离上次gc已过去2分钟

这里的2分钟是runtime中的变量,目前没有看到有什么命令行参数可以去修改该值,应该仅是用来方便做runtime的单元测试使用的。

golang有个系统监控线程:sysmon,在该线程中会检测检测距离上次gc的时间,然后唤醒runtime.forcegchelper()这个Goroutine去调用runtime.gcStart()开启gc。

2.2 当前堆内存相较上次gc后增长100%时

这里这个100%可以由环境变量GOGC控制,默认是100。

golang中所有堆内存都是通过runtime.mallocgc()分配的,在此函数中会判断当前堆内存增长是否超过这个限制,超过这个限制后会调用runtime.gcStart()开启gc。

需要注意并不是每次申请内存mallocgc()都会进行这个判断,而是遵循如下逻辑:

  • 申请超过32K的对象时一定进行判断
  • 申请小于32K的对象时如果当前线程缓存mcache中没有可用内存需要从中心缓存mcentral或者页堆mheap中申请内存时会进行判断

如果看源码的话你会看到并不是增长达到100%时才触发,而是golang中有一套调步算法,会根据当前内存分配情况来动态调整触发值以尽量达到“增长100%时触发”这个目标。

2.3 手动触发

可以调用runtime.GC()强制进行GC,该函数会阻塞到GC完成才返回。

3. GC的启动

了解了GC的触发时机后下一步我们需要探究的就是GC的过程是怎样的,会对我们的用户程序造成什么影响。

从上面我们可以知道GC实际是通过runtime.gcStart()启动的,这个函数大概流程如下:

func gcStart(trigger gcTrigger) {
	...
	// 开启后台标记worker协程
	gcBgMarkStartWorkers()

	// STW
	systemstack(stopTheWorldWithSema)
	
	// 清理sync.Pool
	clearpools()
	
	// 禁用用户协程的调度
	schedEnableUser(false)

	// GC阶段修改为_GCmark,并开启写屏障
	setGCPhase(_GCmark)

	// 标记线程缓存mcache中的tiny alloc
	gcMarkTinyAllocs()

	// 修改全局变量
	atomic.Store(&gcBlackenEnabled, 1)

	// 关闭STW
	now = startTheWorldWithSema(trace.enabled)
	...
}

3.1 开启后台标记worker协程

gcBgMarkStartWorkers()函数中会为每个P创建一个用于执行标记任务的协程,协程创建后都会调用runtime.gopark()将自己挂起等待被调度器唤醒。

3.2 STW

stopTheWorldWithSema中会先修改全局变量sched.gcwaiting = 1,然后遍历所有P,向每个P绑定的M发抢占信号。M收到抢占信号后,会先判断是否能够安全抢占,如果能则调用runtime.schedule()重新开始调度,schedule()中判断sched.gcwaiting等于1会挂起M等待唤醒,这样所有的用户程序就都暂停执行了。

那么STW中如何确认所有P都暂停了呢?答案是STW中如有如下的代码等待所有P暂停。

for {
	// 等待100us,当P响应抢占信号后判断所有P都暂停时会调用notewakeup唤醒
	if notetsleep(&sched.stopnote, 100*1000) {
		noteclear(&sched.stopnote)
			break
	}
	// 再次抢占
	preemptall()
}

go1.14之前是协作时调度,因此不一定能及时响应抢占,这可能导致STW耗时很长。

4. 标记

当STW关闭后(即start the world后) 调度器就可以调度 “2.2 开启后台标记worker协程”一节中开启的后台标记协程了,那么后台标记协程是如何被调度执行的呢?

调度器runtime.schedule()中会按照如下逻辑调度后台标记协程:

// runtime/proc.go
func schedule() {
	...
	var gp *g
   	...
    	// 如果开启gc则通过gcController尝试获取P上的标记协程
    	if gp == nil && gcBlackenEnabled != 0 {
		gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
		tryWakeP = tryWakeP || gp != nil
	}
	...
	if gp == nil {
		if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
			lock(&sched.lock)
			// 从全局队列获取P
			gp = globrunqget(_g_.m.p.ptr(), 1)
			unlock(&sched.lock)
		}
	}
	if gp == nil {
		// 从本地队列获取P
		gp, inheritTime = runqget(_g_.m.p.ptr())
	}
	if gp == nil {
		// 当垃圾处理器处于标记阶段
		// 并且当前处理器不需要做任何任务时
		// findrunable会返回后台标记协程
		gp, inheritTime = findrunnable()
	}
	...
	// 执行gp
}

用于并发标记对象的工作协程有三种不同的工作模式runtime.gcMarkWorkerMode,这三种模式的Goroutine标记工作时采用不同的策略,垃圾收集控制器会按照需求执行不同的工作协程。

  • gcMarkWorkerDedicatedMode: P专门负责标记对象,不会被调度走
  • gcMarkWorkerFractionalMode: 当垃圾收集的后台CPU使用率达不到目标(默认为25%)启动该类型的工作协程帮助垃圾收集达到目标使用率,可以被调度。
  • gcMarkWorkerIdleMode: 当P上没有可运行的G时,它会执行垃圾收集标记任务

runtime.gcControllerState.findRunnabledGCWorker方法会计算得出Worker的mode。

4.1 辅助标记

因为用户程序与后台标记程序是并发执行的,这就有可能用户程序分配内存的速度大于标记速度,出现这种情况就非常尴尬了😓。

为了防止这种情况的发生,runtime引入了辅助标记。它遵循原则:“分配多少内存就需要标记多少内存”。mallocgc函数中会检查申请内存的协程是否入不敷出,如果是则将当前协程陷入休眠、加入全局的辅助标记队列并等待后台标记任务的唤醒。

4.2 标记是如何进行的

实际上标记任务都是调用runtime.gcDrain()函数进行标记的,在该函数中会先扫描根对象(数据段、BSS段以及协程的栈等)。扫描过程中会产生灰色对象,这些灰色对象都会被放入到工作池中,并且写屏障也会向工作池中添加灰色对象

根对象扫描结束后会不断从工作池中选出待扫描的灰色对象,这个过程又可能产生新的灰色对象,标记扫描器就不停重复这个动作直到工作池中没有待扫描的灰色对象为止。

另外内存分配mallocgc()函数中会判断如果当前GC阶段不是_GCoff会将新分配的对象直接标记为黑色

4.3 标记终止

当所有P的本地任务完成,且不存在剩余的工作协程后,后台标记程序或者辅助标记程序会调用runtime.gcMarkDone()转入标记终止阶段,该函数核心逻辑如下:

func gcMarkDone() {
	...
	systemstack(stopTheWorldWithSema)
	...
	// 禁止辅助gc和后台标记任务运行
	atomic.Store(&gcBlackenEnabled, 0)
    	// 唤醒所有因辅助gc休眠的G
	gcWakeAllAssists()
	...
	// 恢复用户协程的调度
	schedEnableUser(true)
	
	nextTriggerRatio := gcController.endCycle()
	// 进入标记终止
	gcMarkTermination(nextTriggerRatio)

gcMarkTermination中大概会做如下工作

func gcMarkTermination(nextTriggerRatio float64) {
	// 修改当前GC阶段为_GCmarktermination
	setGCPhase(_GCmarktermination)
	...
	// 开始STW中的标记
	gcMark(startTime)
	...
	// 设置当前阶段为_GCoff并禁用写屏障
	setGCPhase(_GCoff)
	// 唤醒后台清扫任务
	gcSweep(work.mode)
	...
	// 关闭STW
 	startTheWorldWithSema(true)
	...

可以看到在标记终止阶段还有STW的过程。

5. 清理

标记结束后会调用gcSweep()清理内存,核心逻辑如下:

func gcSweep(mode gcMode) {
	if sweep.parked {
		sweep.parked = false
		// 唤醒后台清扫任务
		ready(sweep.g, 0, true)
	}
}

可以看到gcSweep是通过唤醒sweep.g来执行清扫任务的,sweep.g是在初始化主协程时调用bgsweep()设置的:

// runtime/proc.go
func main() {
    ...
    gcenable()
    ...
}

// runtime/mgc.go
func gcenable() {
	c := make(chan int, 2)
	go bgsweep(c)
	go bgscavenge(c)
	<-c
	<-c
	memstats.enablegc = true
}

// runtime/mgcsweep.go
func bgsweep(c chan int) {
    	// 设置sweep.g
	sweep.g = getg()
	lockInit(&sweep.lock, lockRankSweep)
	lock(&sweep.lock)
	sweep.parked = true
	c <- 1
	goparkunlock(&sweep.lock, waitReasonGCSweepWait, traceEvGoBlock, 1)
    	// 循环清扫
	for {
		for sweepone() != ^uintptr(0) { // 清扫一个span
			sweep.nbgsweep++
			Gosched() // 进入调度
		}
		for freeSomeWbufs(true) { // 释放一些未使用的标记队列缓存到heap
			Gosched()
		}
		lock(&sweep.lock)
		if !isSweepDone() { // 判断sweepdone标记位是否等于0
			unlock(&sweep.lock)
			continue // 如果未清扫完成则继续清扫
		}
		// 否则让后台清扫任务进入休眠
		sweep.parked = true
		goparkunlock(&sweep.lock, waitReasonGCSweepWait, traceEvGoBlock, 1)
	}
}