本文基于 Go 1.20 源码分析,旨在从全局视角出发,将这些知识点结合源码串联起来,回顾Go GC从触发到完成的核心流程,并梳理三色标记法、混合写屏障及辅助标记等关键技术在其中的协同作用,帮助构建一个完整、连贯的知识体系。
一、设计原理
通常来说,用户程序(Mutator)会通过内存分配器(Allocator)在堆(Heap)上申请内存,然后垃圾收集器(Collector)负责回收堆上的内存空间,垃圾收集器和内存分配器共同管理堆上的内存,如下图:
一般来说,随着用户程序申请内存越来越多,系统中的垃圾也会越来越多,当程序的内存占用达到一定阈值时,整个用户程序就会暂停(Stop The World),垃圾收集器会扫描所有已经分配的对象并回收不再使用的内存空间,当这个过程结束后,会再次恢复用户程序的运行。这也是go早期的垃圾回收策略,随着版本迭代更新,现在的垃圾回收策略已比较复杂,主要包含以下几个方面:
1.1 标记清除
标记清除算法是最常见的垃圾清除算法,其执行过程可以分为标记和清除两个阶段:
标记阶段:从根对象出发并标记堆中的存活对象。
清除阶段:遍历堆中所有对象,回收未被标记的垃圾对象,并将回收的内存放回到空闲链表。
如下图所示,内存空间中包含多个对象,我们从根对象出发一次遍历对象的子对象并将从根节点可达的对象都标记成存活状态,如图中的A、C、D对象,剩余的B、E、F因为从根节点不可达,会被认为是垃圾对象。
标记阶段结束后,进入清除阶段,此阶段会一次遍历堆中的对象,释放未被标记的B、E、F对象,然后用链表把清除后的空间连接起来,以供后续内存分配使用。
以上是传统的标记清除垃圾回收算法,在此过程中用户程序需要暂停,直到清除结束,会对应用程序性能有较大影响,因此需要更复杂的机制来解决。
1.2 三色标记法
为了解决原始标记清除算法带来的长时间STW问题,多数现代的追踪式垃圾回收器会实现三色标记算法的变种以缩短STW时间(因为用户程序可能在标记过程中修改对象的指针,所以三色标记法本身是不可以并发或增量执行的,它仍然需要STW。),三色标记算法会把程序中的对象分为黑色、灰色、白色三种对象:
黑色对象:活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
灰色对象:活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;
白色对象:潜在的垃圾,其内存可能会被垃圾收集器回收;
了解基本概念后,可以再看一下三色标记法的执行过程,过程如下:
垃圾收集器刚开始工作时,程序中不存在任何黑色对象,垃圾收集的根对象会被标记为灰色
从灰色对象中选取一个并标记为黑色
将黑色对象指向的所有对象都标记为灰色,保证该对象和被该对象引用的对象不被回收
重复2和3的步骤,直到不存在灰色对象
当三色标记过程结束后,应用程序的堆中就只包含黑色的活跃对象和白色的垃圾对象,垃圾收集器可以回收白色对象。
如果在垃圾收集过程中,用户程序有修改对象的指针,那么就可能导致本不该回收的对象被回收,导致悬挂指针,即指针没有指向特定类型的合法对象,会影响内存安全性,想要并发或增量标记对象就需要使用屏障技术。
想要在并发或增量的标记算法中保证正确性,我们需要达成以下两种三色不变性中的两种中的一种:
强三色不变性:黑色对象不会指向白色对象,只会指向黑色对象或灰色对象
弱三色不变性:黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径
正常按照三色标记过程,黑色对象指向的对象会变成灰色,所以不会出现直接指向白色,如果没有一个灰色对象指向白色对象,那么白色对象就会一直维持白色状态,最后会被回收
遵循上述两个不变性中的任意一个,都可以保证垃圾收集算法的正确性,而屏障技术就是在并发或增量标记过程中保证三色不变性的重要技术。
1.3 混合写屏障
屏障技术是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性。
- 插入写屏障:每当执行类似
*slot = ptr
的表达式时,我们会执行上述写屏障通过shade
函数尝试改变指针的颜色。如果ptr
指针是白色的,那么该函数会将该对象设置成灰色,其他情况则保持不变。
writePointer(slot, ptr):
shade(ptr)
*slot = ptr
- 删除写屏障:在老对象的引用被删除时,将白色的老对象涂成灰色,这样删除写屏障就可以保证弱三色不变性,老对象引用的下游对象一定可以被灰色对象引用。
writePointer(slot, ptr):
shade(*slot)
*slot = ptr
Go 语言在 v1.8 组合 Dijkstra 插入写屏障和 Yuasa 删除写屏障构成了如下所示的混合写屏障,该写屏障会将被覆盖的对象标记成灰色并在当前栈没有扫描时将新对象也标记成灰色:
writePointer(slot, ptr):
shade(*slot)
if current stack is grey:
shade(ptr)
*slot = ptr
1.4 增量和并发
传统的垃圾收集算法会在垃圾收集阶段暂停用户程序,一旦触发垃圾收集,垃圾收集器会抢占CPU的使用权,同时占据大量计算资源以完成标记和清除工作,然而很多追求实时的服务无法忍受长时间的STW。
为了减少程序暂停的的最长时间和垃圾收集的总暂停时间,我们会采用下面的策略来进行优化:
增量垃圾收集:增量地标记和清除垃圾,降低应用程序暂停的最长时间
并发垃圾收集:利用多核计算资源,在用户程序执行时并发标记和清除垃圾
以上两种方式都可以与用户程序交替运行,所以就需要用屏障技术来保证垃圾收集的正确性;同时垃圾收集器也不能等到内存溢出时才触发垃圾收集,必须提前触发并在内存不足前完成整个循环,避免程序长时间暂停。
增量垃圾收集
增量式(Incremental)的垃圾收集是减少程序最长暂停时间的一种方案,它可以将原本时间较长的暂停时间切分成多个更小的 GC 时间片,虽然从垃圾收集开始到结束的时间更长了,但是这也减少了应用程序暂停的最大时间:
需要注意的是,增量式的垃圾收集需要与三色标记法一起使用,为了保证垃圾收集的正确性,我们需要在垃圾收集开始前打开写屏障,这样用户程序修改内存都会先经过写屏障的处理,保证了堆内存中对象关系的强三色不变性或者弱三色不变性。虽然增量式的垃圾收集能够减少最大的程序暂停时间,但是增量式收集也会增加一次 GC 循环的总时间,在垃圾收集期间,因为写屏障的影响用户程序也需要承担额外的计算开销,所以增量式的垃圾收集也不是只带来好处的,但是总体来说还是利大于弊。
并发垃圾收集
并发(Concurrent)的垃圾收集不仅能够减少程序的最长暂停时间,还能减少整个垃圾收集阶段的时间,通过开启读写屏障、利用多核优势与用户程序并行执行,并发垃圾收集器确实能够减少垃圾收集对应用程序的影响:
虽然并发收集器能够与用户程序一起运行,但是并不是所有阶段都可以与用户程序一起运行,部分阶段还是需要暂停用户程序的,不过与传统的算法相比,并发的垃圾收集可以将能够并发执行的工作尽量并发执行;当然,因为读写屏障的引入,并发的垃圾收集器也一定会带来额外开销,不仅会增加垃圾收集的总时间,还会影响用户程序,这是我们在设计垃圾收集策略时必须要注意的。
二、源码解析
2.1 入口
2.1.1 后台触发
程序启动的时候会在后台启动一个goroutine,用于强制触发垃圾回收;
// runtime/proc.go
// start forcegc helper goroutine
func init() {
go forcegchelper()
}
func forcegchelper() {
forcegc.g = getg()
lockInit(&forcegc.lock, ***lockRankForcegc***)
for {
lock(&forcegc.lock)
if forcegc.idle.Load() {
throw("forcegc: phase error")
}
forcegc.idle.Store(true)
// 为了减少对计算资源的占用,这里会调用goparkunlock主动陷入休眠等待其他goroutine唤醒
goparkunlock(&forcegc.lock, ***waitReasonForceGCIdle***, ***traceEvGoBlock***, 1)
// this goroutine is explicitly resumed by sysmon
if debug.gctrace > 0 {
println("GC forced")
}
// ***gcTriggerTime表示***由时间触发垃圾回收,默认两次GC的时间间隔是2min,可在gcTrigger.test()中查看逻辑
gcStart(gcTrigger{kind: ***gcTriggerTime***, now: nanotime()})
}
}
forcegchelper
在大多数情况下都是处于休眠状态,只有在系统监控器发现满足垃圾回收条件的时候唤醒,监控器会在每次循环的时候构建一个gcTriggerTime类型的gcTrigger,当满足GC条件的时候会把forcegc
持有的goroutine放入调度队列中等待调度
func sysmon() {
...
for {
// check if we need to force a GC
if t := (gcTrigger{kind: ***gcTriggerTime***, now: now}); t.test() && forcegc.idle.Load() {
lock(&forcegc.lock)
forcegc.idle.Store(false)
var list gList
list.push(forcegc.g)
// 放入调度队列
injectglist(&list)
unlock(&forcegc.lock)
}
}
}
injectglist
用来把goroutine放入到调度队列中:如果当前没有正在运行的P对象,则直接放入全局队列; 如果有正在运行的P对象且仍有空闲的P对象,则取出和P对象数量相同的goroutine放入全局队列【GMP调度模型如果有空闲的P会从全局队列中获取可运行的goroutine】,剩下的放到当前P的本地队列;
injectglist
函数核心属于调度器范畴,不在GC范围内,感兴趣的了解即可
func injectglist(glist *gList) {
...
// 遍历gList中的goroutine,找到链表的尾节点,同时把gList中的goroutine状态从Gwaiting改成Grunnable
head := glist.head.ptr()
var tail *g
qsize := 0
for gp := head; gp != nil; gp = gp.schedlink.ptr() {
tail = gp
qsize++
casgstatus(gp, ***_Gwaiting***, ***_Grunnable***)
}
// 把gList转位gQueue结构
var q gQueue
q.head.set(head)
q.tail.set(tail)
*glist = gList{}
// 匿名函数,用于启动n个空闲的M,即系统线程,来运行P对象
startIdle := func(n int) {
for i := 0; i < n; i++ {
// 获取M
mp := acquirem() // See comment in startm.
lock(&sched.lock)
// 获取空闲的P对象
pp, _ := pidlegetSpinning(0)
// 如果没有空闲的P对象,则直接返回
if pp == nil {
unlock(&sched.lock)
releasem(mp)
break
}
// 如果有空闲的P对象,则启动M来运行P
startm(pp, false, true)
unlock(&sched.lock)
releasem(mp)
}
}
// 获取当前goroutine绑定的P对象
pp := getg().m.p.ptr()
// 如果当前goroutine没有绑定在一个P对象上,则直接把gQueue中的goroutine放到全局队列中
if pp == nil {
lock(&sched.lock)
globrunqputbatch(&q, int32(qsize))
unlock(&sched.lock)
startIdle(qsize)
return
}
// 如果当前goroutine已经绑定在一个P对象上,则获取当前有多少个空闲的P对象(别闲着了,每个P去运行一个goroutine)
npidle := int(sched.npidle.Load())
var globq gQueue
var n int
// 从gQueue中取出和空闲P对象相同数量的goroutine放入到globq
for n = 0; n < npidle && !q.empty(); n++ {
g := q.pop()
globq.pushBack(g)
}
// globq中放入n个goroutine,把globq放到全局运行队列中,等待GMP模型调度,空闲的P对象会从全局队列中获取可运行的goroutine
if n > 0 {
lock(&sched.lock)
globrunqputbatch(&globq, int32(n))
unlock(&sched.lock)
// 启动n个空闲的系统线程M
startIdle(n)
qsize -= n
}
// 如果仍然有剩余的goroutine,则放入当前P的本地运行队列
if !q.empty() {
runqputbatch(pp, &q, qsize)
}
}
2.1.2 堆内存分配
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
shouldhelpgc := false
...
if size <= ***maxSmallSize ***{
if noscan && size < ***maxTinySize ***{
v := nextFreeFast(span)
if v == 0 {
// 如果内存不够,则触发垃圾回收
v, span, shouldhelpgc = c.nextFree(***tinySpanClass***)
}
} else {
v := nextFreeFast(span)
if v == 0 {
// 如果内存不够,则触发垃圾回收
v, span, shouldhelpgc = c.nextFree(spc)
}
}
} else {
// 超过32kb的堆内存分配必然触发垃圾回收
shouldhelpgc = true
}
...
if shouldhelpgc {
// ***gcTriggerHeap 表示由堆内存触发垃圾回收***
if t := (gcTrigger{kind: ***gcTriggerHeap***}); t.test() {
gcStart(t)
}
}
...
}
2.1.3 手动触发
// 1. 如果当前处于**清扫终止/标记/标记终止**阶段,那么就一直等到标记结束阶段结束并过渡到清扫结束
// 2. 如果当前处于**清扫阶段**,就就辅助清扫,同时可以开启下一轮GC(因为清扫阶段已经确定需要回收的垃圾对象了)
// 3. 开始下一轮的**清扫终止**
// 4. 等到下一轮**标记终止**完成
// 5. 辅助下一轮的**清扫**
func GC() {
n := work.cycles.Load()
// 等待上一轮的清扫终止/标记/标记终止结束
gcWaitOnMark(n)
// 触发本轮垃圾回收
gcStart(gcTrigger{kind: ***gcTriggerCycle***, n: n + 1})
// 等待本轮标记终止阶段结束
gcWaitOnMark(n + 1)
// 手动GC的时候,会等待上一轮GC工作完成,然后在这里调用sweepone清理全部待处理的内存单元,等待期间会通过Gosched让出处理器
for work.cycles.Load() == n+1 && sweepone() != ^uintptr(0) {
sweep.nbgsweep++
Gosched()
}
// 有可能走到此处的时候spans还没有清理结束,原因:即便清扫队列已空,由于存在并发清扫操作,仍需等待以确保所有内存跨度都被正确清扫,从而让系统进入相对稳定和隔离的状态
// 这里需要继续等待全部span都被清理结束后再继续往下执行,同样如果还没有结束会让出处理器
for work.cycles.Load() == n+1 && !isSweepDone() {
Gosched()
}
mp := acquirem()
cycle := work.cycles.Load()
// 完成本轮GC后,发布堆内存快照,pprof的heap数据统计即在此处更新
if cycle == n+1 || (gcphase == ***_GCmark ***&& cycle == n+2) {
mProf_PostSweep()
}
releasem(mp)
}
手动触发垃圾回收的场景不多见,尤其在当前已经很成熟的垃圾回收算法下,不推荐在线上环境手动调用GC触发垃圾回收,而且这里在上一轮垃圾回收完成之前都会一直阻塞。
2.2 触发条件
触发器中有一个关键逻辑是控制器,但控制器比较复杂,无需深入源码,仅了解即可:
控制器由一个精密的**调步算法(Pacing Algorithm)**动态决策。该算法的核心目标是将GC的CPU开销控制在一个目标比例(通过GOGC
环境变量设定,默认为25%的额外CPU资源)。它会基于上一次GC周期的各项指标(如CPU时间、堆增长率、标记和清扫速度),来预测下一次GC应该在堆增长到多大(即trigger
值)时启动,才能确保在堆大小翻倍之前正好完成本次GC周期。这个算法赋予了Go GC强大的自适应能力,是其高性能表现的幕后功臣。
除手动调用GC函数触发垃圾回收外,后台触发和堆内存分配触发都会调用gcTrigger.test
校验是否满足垃圾回收触发条件:
func (t gcTrigger) test() bool {
// 1. 当前是否允许gc,在程序初始化的时候写入,总为true
// 2. 是否处于panic中
// 3. 如果在GC过程中,也不允许开启下一轮GC
if !memstats.enablegc || panicking.Load() != 0 || gcphase != ***_GCoff ***{
return false
}
switch t.kind {
case ***gcTriggerHeap***:
// 堆内存分配达到了控制器计算的触发堆大小
trigger, _ := gcController.trigger()
return gcController.heapLive.Load() >= trigger
case ***gcTriggerTime***:
if gcController.gcPercent.Load() < 0 {
return false
}
lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
// 距离上次GC超过2min
return lastgc != 0 && t.now-lastgc > forcegcperiod
case ***gcTriggerCycle***:
// 如果当前没有开启垃圾收集,则触发下一轮
return int32(t.n-work.cycles.Load()) > 0
}
return true
}
2.3 垃圾收集启动
垃圾收集通过gcStart
函数启动,该函数主要职责是修改垃圾收集状态到***_GCmark***,并做一些准备工作,分为以下几个步骤:
仔细看过源码后再回来对比和优化一下
两次调用
trigger.test
检查是否满足垃圾收集条件;暂停程序、在后台启动用户标记任务的工作goroutine、确定所有内存管理单元都被清理、以及其他标记标记阶段开始前的准备工作;
进入标记阶段、准备后台的标记工作、根对象的标记工作以及微对象、恢复用户程序,进入并发扫描呵标记阶段;
func gcStart(trigger gcTrigger) {
mp := acquirem()
// 前置检查,gp == mp.g0(g0是调度器的goroutine)表示处于初始化阶段,mp.preemptoff != ""表示系统级线程被抢占
if gp := getg(); gp == mp.g0 || mp.locks > 1 || mp.preemptoff != "" {
releasem(mp)
return
}
releasem(mp)
mp = nil
// Go 的清扫阶段是并发进行的,后台会有一个 goroutine 持续清扫内存跨度,同时当 goroutine 需要新的内存块时也会触发清扫操作。
// 这种并发特性虽然提升了性能,但也带来了清扫不及时或不彻底的风险。所以有些场景会多次调用 sweepone,这样可以在不同的时间点对未清扫的内存跨度进行检查和处理,提高清扫的完整性。
// 验证是否满足GC触发条件,然后清理上个阶段未被清理的内存
// 手动GC的时候会在调用gcStart在这里清扫,然后再清扫一遍,其他两个入口没有提前清理,统一在这里清理
// 所以这里可以看出来Go的GC是每次标记完之后,在下次GC启动的时候再清扫
for trigger.test() && sweepone() != ^uintptr(0) {
sweep.nbgsweep++
}
semacquire(&work.startSema)
// 再次检查是否满足GC条件
if !trigger.test() {
semrelease(&work.startSema)
return
}
// 这里开始正式标记工作
semacquire(&gcsema)
semacquire(&worldsema)
// 目前只有手动触发的时候kind=***gcTriggerCycle***
work.userForced = trigger.kind == ***gcTriggerCycle***
// 校验逻辑,如果flushGen!=mheap_.sweepgen就表示该mcache是脏数据需要被清理
for _, p := range allp {
if fg := p.mcache.flushGen.Load(); fg != mheap_.sweepgen {
println("runtime: p", p.id, "flushGen", fg, "!= sweepgen", mheap_.sweepgen)
throw("p mcache not flushed")
}
}
gcBgMarkStartWorkers()
}
gcBgMarkStartWorkers
用于启动后台标记goroutine,垃圾收集启动的时候会为每个P对象创建一个标记goroutine,异步调用gcBgMarkWorker
该函数后,goroutine会被挂起等待调度器唤醒
func gcBgMarkStartWorkers() {
// Background marking is performed by per-P G's. Ensure that each P has
// a background GC G.
//
// Worker Gs don't exit if gomaxprocs is reduced. If it is raised
// again, we can reuse the old workers; no need to create new workers.
for gcBgMarkWorkerCount < gomaxprocs {
go gcBgMarkWorker()
notetsleepg(&work.bgMarkReady, -1)
noteclear(&work.bgMarkReady)
// The worker is now guaranteed to be added to the pool before
// its P's next findRunnableGCWorker.
gcBgMarkWorkerCount++
}
}
gcBgMarkWorker
是后台标记任务执行的函数,该函数的循环中执行了对内存中对象图的扫描和标记,以下分三个部分介绍该函数的实现原理:
获取当前处理器以及goroutine,并打包成gcBgMarkWorkerNode结构,同时主动陷入休眠等待唤醒
根据gcMarkWorkerMode决定扫描任务的策略
所有标记任务都结束后,调用
gcMarkDone
方法完成标记阶段
接下来看代码
func gcBgMarkWorker() {
gp := getg()
// 防止当前goroutine被抢占,避免禁止抢占后递归启动GC导致的死锁
gp.m.preemptoff = "GC worker init"
// 初始化node
node := new(gcBgMarkWorkerNode)
gp.m.preemptoff = ""
// 把goroutine打包到node
node.gp.set(gp)
// 把系统线程打包到node
node.m.set(acquirem())
notewakeup(&work.bgMarkReady)
// 从这里开始,后台标记任务会由gcController.findRunnableGCWorker调度,调度逻辑见后面
for {
// 主动陷入休眠,直到被gcController.findRunnableGCWorker唤醒
// 通过gopark陷入休眠的goroutine不会进入运行队列,只能等待垃圾收集控制器或调度器的唤醒
gopark(func(g *g, nodep unsafe.Pointer) bool {
node := (*gcBgMarkWorkerNode)(nodep)
if mp := node.m.ptr(); mp != nil {
releasem(mp)
}
// 把标记goroutine放入到标记任务池中
gcBgMarkWorkerPool.push(&node.node)
// 到这里以后可能会马上被调度唤醒
return true
}, unsafe.Pointer(node), ***waitReasonGCWorkerIdle***, ***traceEvGoBlock***, 0)
// 此处禁止抢占,否则其他goroutine可能会看到gcMarkWorkerMode的变化过程,造成调度错误
// 如果调度器需要抢占,那么我们会停止标记任务的处理,gcw即gc worker,然后就可以抢占了
// acquirem中会对m加锁,然后再返回,用完之后需要调用releasem释放锁,上面gopark中会调用releasem
node.m.set(acquirem())
pp := gp.m.p.ptr() // P can't change with preemption disabled.
// 校验逻辑
...
systemstack(func() {
// Mark our goroutine preemptible so its stack
// can be scanned. This lets two mark workers
// scan each other (otherwise, they would
// deadlock). We must not modify anything on
// the G stack. However, stack shrinking is
// disabled for mark workers, so it is safe to
// read from the G stack.
casGToWaiting(gp, ***_Grunning***, ***waitReasonGCWorkerActive***)
switch pp.gcMarkWorkerMode {
default:
throw("gcBgMarkWorker: unexpected gcMarkWorkerMode")
// ***gcMarkWorkerDedicatedMode模式下不允许抢占,这也是为了更高效执行标记任务,不受其他并发操作的干扰***
// 需要注意的是会调用两次gcDrain,其中第一次是可以被抢占的,一旦处理器被抢占(原因应该是,试探一下,如果被抢占,说明当前处理器资源较为紧张,即使强制开始gc,也可能会影响到别的goroutine,所以不如直接把其他goroutine统一转移到全局队列,让其他处理器去处理),
// 当前goroutine会将处理器上所有可运行的goroutine转移到全局队列中,来保证垃圾回收的CPU资源
case ***gcMarkWorkerDedicatedMode***:
gcDrain(&pp.gcw, ***gcDrainUntilPreempt***|***gcDrainFlushBgCredit***)
if gp.preempt {
// 这里把当前处理器本地队列中的goroutine全部转移到全局队列中
if drainQ, n := runqdrain(pp); n > 0 {
lock(&sched.lock)
globrunqputbatch(&drainQ, int32(n))
unlock(&sched.lock)
}
}
// 再次调用gcDrain,本次不允许抢占
gcDrain(&pp.gcw, ***gcDrainFlushBgCredit***)
case ***gcMarkWorkerFractionalMode***:
gcDrain(&pp.gcw, ***gcDrainFractional***|***gcDrainUntilPreempt***|***gcDrainFlushBgCredit***)
case ***gcMarkWorkerIdleMode***:
gcDrain(&pp.gcw, ***gcDrainIdle***|***gcDrainUntilPreempt***|***gcDrainFlushBgCredit***)
}
casgstatus(gp, ***_Gwaiting***, ***_Grunning***)
})
// Account for time and mark us as stopped.
now := nanotime()
duration := now - startTime
gcController.markWorkerStop(pp.gcMarkWorkerMode, duration)
if trackLimiterEvent {
pp.limiterEvent.stop(***limiterEventIdleMarkWork***, now)
}
if pp.gcMarkWorkerMode == ***gcMarkWorkerFractionalMode ***{
atomic.Xaddint64(&pp.gcFractionalMarkTime, duration)
}
// Was this the last worker and did we run out
// of work?
incnwait := atomic.Xadd(&work.nwait, +1)
if incnwait > work.nproc {
println("runtime: p.gcMarkWorkerMode=", pp.gcMarkWorkerMode,
"work.nwait=", incnwait, "work.nproc=", work.nproc)
throw("work.nwait > work.nproc")
}
// We'll releasem after this point and thus this P may run
// something else. We must clear the worker mode to avoid
// attributing the mode to a different (non-worker) G in
// traceGoStart.
pp.gcMarkWorkerMode = ***gcMarkWorkerNotWorker***
*** ******// 如果所有后台任务都陷入等待,且没有剩余工作,则认为本轮标记结束,调用gcMarkDone终止***
if incnwait == work.nproc && !gcMarkWorkAvailable(nil) {
// We don't need the P-local buffers here, allow
// preemption because we may schedule like a regular
// goroutine in gcMarkDone (block on locks, etc).
releasem(node.m.ptr())
node.m.set(nil)
// 标记任务终止
gcMarkDone()
}
}
}
gcMarkWorkerMode
有四种模式:
gcMarkWorkerNotWorker表示该goroutine不是gcWorker
gcMarkWorkerDedicatedMode表示该goroutine不允许被抢占
gcMarkWorkerFractionalMode表示该goroutine上的gcWorker会被碎片化执行,会和其他任务共享处理器资源
gcMarkWorkerIdleMode表示当前P处于空闲状态,会执行剩余的gcWorker,直到被其他任务抢占
gcWork结构是垃圾收集器中的工作池对象,它实现了一个生产者消费者模型,可以从这个结构出发去整体理解标记工作:
写屏障/根对象扫描/栈扫描,都会向gcWork中放入灰色对象,对象扫描过程中会把灰色对象标记成黑色对象,同时也可能发现新的灰色对象,当工作队列中不包含灰色对象时,整个扫描过程就会结束。
为了减少锁竞争,运行时在每个处理器上会保存独立的待扫描工作,然而这会遇到和调度器一样的问题:不同处理器资源不平均,导致部分处理器无事可做,调度器引入了工作窃取来解决这个问题,垃圾收集器也使用类似的策略来平衡不同处理器上的待处理任务。
runtime.gcWork.balance
会将处理器本地的一部分工作放回到全局队列中,让其他处理器处理,保证所有处理器都可以物尽其用。
runtime.gcWork
为垃圾收集器提供了生产和消费任务的抽象,该结构有两个重要的工作缓冲区wbuf1
和wbuf2
,这两个缓冲区分别是主缓冲区和备缓冲区。
type gcWork struct {
wbuf1, wbuf2 *workbuf
bytesMarked uint64
heapScanWork int64
flushedWork bool
}
type workbuf struct {
_ sys.NotInHeap
workbufhdr
// account for the above fields
obj [(***_WorkbufSize ***- unsafe.Sizeof(workbufhdr{})) / goarch.***PtrSize***]uintptr
}
当我们向该结构中增加或删除对象时,它会先操作主缓冲区,一旦主缓冲区空间不足或没有可操作对象,就会触发主备缓冲区的切换;而当两个缓冲区空间都不足或都为空时,会向全局缓冲区插入或获取对象,代码不复杂,感兴趣可以看runtime/mgcwork.go
;
2.3.1 扫描对象
根据不同的模式,调用gcDrain
扫描工作缓冲内的灰色对象并涂色,它会根据传入的gcDrainFlags
选择不同的策略:
gcDrainUntilPreempt:当Goroutine上的preempt设置成true时返回;
gcDrainFlushBgCredit:调用 runtime.gcFlushBgCredit 计算后台完成的标记任务量以减少并发标记期间辅助垃圾收集的用户程序的工作量;
gcDrainIdle:调用runtime.pollwork***,当处理器上有其他待执行的goroutine时返回;***
gcDrainFractional:调用runtime.pollFractionalWorkerExit***,当 CPU 的占用率超过 fractionalUtilizationGoal 的 20% 时返回;***
fractionalUtilizationGoal是一个动态计算出来的值,核心是GC占用的CPU不超过25%,基于此来计算fractionalUtilizationGoal的值
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
if !writeBarrier.needed {
throw("gcDrain phase incorrect")
}
gp := getg().m.curg
// 分别标记三种模式
// preemptible模式下,如果Goroutine上的preempt为true,会直接走到done代码块下
preemptible := flags&***gcDrainUntilPreempt ***!= 0
flushBgCredit := flags&***gcDrainFlushBgCredit ***!= 0
idle := flags&***gcDrainIdle ***!= 0
initScanWork := gcw.heapScanWork
// checkWork is the scan work before performing the next
// self-preempt check.
checkWork := int64(1<<63 - 1)
// 根据不同模式对check赋值,在后面调用,检查当前是否应该退出标记任务并让出处理器,
// 当做完所有前置准备工作,就可以开始扫描全局变量中的根对象了
var check func() bool
if flags&(***gcDrainIdle***|***gcDrainFractional***) != 0 {
checkWork = initScanWork + ***drainCheckThreshold***
*** ***if idle {
check = pollWork
} else if flags&***gcDrainFractional ***!= 0 {
check = pollFractionalWorkerExit
}
}
// 扫描全部根对象
// work.markrootJobs表示要扫描的根对象数量,每扫描一个根对象,markrootNext就加1
if work.markrootNext < work.markrootJobs {
// Stop if we're preemptible or if someone wants to STW.
// 如果当前goroutine没有被抢占,且(对应模式是***gcDrainUntilPreempt或有gc任务待执行)***
for !(gp.preempt && (preemptible || sched.gcwaiting.Load())) {
job := atomic.Xadd(&work.markrootNext, +1) - 1
if job >= work.markrootJobs {
break
}
// 标记根对象
// markRoot 该函数会扫描 缓存、数据段、存放全局变量和静态变量的 BSS 段以及 Goroutine 的栈内存;
// 一旦完成了对根对象的扫描,当前 Goroutine 会开始从本地和全局的工作缓存池中获取待执行的任务
markroot(gcw, job, flushBgCredit)
// 如果goroutine的preempt被标记为true,则退出标记任务,让出处理器
if check != nil && check() {
goto done
}
}
}
// Drain heap marking jobs.
// Stop if we're preemptible or if someone wants to STW.
for !(gp.preempt && (preemptible || sched.gcwaiting.Load())) {
// 平衡各处理器上的标记任务
if work.full == 0 {
gcw.balance()
}
// 从缓冲池中获取标记任务
b := gcw.tryGetFast()
if b == 0 {
b = gcw.tryGet()
if b == 0 {
// Flush the write barrier
// buffer; this may create
// more work.
wbBufFlush(nil, 0)
b = gcw.tryGet()
}
}
if b == 0 {
// Unable to get work.
break
}
// 从b位置开始扫描,扫描期间会调用greyobject为找到的活跃对象上色
scanobject(b, gcw)
// Flush background scan work credit to the global
// account if we've accumulated enough locally so
// mutator assists can draw on it.
// ***计算后台完成的标记任务量以减少并发标记期间辅助垃圾收集的用户程序的工作量;***
if gcw.heapScanWork >= ***gcCreditSlack ***{
gcController.heapScanWork.Add(gcw.heapScanWork)
if flushBgCredit {
gcFlushBgCredit(gcw.heapScanWork - initScanWork)
initScanWork = 0
}
checkWork -= gcw.heapScanWork
gcw.heapScanWork = 0
if checkWork <= 0 {
checkWork += ***drainCheckThreshold***
*** ***if check != nil && check() {
break
}
}
}
}
done:
// 如果本轮扫描因为外部条件变化而中断,会调用gcFlushBgCredit记录本次扫描的内存字节数,下次就不需要重复扫描,从而减少辅助标记的工作量
if gcw.heapScanWork > 0 {
gcController.heapScanWork.Add(gcw.heapScanWork)
if flushBgCredit {
gcFlushBgCredit(gcw.heapScanWork - initScanWork)
}
gcw.heapScanWork = 0
}
}
接下来再看看具体如何标记的,这里只看大体标记流程,具体细节不再细拆:
func markroot(gcw *gcWork, i uint32, flushBgCredit bool) int64 {
var workDone int64 // 本次扫描操作完成的工作量
var workCounter *atomic.Int64 // 指向原子整数,统计不通类型根对象的扫描工作量
switch {
// 扫描【已初始化全局变量】和【静态变量】
case work.baseData <= i && i < work.baseBSS:
workCounter = &gcController.globalsScanWork
for _, datap := range activeModules() {
workDone += markrootBlock(datap.data, datap.edata-datap.data, datap.gcdatamask.bytedata, gcw, int(i-work.baseData))
}
// 扫描【未初始化的全局变量】和【静态变量】
case work.baseBSS <= i && i < work.baseSpans:
workCounter = &gcController.globalsScanWork
for _, datap := range activeModules() {
workDone += markrootBlock(datap.bss, datap.ebss-datap.bss, datap.gcbssmask.bytedata, gcw, int(i-work.baseBSS))
}
// 扫描终结器(Finalizers)根对象。终结器是Go的一种特殊机制,它允许为对象注册一个函数,当该对象即将被垃圾回收器回收时,这个注册的函数会被自动调用
// 终结器主要用于在对象被回收前执行一些清理操作,比如释放非内存资源(如文件句柄、网络连接、数据库连接等)。虽然 Go 语言有自动垃圾回收机制,但对于非内存资源,需要手动释放,终结器能帮助开发者实现这一点
// 简单来说,就是用户可以自己为struct写一个函数,比如Close(),当结构体不再被引用且即将被回收的时候,调用Close()方法
case i == ***fixedRootFinalizers***:
for fb := allfin; fb != nil; fb = fb.alllink {
cnt := uintptr(atomic.Load(&fb.cnt))
scanblock(uintptr(unsafe.Pointer(&fb.fin[0])), cnt*unsafe.Sizeof(fb.fin[0]), &finptrmask[0], gcw, nil)
}
// 扫描死亡Goroutine的栈根对象,当goroutine执行完毕后,其占用的栈空间就不再被使用,需要释放这部分内存以避免内存浪费
case i == ***fixedRootFreeGStacks***:
// Switch to the system stack so we can call
// stackfree.
systemstack(markrootFreeGStacks)
// 扫描mspan特殊对象的根对象,指mspan中带有特殊属性(如终结器)的对象
case work.baseSpans <= i && i < work.baseStacks:
// mark mspan.specials
markrootSpans(gcw, int(i-work.baseSpans))
// 其余,扫描goroutine中的栈根对象,除上面类型的内存,剩下的都是goroutine申请的栈内存
default:
workCounter = &gcController.stackScanWork
// 检查i是否在有效范围内,baseEnd表示所有根对象索引的结束位置,是根对象索引的边界,垃圾回收的时候
// 可以借助这个变量判断是否已完成所有根对象的扫描
if i < work.baseStacks || work.baseEnd <= i {
printlock()
print("runtime: markroot index ", i, " not in stack roots range [", work.baseStacks, ", ", work.baseEnd, ")\n")
throw("markroot: bad index")
}
// 根据索引找到对应的goroutine
gp := work.stackRoots[i-work.baseStacks]
...
systemstack(func() {
userG := getg().m.curg
selfScan := gp == userG && readgstatus(userG) == ***_Grunning***
*** ***
***// GC工作协程(worker)在扫描其他Goroutine的栈之前,会先通过 suspendG 暂停目标。***
// ***但worker无法暂停自己,因此在扫描自身栈时,它会先通过 casGToWaiting 将自己的状态临时切换为 _Gwaiting。***
// ***这使得扫描逻辑可以像处理一个普通等待状态的Goroutine一样安全地扫描其栈,从而避免了自我暂停导致的逻辑死锁。***
// (为什么自扫描会死锁呢?一个正在运行的GC worker(状态为 _Grunning)尝试扫描自己的栈。扫描操作通常需要先“暂停”目标Goroutine,但一个Goroutine无法暂停自己。因此就会死锁)
if selfScan {
casGToWaiting(userG, ***_Grunning***, ***waitReasonGarbageCollectionScan***)
}
// 暂停goroutine
stopped := suspendG(gp)
if stopped.dead {
gp.gcscandone = true
return
}
if gp.gcscandone {
throw("g already scanned")
}
// 调用<u>scanstack</u>函数扫描栈,标记goroutine扫描完成,并回复goroutine运行
workDone += scanstack(gp, gcw)
gp.gcscandone = true
resumeG(stopped)
if selfScan {
casgstatus(userG, ***_Gwaiting***, ***_Grunning***)
}
})
}
// 更新扫描工作量统计,刷新后台信用池
if workCounter != nil && workDone != 0 {
workCounter.Add(workDone)
if flushBgCredit {
gcFlushBgCredit(workDone)
}
}
return workDone
}
2.3.2 写屏障
写屏障是保障Go并发标记安全不可或缺的技术,所以需要使用混合写屏障来保证对象图的弱三色不变性,写屏障的实现需要编译器和运行时的共同协作,在编译阶段,编译器会使用cmd/compile/internal/ssa.writebarrier 在 Store
、Move
和 Zero
操作中加入写屏障,生成如下代码:
if writeBarrier.enabled {
gcWriteBarrier(ptr, val)
} else {
*ptr = val
}
当Go语言进入垃圾收集阶段时,全局变量runtime.writeBarrier中的enabled
会被置为true,所有的写操作都会调用runtime.gcWriteBarrier
。
在上面提到的混合写屏障在开启后,会把所有新创建的对象都标记为黑色,通过runtime.gcmarknewobject
来实现,回顾一下内存分配源码:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
// Allocate black during GC.
// All slots hold nil so no scanning is needed.
// This may be racing with GC so do it atomically if there can be
// a race marking the bit.
// 垃圾收集开启后调用gcmarknewobject涂黑新对象
if gcphase != ***_GCoff ***{
gcmarknewobject(span, uintptr(x), size)
}
...
}
func gcmarknewobject(span *mspan, obj, size uintptr) {
if useCheckmark { // The world should be stopped so this should not happen.
throw("gcmarknewobject called while doing checkmark")
}
// Mark object.
objIndex := span.objIndex(obj)
// markBitsForIndex 获取对应的内存单元以及标记位
// setMarked涂黑
span.markBitsForIndex(objIndex).setMarked()
// Mark span.
arena, pageIdx, pageMask := pageIndexOf(span.base())
if arena.pageMarks[pageIdx]&pageMask == 0 {
atomic.Or8(&arena.pageMarks[pageIdx], pageMask)
}
gcw := &getg().m.p.ptr().gcw
gcw.bytesMarked += uint64(size)
}
2.3.3 辅助标记
为了保证用户程序分配内存的速度不会超过后台任务的标记速度,Go在运行时引入了辅助标记,它遵循一条简单朴实的原则:分配多少内存就完成多少标记任务。每个goroutine都持有一个gcAssistBytes
字段,这个字段存储了当前goroutine辅助标记的对象字节数。在并发标记期间,当goroutine调用runtime.mallocgc
分配新对象时,该函数会检查申请内存的goroutine是否处于入不敷出的状态:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
// assistG is the G to charge for this allocation, or nil if
// GC is not currently active.
assistG := deductAssistCredit(size)
...
}
// 用于扣除辅助垃圾收集的信用额度
func deductAssistCredit(size uintptr) *g {
var assistG *g
if gcBlackenEnabled != 0 {
// 获取当前goroutine
assistG = getg()
if assistG.m.curg != nil {
// 如果当前goroutine有嵌套的goroutine,则取嵌套的goroutine
assistG = assistG.m.curg
}
// 先扣除分配内存size大小的额度,如果不够(为负数),则在GC中先完成-assistG.gcAssistBytes额度的标记任务
assistG.gcAssistBytes -= int64(size)
// 如果额度不够,则调用gcAssistAlloc先帮助GC完成对应额度的标记任务,额度即为assistG.gcAssistBytes
if assistG.gcAssistBytes < 0 {
gcAssistAlloc(assistG)
}
}
return assistG
}
接下来再看看gcAssistAlloc
的实现:
gcAssistBytes
表示当前goroutine辅助标记的字节数bgScanCredit
表示后台goroutine辅助标记的字节数
当goroutine分配了较多字节数时,可以使用公用的信用bgScanCredit
偿还
func gcAssistAlloc(gp *g) {
...
// 表示在垃圾收集过程中,辅助标记工作量与分配的字节数的比率。这个比率在垃圾收集的每个周期开始时都会被计算和更新。
assistWorkPerByte := gcController.assistWorkPerByte.Load()
// 1/assistWorkPerByte
assistBytesPerWork := gcController.assistBytesPerWork.Load()
// 当前goroutine的欠债
debtBytes := -gp.gcAssistBytes
// scanWork 表示需要完成的标记任务数量
scanWork := int64(assistWorkPerByte * float64(debtBytes))
// ***gcOverAssistWork表示每个辅助标记任务可以额外完成的工作量,这里让辅助标记尽可能多的分担标记任务,为后台标记任务减少工作量***
if scanWork < ***gcOverAssistWork ***{
scanWork = ***gcOverAssistWork***
*** ***debtBytes = int64(assistBytesPerWork * float64(scanWork))
}
bgScanCredit := gcController.bgScanCredit.Load()
stolen := int64(0)
// 如果全局信用中有可用点数,那么就减去当前goroutine需要借用的点数
// 因为是并发操作,全局点数有可能会变成负数,不过长期来看并不是什么重要问题
// 然后更新gcAssistBytes点数
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 {
if traced {
traceGCMarkAssistDone()
}
return
}
}
...
// 如果全局信用分不足以偿还,在系统栈中调用gcAssistAlloc1执行标记任务,
// 它会直接调用runtime.gcDrainN完成指定数量的标记任务并返回
systemstack(func() {
gcAssistAlloc1(gp, scanWork)
})
completed := gp.param != nil
gp.param = nil
if completed {
gcMarkDone()
}
// 如果在完成上述辅助标记任务后,全局信用分仍然不够,且当前goroutine没有被抢占,就会调用gcParkAssist
// 将当前goroutine陷入休眠,加入全局辅助标记队列,同时等待后台标记任务唤醒
if gp.gcAssistBytes < 0 {
// 被抢占,挂起后直接重试
if gp.preempt {
Gosched()
goto retry
}
if !gcParkAssist() {
goto retry
}
}
}
申请内存时调用的 runtime.gcAssistAlloc
和扫描内存时调用的 runtime.gcFlushBgCredit
分别负责借债和还债,通过这套债务管理系统,我们能够保证 Goroutine 在正常运行的同时不会为垃圾收集造成太多的压力,保证在达到堆大小目标时完成标记阶段。
func gcFlushBgCredit(scanWork int64) {
// 如果辅助标记队列为空,表示当前没有辅助标记任务-没有欠债,此时当前的信用分加到全局信用分,后续供其他辅助标记任务借贷
if work.assistQueue.q.empty() {
gcController.bgScanCredit.Add(scanWork)
return
}
assistBytesPerWork := gcController.assistBytesPerWork.Load()
scanBytes := int64(float64(scanWork) * assistBytesPerWork)
lock(&work.assistQueue.lock)
for !work.assistQueue.q.empty() && scanBytes > 0 {
// 从辅助标记队列中吐出G
gp := work.assistQueue.q.pop()
// 如果是负数,表示是负债状态,需要在else中进入辅助标记队列还债
if scanBytes+gp.gcAssistBytes >= 0 {
scanBytes += gp.gcAssistBytes
gp.gcAssistBytes = 0
ready(gp, 0, false)
} else {
// Partially satisfy this assist.
gp.gcAssistBytes += scanBytes
scanBytes = 0
// 进入辅助标记队列
work.assistQueue.q.pushBack(gp)
break
}
}
// 如果唤醒所有goroutine后,标记任务量还有剩余,则加入到全局信用分中
if scanBytes > 0 {
// Convert from scan bytes back to work.
assistWorkPerByte := gcController.assistWorkPerByte.Load()
scanWork = int64(float64(scanBytes) * assistWorkPerByte)
gcController.bgScanCredit.Add(scanWork)
}
unlock(&work.assistQueue.lock)
}
辅助标记任务的核心目的是为了避免用户分配内存影响垃圾收集器完成标记工作的期望时间,它通过维护账户体系保证用户程序不会对垃圾回收造成过大负担,如果用户程序分配了大量内存,该用户程序就会通过辅助标记的方式平衡账户,这个过程会在最后达到相对平衡,保证标记任务在达到期望堆大小时完成。
2.3.4 标记终止
当所有处理器的本地任务都完成,且不存在剩余的工作goroutine时,后台标记任务或辅助标记的用户程序对调用runtime.gcMarkDone
通知垃圾收集器。当所有可达对象都被标记后,该函数会将垃圾收集的状态切换至_GCmarktermination
,如果本地队列中仍然存在待处理的任务,当前方法会将所有的任务加入全局队列并等待其他goroutine完成处理。
func gcMarkDone() {
...
top:
// 校验:当前处于标记阶段,所有后台标记任务都陷入等待,没有可执行的标记任务
if !(gcphase == ***_GCmark ***&& work.nwait == work.nproc && !gcMarkWorkAvailable(nil)) {
semrelease(&work.markDoneSema)
return
}
...
gcMarkDoneFlushed = 0
systemstack(func() {
gp := getg().m.curg
// 修改当前goroutine状态从***_Grunning到_Gwaiting***
casGToWaiting(gp, ***_Grunning***, ***waitReasonGCMarkTermination***)
// 遍历所有P:
// 刷新写屏障缓冲区,防止有新的gcWork产生
// 刷新当前P的gcWork,因为有可能产生全局gcWork并设置flushedWork
// 如果gcWork被刷新,则增加gcMarkDoneFlushed数量,并将flushedWork设置为false
// flushedWork表示一个上面的校验通过后,有非空任务缓冲区被刷到了全局任务列表中,这也表示有一个gcWork把任务转交给了另一个gcWork
forEachP(func(pp *p) {
wbBufFlush1(pp)
pp.gcw.dispose()
if pp.gcw.flushedWork {
atomic.Xadd(&gcMarkDoneFlushed, 1)
pp.gcw.flushedWork = false
}
})
// 修改当前goroutine状态从***_Gwaiting到_Grunning***
casgstatus(gp, ***_Gwaiting***, ***_Grunning***)
})
// 如果有剩余gcWork,则回到top位置重新执行
if gcMarkDoneFlushed != 0 {
semrelease(&worldsema)
goto top
}
}
上述逻辑通过后,接下来就可以开始垃圾收集的阶段迁移了
func gcMarkDone() {
...
getg().m.preemptoff = "gcing"
// STW
systemstack(stopTheWorldWithSema)
// 再次检查是否有未完成的gcWork,如果有,则再次回到top位置(为了解决某现存case)
restart := false
systemstack(func() {
for _, p := range allp {
wbBufFlush1(p)
if !p.gcw.empty() {
restart = true
break
}
}
})
if restart {
getg().m.preemptoff = ""
systemstack(func() {
now := startTheWorldWithSema(trace.enabled)
work.pauseNS += now - work.pauseStart
memstats.gcPauseDist.record(now - work.pauseStart)
})
semrelease(&worldsema)
goto top
}
// 关闭混合写屏障
atomic.Store(&gcBlackenEnabled, 0)
// 唤醒所有用于辅助垃圾收集的用户程序
gcWakeAllAssists()
// 恢复用户程序goroutine的调度
schedEnableUser(true)
// 计算触发下一阶段垃圾回收的触发条件
gcController.endCycle(now, int(gomaxprocs), work.userForced)
// 进入标记终止阶段
gcMarkTermination()
}
func gcMarkTermination() {
// 注意此时混合写屏障仍然是关闭状态
setGCPhase(***_GCmarktermination***)
mp := acquirem()
mp.preemptoff = "gcing"
mp.traceback = 2
curgp := mp.curg
// 修改当前goroutine状态从***_Grunning到_GWaiting***
casGToWaiting(curgp, ***_Grunning***, ***waitReasonGarbageCollection***)
systemstack(func() {
// 标记阶段数据的一些清理和校验工作
gcMark(startTime)
})
systemstack(func() {
work.heap2 = work.bytesMarked
...
// marking is complete so we can turn the write barrier off
setGCPhase(***_GCoff***)
// 重置清理阶段的相关状态并在需要时阻塞清理所有的内存管理单元
gcSweep(work.mode)
})
// 切换回***_Grunning***
casgstatus(curgp, ***_Gwaiting***, ***_Grunning***)
mp.preemptoff = ""
// 一些GC的统计逻辑,包括正在使用的内存大小,本轮垃圾收集的暂停时间,CPU利用率等,这些数据能够帮助控制器决定
// 触发下一轮垃圾回收的堆内存大小
...
// STW结束
systemstack(func() { startTheWorldWithSema(trace.enabled) })
mProf_Flush()
prepareFreeWorkbufs()
systemstack(freeStackSpans)
systemstack(func() {
forEachP(func(pp *p) {
pp.mcache.prepareForSweep()
})
})
}
_GCmarktermination
在垃圾收集阶段不会持续太久,它会迅速切换至_GCoff
并恢复应用从程序,到这里垃圾回收全过程基本就结束了,用户程序在申请内存时才会惰性回收内存。
2.3.5 内存清理
sweepone
函数会在对内存中查找待清理的内存管理单元。
func sweepone() uintptr {
gp := getg()
gp.m.locks++
sl := sweep.active.begin()
if !sl.valid {
gp.m.locks--
return ^uintptr(0)
}
// 找到待清理的mspan
npages := ^uintptr(0)
var noMoreWork bool
for {
// 从mcentral中返回需要清理的mspan
s := mheap_.nextSpanForSweep()
if s == nil {
noMoreWork = sweep.active.markDrained()
break
}
if state := s.state.get(); state != ***mSpanInUse ***{
// sweepgen 相当于GC周期的版本号,每次GC会加2。通过比较一个内存块(mspan)的sweepgen和全局的sweepgen,运行时可以高效地判断出这个内存块是‘待清扫’、‘正在清扫中’还是‘已清扫’的状态。定义如下:
// sweep generation:
// if sweepgen == h->sweepgen - 2, the span needs sweeping
// if sweepgen == h->sweepgen - 1, the span is currently being swept
// if sweepgen == h->sweepgen, the span is swept and ready to use
// if sweepgen == h->sweepgen + 1, the span was cached before sweep began and is still cached, and needs sweeping
// if sweepgen == h->sweepgen + 3, the span was swept and then cached and is still cached
// h->sweepgen is incremented by 2 after every GC
if !(s.sweepgen == sl.sweepGen || s.sweepgen == sl.sweepGen+3) {
print("runtime: bad span s.state=", state, " s.sweepgen=", s.sweepgen, " sweepgen=", sl.sweepGen, "\n")
throw("non in-use span in unswept list")
}
continue
}
if s, ok := sl.tryAcquire(s); ok {
npages = s.npages
// 执行清理
if s.sweep(false) {
mheap_.reclaimCredit.Add(npages)
} else {
npages = 0
}
break
}
}
sweep.active.end(sl)
...
gp.m.locks--
return npages
}
所有的回收工作最终都是靠 runtime.mspan.sweep
完成的,它会根据并发标记阶段回收内存单元中的垃圾并清除标记以免影响下一轮垃圾收集。
// 从mcentral的fullUnswept或partialUnswept中返回需要清理的mspan
func (h *mheap) nextSpanForSweep() *mspan {
sg := h.sweepgen
for sc := sweep.centralIndex.load(); sc < ***numSweepClasses***; sc++ {
spc, full := sc.split()
c := &h.central[spc].mcentral
var s *mspan
if full {
s = c.fullUnswept(sg).pop()
} else {
s = c.partialUnswept(sg).pop()
}
if s != nil {
// Write down that we found something so future sweepers
// can start from here.
sweep.centralIndex.update(sc)
return s
}
}
// Write down that we found nothing.
sweep.centralIndex.update(***sweepClassDone***)
return nil
}
至此垃圾回收源码结束,垃圾回收相比内存分配要复杂很多,Go 语言为了实现高性能的并发垃圾收集器,使用三色抽象、并发增量回收、混合写屏障、调步算法以及用户程序协助等机制将垃圾收集的暂停时间优化至毫秒级以下。这里只是分析了整体的流程,更多细节后面感兴趣再看。
三、全局总结
3.1 核心流程回顾
Go的并发垃圾收集器是一个精心设计的系统,其目标是在尽可能不影响用户程序(Mutator)执行的前提下,高效地完成内存回收。其完整周期可概括为以下几个核心阶段:
GC触发 (Triggering):GC的启动并非随意,主要由两种条件触发:
- 堆内存分配:当程序累计分配的内存达到一个由步调算法(Pacing Algorithm)动态计算出的阈值(
gcTriggerHeap
)时,GC被触发。这是最主要的触发方式。 - 定时触发:如果长时间(默认为2分钟)未触发GC,系统会启动一次强制GC(
gcTriggerTime
),以回收闲置内存。
- 堆内存分配:当程序累计分配的内存达到一个由步调算法(Pacing Algorithm)动态计算出的阈值(
标记准备 (Mark Setup - STW):一旦GC被触发,系统会进入一个短暂的“Stop The World”(STW)阶段。此阶段主要任务是开启混合写屏障(Write Barrier),并为并发标记做准备。这个暂停时间极短,通常在微秒级别。
并发标记 (Marking - Concurrent):这是GC中最为核心且耗时的阶段,但它与用户程序并发执行。
- 此阶段使用三色标记法作为基本算法,从根对象(如全局变量、Goroutine栈)出发,遍历对象图,识别所有存活对象。
- 为了确保在用户程序持续修改对象指针(即改变对象图)的情况下标记结果的正确性,混合写屏障机制开始发挥关键作用。
- 同时,为了防止用户程序分配内存的速度远超GC标记的速度,辅助标记 (Mark Assist) 机制会介入,实现一种“债务”平衡。
标记终止 (Mark Termination - STW):当所有存活对象都被标记后,系统进入第二个短暂的STW阶段。此阶段的任务包括:
- 关闭混合写屏障。
- 处理一些在并发标记期间无法安全处理的剩余工作。
- 完成最终的记账和状态切换,为接下来的清扫阶段做准备。这个暂停时间同样非常短暂。
并发清扫 (Sweeping - Concurrent):在此阶段,GC会遍历整个堆,将所有未被标记的白色对象(即垃圾)回收,并将其管理的内存归还到空闲链表,以备后续分配。这个过程与用户程序并发执行,不会造成程序暂停。清扫是惰性的,它按需进行,直到所有垃圾被回收。
3.2 关键技术的协同作用
Go GC的低延迟特性,得益于三色标记法、混合写屏障和辅助标记这三项关键技术的精妙协同。
三色标记法 (Tricolor Marking):
- 角色:它是并发标记的指导思想和工作流程框架。通过将对象分为白(潜在垃圾)、灰(待扫描)、黑(已存活)三类,使得并发标记过程得以有序进行。GC的工作就是不断将灰色对象变为黑色,并将其引用的白色对象变为灰色,直到没有灰色对象为止。
混合写屏障 (Hybrid Write Barrier):
- 角色:它是并发标记期间数据一致性的核心保障。Go的混合写屏障结合了插入屏障和删除屏障的优点,其核心目的是防止黑色对象直接指向白色对象,从而避免存活对象被错误回收。当用户程序执行指针写操作(
*slot = ptr
)时,写屏障会拦截该操作,将被覆盖的旧对象(*slot
)和新指向的对象(ptr
,在特定条件下)标记为灰色,从而维持了弱三色不变性,确保了GC的正确性。
- 角色:它是并发标记期间数据一致性的核心保障。Go的混合写屏障结合了插入屏障和删除屏障的优点,其核心目的是防止黑色对象直接指向白色对象,从而避免存活对象被错误回收。当用户程序执行指针写操作(
辅助标记 (Mark Assist):
- 角色:它是GC进度与用户程序执行速度之间的动态平衡器。在并发标记阶段,如果某个Goroutine分配内存过快,它会“欠下”一笔“标记债务”。辅助标记机制会强制该Goroutine暂停其业务逻辑,转而执行一部分GC的标记工作来“偿还债务”。这种设计确保了GC的标记进度能跟上内存分配的速度,保证GC能在预设的堆内存增长目标内完成,避免了因内存耗尽而导致的长时间STW。
3.3 知识体系的融会贯通
综上所述,Go的垃圾回收机制是一个高度协同的系统:
它以三色标记法为蓝图,在并发标记阶段识别存活对象;通过混合写屏障技术,构筑了一道坚固的防线,确保了并发操作下的数据一致性;并借助辅助标记机制,巧妙地实现了GC与用户程序之间的步调协同。整个流程通过两个极短的STW(标记准备和标记终止)进行状态切换和保障,而真正耗时的工作(标记和清扫)则与用户程序并发执行。
通过将这些技术有机地结合,Go语言在实现自动内存管理的同时,成功地将GC带来的暂停时间(STW)控制在毫秒甚至微秒级别,为构建高性能、低延迟的现代化服务提供了坚实的基础。
四、参考
https://draven.co/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/