教程集 www.jiaochengji.com
教程集 >  Golang编程  >  golang教程  >  正文 golang gc实现分析(go1.14.4)

golang gc实现分析(go1.14.4)

发布时间:2021-05-11   编辑:jiaochengji.com
教程集为您提供golang gc实现分析(go1.14.4)等资源,欢迎您收藏本站,我们将为您提供最新的golang gc实现分析(go1.14.4)资源

GC(garbage collection,垃圾回收/自动内存回收)是golang的关键特性。基于标记-清除(Mark and Sweep)算法的golang GC比java中基于引用计数的GC更加简单易用。为了降低GC暂停任务执行(STW,Stop The World)的时间,golang中的GC采用三色标记算法,最大程度的将标记过程和任务执行并行。

介绍golang GC原理和三色标记算法原理的中文文章不少,例如: 

Golang GC 探究:https://www.open-open.com/lib/view/open1435846881544.html

图解Golang的GC算法:https://studygolang.com/articles/18850?fr=sidebar

golang gc 简明过程(基于go 1.14):https://zhuanlan.zhihu.com/p/92210761

深入理解Go-垃圾回收机制:https://segmentfault.com/a/1190000020086769

Go GC简介:https://www.jianshu.com/p/6dab8f25c051

但分析golang gc具体实现逻辑和相关配置的文章却不多。本文将具体分析golang对GC的实现,便于更深入直接的理解GC的原理,支撑后续的GC问题分析和性能优化工作。

GC原理

为了减少GC的CPU开销、执行时间、特别是STW(stop the world,停止所有任务)时间,golang的GC采用了多种机制,包括:

1. 三色标记法

2. 后台并发标记

3. 后台并发清除

4. 写屏障(write barrier)

5. 辅助GC

三色标记法

golang的GC基于标记-清除算法,GC过程从根对象出发(全局对象、栈对象),逐步遍历和标记能够索引到的所有对象,最后将没有标记到的对象清除,回收内存。golang的三色标记法具体逻辑如下:

1. 将所有对象标记为白色。

2. 将根对象标记为灰色,并加入待处理队列。

3. 从待处理队列中取出一个对象,标记为黑色,并将这个对象引用的所有白色对象都标记为灰色并加入待处理队列。

4. 循环执行第3步,直到待处理队列为空,不存在灰色对象为止。

5. 回收所有白色对象。

这里的三色对应的对象状态为:

白色:没有找到对象被引用位置

灰色:已找到对象被引用位置,还未分析对象引用的其他对象

黑色:已找到对象被引用位置,已经分析对象引用的其他对象

可见,三色中的灰色状态是一种中间状态,GC标记完成后,所有对象只能处于黑色和白色两种状态下,白色对象被回收,黑色对象被保留。

后台并发标记

为了避免GC长时间STW影响正常任务执行,golang将主要标记工作放在单独的G上执行。这些G可以和其他任务G一起被调度,并发执行,而不需要STW。

由于执行并发标记的G有多个,这些G需要并发的从全局GC队列中获取对象。为了减少这些G的并发冲突,golang在每个G中维护了两段buffer,用于批量缓存扫描对象,这样每个G可以从GC队列中批量获取对象,减少冲突。

后台并发清除

被清除的对象已经不可能再被访问,因此没有必要在STW中处理。golang使用一个专门的G来清除回收内存,这个G可以与其他G并发调度执行。

写屏障

写屏障是后台并发标记的重要补充。在非STW状态下执行标记,必然会出现已经扫描过的对象又引用了新对象的情况,这时新对象就没有被标记。写屏障解决了这个问题。写屏障打开后,对指针的修改操作会将指针的新旧值全部加入GC工作队列。并在并发标记完成后再进入STW最后检查一次GC工作队列,保证所有对象都在最新状态扫描标记过。最新版的golang使用了复合写屏障技术,避免了在最后阶段需要从根节点开始重新扫描一遍的工作。具体的原理可以参考 17503-eliminate-rescan,以及runtime/mbarrier.go的注释。

辅助GC

辅助GC是在mallocgc中执行一部分gc标记的机制。每分配一定数量的内存,就会在mallocgc中做一些gc标记工作。如果mallocgc分配了过多内存却没有完成足够多的标记工作,就会被挂起,直到其他GC工作线程完成了足够多的工作或GC结束时才会被唤醒。这个机制的目的是防止GC过程中mallocgc执行过快分配过多新内存,导致GC持续时间过长或无法完成。

GC触发条件

gc的起始函数是runtime.gcStart,但调用gcStart不一定会开始一轮GC。gcStart有一个gcTrigger类型的参数,需要验证trigger.test()为true时才会真正触发GC。而trigger有3种类型,分别对应3种触发GC的条件:

1. gcTriggerHeap。这种类型的触发条件是memstats.heap_live >= memstats.gc_trigger,即当前使用的内存量超过GC触发阈值。

而触发阈值memstats.gc_trigger,这个gc_trigger并不是根据gcpercent计算的下一次GC内存大小next_gc,而是基于一个持续估算更新的triggerRatio计算的值。triggerRatio的更新算法很复杂,主要在gcController.endCycle()中,笔者不太理解其原理,但triggerRatio的值一定在gcpercent/100的0.6~0.95倍之间,因此gc_trigger一定小于next_gc。

2. gcTriggerTime。这种类型的触发条件是当前时间与上次GC时间memstats.last_gc_nanotime间的时间间隔已经超过forcegcperiod(120s)。

3. gcTriggerCycle。这种类型的trigger中会指定一个GC执行轮数,触发条件是指定的轮数n大于已执行过的GC轮数work.cycles。即如果已执行的GC次数没有达到指定次数,则触发一次GC。

而与上面的3类trigger对应,调用gcStart开始一次GC的位置有3处:

1. 内存分配函数runtime.mallocgc中。mallocgc是goruntime中唯一的堆分配函数,在函数的末尾,会根据此次调用是否真正申请了新的堆内存来决定是否需要触发GC。这里使用的trigger类型是gcTriggerHeap。如前文所述,这里触发GC的条件是堆内存的用量超过一个阈值,这个阈值是由上一次GC结束后堆内存的大小乘以一个系数计算而来的。大部分文章都认为这个系数是1 gcpercent/100,但根据前文的分析,这个系数事实上是由更复杂的算法得出的,会略小于1 gcpercent/100。gcpercent的初始值来自于GOGC环境变量,默认为100。也就是说,如果堆内存用量达到上一次GC结束时用量的两倍,就会触发下一次GC。gcpercent可以通过debug.SetGCPercent接口动态调整。通过这种方式,go runtime达到了两个目的:(1)内存使用量控制在一个较稳定的范围;(2)GC触发不会太过频繁。

2. forcegchelper定时触发。forcegchelper是在runtime/proc.go的init函数中创建的一个goroutine(G),专门用于定时触发GC。这个G由sysmon定时触发执行,触发间隔为120秒。这里使用的trigger类型是gcTriggerTime。

3. runtime.GC()接口调用触发。在程序中调用这个接口会强制触发一次GC,而这个调用需要等到下一次GC完成后才会返回,而不是它触发的这次GC完成。原因设计者希望显示调用GC的效果可以在函数返回后立刻通过heap profile看到,而下一次GC完成后这次GC的结果才会体现在heap profile中(heap profile原理可以参考https://blog.csdn.net/dillanzhou/article/details/107032180)。这里使用的trigger类型是gcTriggerCycle。

GC过程

为了避免长时间停止任务运行,golang将GC过程分为多个步骤,其中一些步骤(mark、sweep)是可以和任务goroutine并发执行的。目的是尽可能减少需要完全停止任务运行(stop the world)的时间。通过debug.gcstoptheworld可以控制GC的方式,如果debug.gcstoptheworld=1则标记(mark)阶段会完全停止任务运行,如果debug.gcstoptheworld=2则标记(mark)和清除(sweep)阶段都会完全停止任务运行。默认情况下,debug.gcstoptheworld=0。我们也主要分析这种情况下的GC流程。

gcStart:初始化与启动

gcStart负责GC的舒适化,并启动后台标记工作,返回时会将GC阶段修改为_GCmark状态。具体执行流程如下:

1. 调用gcBgMarkStartWorkers为每个P启动一个gcBgMarkWorker的G,用于标记全局和每个P上分配的内存。gcBgMarkWorker启动之后是可以长期存在的,因此不会每次执行GC都去创建,但在一些条件下部分gcBgMarkWorker会退出,这时gcStart会重新启动一个。gcStart会等待这些G全部创建完成再进入下一步。但这些G不会立刻开始执行标记,而是要等到标记(mark)阶段后被gcController.findRunnableGCWorker唤醒执行。gcController.findRunnableGCWorker在runtime.schedule中被调用,如果正处于GC状态且worker数量没有达到阈值,就会运行gcBgMarkWorker。

2. stopTheWorldWithSema,停止所有任务。

3. gcController.startCycle()初始化一些GC执行参数,其中主要包括gcControllerState.dedicatedMarkWorkersNeeded和gcControllerState.fractionalUtilizationGoal。这两个参数用于计算和设置每个P上的gcBgMarkWorker是独占P运行的还是分时间片运行的。设置的原则和目的是控制GC标记占用的CPU比例,这个比例由全局常量const gcBackgroundUtilization = 0.25指定,即不超过25%。

3. setGCPhase(_GCmark),将GC阶段修改为_GCmark状态。setGCPhase除了修改状态外,更重要的是启动写屏障(write barrier)。如果状态为_GCmark/_GCmarktermination,写屏障就会打开。

4. gcMarkRootPrepare,收集根节点信息,计算出根节点标记的任务量,记录在work.markrootJobs中。

5. gcMarkTinyAllocs,完成每个P的小型对象内存标记。gcMarkTinyAllocs会将每个P使用本地缓存mcache分配的小型数据结构地址标记为灰色并加入到p.gcw队列中。

6. atomic.Store(&gcBlackenEnabled, 1),设置gcBlackenEnabled。设置了这个状态后,会产生如下影响:

  • mallocgc分配内存时,会触发辅助GC(mutator assist)。
  • 操作mspan时(allocSpan/freeSpan/cacheSpan)会执行gcController.revise(),更新辅助GC的比例参数。
  • 调度器会尝试调度运行gcBgMarkWorker。

7. startTheWorldWithSema,继续执行任务。

gcBgMarkWorker:并发后台标记worker

gcBgMarkWorker在每个P上作为一个独立的G并发运行,完成内存对象的标记工作。gcBgMarkWorker不会和普通的工作任务共同调度,而是有其独立的调度逻辑。需要注意的是gcBgMarkWorker一般不会在一次GC标记结束后就退出,而是会继续等待下一次GC工作。下面介绍的是在一轮GC中gcBgMarkWorker的功能逻辑。

gcBgMarkWorker有三种工作模式,分别是:

1. gcMarkWorkerDedicatedMode,独占模式。

2. gcMarkWorkerFractionalMode,时间片模式。

3. gcMarkWorkerIdleMode,空闲模式。

当已运行的gcBgMarkWorker数量没有达到gcControllerState.dedicatedMarkWorkersNeeded设定的阈值时使用gcMarkWorkerDedicatedMode模式。如果dedicatedMarkWorkersNeeded达到阈值,但fractionalUtilizationGoal阈值还没有达到则运行gcMarkWorkerFractionalMode。如果两个阈值都已达到,gcController.findRunnableGCWorker就不会调度执行gcBgMarkWorker,但是在findrunnable的最后阶段,如果没有找到任何可执行的G,那么就会以gcMarkWorkerIdleMode模式调度执行gcBgMarkWorker。因此并发运行的worker数量并不是P的数量,而是0.25*P左右,加上没有任务需要执行的P。

gcBgMarkWorker以指定模式运行gcDrain执行内存对象标记。

完成标记后,会判断是否已经完成所有标记工作,判断的条件是其他gcBgMarkWorker和辅助GC是否已经都已完成标记,而且没有剩余的对象需要标记。这里判断其他worker完成的依据是work.nwait==work.nproc,每个worker或辅助GC worker在开始工作前会将work.nwait减1,完成标记工作后都会将work.nwait加1。如果还有worker没有完成,则当前worker的工作结束。如果其他worker都已完成,则说明第一阶段的后台标记工作已全部完成,当前gcBgMarkWorker会调用gcMarkDone开始下一阶段工作。

gcDrain:后台标记

gcDrain主要完成两个步骤的工作:

1. 循环调用markroot处理和标记根节点。所有worker会先并发完成根节点标记任务。根节点标记任务分成work.markrootJobs份,所有worker并发获取任务并执行,直到没有markroot任务待处理后执行下一步骤。

markroot中的根节点有很多类型,因此每份任务处理的节点类型可能各不相同,处理类型包括:

  • RootFinalizers:finalizer相关内存

  • RootFreeGStacks:已经结束的G的栈

  • FlushCache:每个P的本地mcache。奇怪的是这个类型的标记并没有真正执行,因为初始化的work.nFlushCacheRoots=0。也没有找到其他位置会执行mcache的标记,不确定是mcache不需要标记还是有其他方式实现了(例如gcMarkTinyAllocs)?

  • Data:每个活跃模块的数据段

  • BSS:每个活跃模块的BSS段

  • Spans:finalizer使用的特殊mspan

  • Stacks:每个G的栈

2. 循环调用gcWork.tryGet和scanobject处理和标记灰色节点。

gcWork.tryGet:从队列中获取一个待扫描的灰色对象

scanobject:扫描灰色对象,将扫描发现的引用对象标记并加入队列中。(如果对象已经被标记过则跳过)

每个mspan中有一个gcmarkBits对象用于标记mspan中的每个对象是否需要保留,可以看到标记其实只有两个状态,表示白色(0)和黑色(1)。灰色状态表示标记为1的对象正处于扫描队列中,并没有一个具体的数值表示这个状态。

gcMarkDone:完成并发标记

gcMarkDone在所有gcBgMarkWorker标记完成后执行,负责检查标记完成状态。其执行过程为:

1. 使用forEachP在每个P上执行wbBufFlush1和_p_.gcw.dispose。wbBufFlush1将P缓存的写屏障待扫描对象加入P的GC工作队列。_p_.gcw.dispose将扫描对象从P的GC队列移动到全局GC队列中。如果这时发现还有对象待扫描,则会放弃这次gcMarkDone。如果没有对象待扫描,进入下一步。

2. stopTheWorldWithSema。这里会再检查一次从上一步到这一步之间是否产生了新的写屏障待扫描对象,如果不幸产生了,则startTheWorldWithSema,放弃这次gcMarkDone。否则,可以确认标记彻底完成。

3. atomic.Store(&gcBlackenEnabled, 0),停止标记工作

4. gcWakeAllAssists,唤醒所有被挂起的辅助GC的G。

5. gcMarkTermination。

gcMarkTermination:结束标记,开始清除

gcMarkTermination负责完成最后的标记工作,并启动清除工作。其执行流程为:

1. atomic.Store(&gcBlackenEnabled, 0),这里似乎与gcMarkDone中重复了?不确定是否有意义。

2. setGCPhase(_GCmarktermination),将gcphase设置为_GCmarktermination状态。

3. gcMark,最后标记一次当前G。

4. setGCPhase(_GCoff),将gcphase设置为_GCoff状态,并关闭写屏障。

5. gcSweep,开始清除无效对象,将内存释放回heap。

6. 执行一些状态更新和gc统计值收集工作。

7. startTheWorldWithSema。

8. prepareFreeWorkbufs,将GC使用的mspan结构加入待释放链表,后续将被bgsweep释放。

9. freeStackSpans,释放未使用的stack span。

10. forEachP(_p_.mcache.prepareForSweep),清空每个p的mcache供后续回收。

gcSweep:清除对象,释放内存

清除和标记一样,有阻塞模式(调试用)和后台并行模式(日常实用模式)。在后台模式下,gcSweep只需要唤醒sweep.g即可。sweep.g在main函数执行时初始化,就是bgsweep函数。bgsweep启动后一直在主循环中等待,每次被唤醒后就执行一次内存回收操作,之后继续循环等待。

bgsweep循环调用sweepone释放mspan直到没有可释放的mspan,然后循环调用freeSomeWbufs释放GC过程中使用的workbuf结构。全部释放完成后这一轮GC完全结束。

总结

通过多阶段的并发标记和复合写屏障,golang GC最大程度的减少了GC过程中STW的时间,并通过多种机制控制了并发GC的CPU占用率以及GC执行时间。最后,通过一张流程图来总结一轮GC执行的关键流程:

在上文介绍的GC流程中,仍然有许多不清楚不确定的地方。GC是golang内存管理机制的一部分,详细的GC逻辑和整个内存管理细节是强相关的。在没有对golang内存管理有清晰认识的情况下,对GC的分析必然是不完整的。这部分的细化需要在学习了golang的内存管理原理和实现后再补充。

到此这篇关于“golang gc实现分析(go1.14.4)”的文章就介绍到这了,更多文章或继续浏览下面的相关文章,希望大家以后多多支持JQ教程网!

您可能感兴趣的文章:
golang gc实现分析(go1.14.4)
Golang GC原理
go gc原理
golang垃圾回收
Golang垃圾回收机制
golang 释放内存机制的探索
Golang中的垃圾回收算法
图解Golang的GC算法
go 缓存机制
golang 的GC原理

[关闭]
~ ~