Go的GC自打出生的时候就开始被人诟病,但是在引入v1.5的三色标记和v1.8的混合写屏障后,正常的GC已经缩短到10us左右,已经变得非常优秀,了不起了,我们接下来探索一下Go的GC的原理吧
三色标记原理
我们首先看一张图,大概就会对 三色标记法 有一个大致的了解:
原理:
- 首先把所有的对象都放到白色的集合中
- 从根节点开始遍历对象,遍历到的白色对象从白色集合中放到灰色集合中
- 遍历灰色集合中的对象,把灰色对象引用的白色集合的对象放入到灰色集合中,同时把遍历过的灰色集合中的对象放到黑色的集合中
- 循环步骤3,知道灰色集合中没有对象
- 步骤4结束后,白色集合中的对象就是不可达对象,也就是垃圾,进行回收
写屏障
Go在进行三色标记的时候并没有STW,也就是说,此时的对象还是可以进行修改
那么我们考虑一下,下面的情况
我们在进行三色标记中扫描灰色集合中,扫描到了对象A,并标记了对象A的所有引用,这时候,开始扫描对象D的引用,而此时,另一个goroutine修改了D->E的引用,变成了如下图所示
这样会不会导致E对象就扫描不到了,而被误认为 为白色对象,也就是垃圾
写屏障就是为了解决这样的问题,引入写屏障后,在上述步骤后,E会被认为是存活的,即使后面E被A对象抛弃,E会被在下一轮的GC中进行回收,这一轮GC中是不会对对象E进行回收的
Go1.9中开始启用了混合写屏障,伪代码如下
1 | writePointer(slot, ptr): |
混合写屏障会同时标记指针写入目标的”原指针”和“新指针”.
标记原指针的原因是, 其他运行中的线程有可能会同时把这个指针的值复制到寄存器或者栈上的本地变量
因为复制指针到寄存器或者栈上的本地变量不会经过写屏障, 所以有可能会导致指针不被标记, 试想下面的情况:
1 | [go] b = obj |
标记新指针的原因是, 其他运行中的线程有可能会转移指针的位置, 试想下面的情况:
1 | [go] a = ptr |
混合写屏障可以让GC在并行标记结束后不需要重新扫描各个G的堆栈, 可以减少Mark Termination中的STW时间
除了写屏障外, 在GC的过程中所有新分配的对象都会立刻变为黑色, 在上面的mallocgc函数中可以看到
回收流程
GO的GC是并行GC, 也就是GC的大部分处理和普通的go代码是同时运行的, 这让GO的GC流程比较复杂.
首先GC有四个阶段, 它们分别是:
- Sweep Termination: 对未清扫的span进行清扫, 只有上一轮的GC的清扫工作完成才可以开始新一轮的GC
- Mark: 扫描所有根对象, 和根对象可以到达的所有对象, 标记它们不被回收
- Mark Termination: 完成标记工作, 重新扫描部分根对象(要求STW)
- Sweep: 按标记结果清扫span
下图是比较完整的GC流程, 并按颜色对这四个阶段进行了分类:
在GC过程中会有两种后台任务(G), 一种是标记用的后台任务, 一种是清扫用的后台任务.
标记用的后台任务会在需要时启动, 可以同时工作的后台任务数量大约是P的数量的25%, 也就是go所讲的让25%的cpu用在GC上的根据.
清扫用的后台任务在程序启动时会启动一个, 进入清扫阶段时唤醒.
目前整个GC流程会进行两次STW(Stop The World), 第一次是Mark阶段的开始, 第二次是Mark Termination阶段.
第一次STW会准备根对象的扫描, 启动写屏障(Write Barrier)和辅助GC(mutator assist).
第二次STW会重新扫描部分根对象, 禁用写屏障(Write Barrier)和辅助GC(mutator assist).
需要注意的是, 不是所有根对象的扫描都需要STW, 例如扫描栈上的对象只需要停止拥有该栈的G.
写屏障的实现使用了Hybrid Write Barrier, 大幅减少了第二次STW的时间.
源码分析
gcStart
1 | func gcStart(mode gcMode, trigger gcTrigger) { |
gcBgMarkStartWorkers
这个函数准备一些 执行bg mark工作的goroutine,但是这些goroutine并不是立即工作的,而是到等到GC的状态被标记为gcMark 才开始工作,见上个函数的119行
1 | func gcBgMarkStartWorkers() { |
gcBgMarkWorker
后台标记任务的函数
1 | func gcBgMarkWorker(_p_ *p) { |
gcDrain
三色标记的主要实现
gcDrain扫描所有的roots和对象,并表黑灰色对象,知道所有的roots和对象都被标记
1 | func gcDrain(gcw *gcWork, flags gcDrainFlags) { |
markroot
这个被用于根对象扫描
1 | func markroot(gcw *gcWork, i uint32) { |
markRootBlock
根据 ptrmask0,来扫描[b0, b0+n0)区域
1 | func markrootBlock(b0, n0 uintptr, ptrmask0 *uint8, gcw *gcWork, shard int) { |
scanblock
1 | func scanblock(b0, n0 uintptr, ptrmask *uint8, gcw *gcWork) { |
greyobject
标灰对象其实就是找到对应bitmap,标记存活并扔进队列
1 | func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) { |
gcWork.putFast
work有wbuf1 wbuf2两个队列用于保存灰色对象,首先会往wbuf1队列里加入灰色对象,wbuf1满了后,交换wbuf1和wbuf2,这事wbuf2便晋升为wbuf1,继续存放灰色对象,两个队列都满了,则想全局进行申请
putFast这里进尝试将对象放进wbuf1队列中
1 | func (w *gcWork) putFast(obj uintptr) bool { |
gcWork.put
put不仅尝试将对象放入wbuf1,还会再wbuf1满的时候,尝试更换wbuf1 wbuf2的角色,都满的话,则想全局进行申请,并将满的队列上交到全局队列
1 | func (w *gcWork) put(obj uintptr) { |
到这里,接下来,我们继续分析gcDrain里面的函数,追踪一下,我们标灰的对象是如何被标黑的
gcw.balance()
继续分析 gcDrain的58行,balance work是什么
1 | func (w *gcWork) balance() { |
gcw.get()
继续分析 gcDrain的63行,这里就是首先从本地的队列获取一个对象,如果本地队列的wbuf1没有,尝试从wbuf2获取,如果两个都没有,则尝试从全局队列获取一个满的队列,并获取一个对象
1 | func (w *gcWork) get() uintptr { |
gcw.tryGet()
gcw.tryGetFast()
逻辑差不多,相对比较简单,就不继续分析了
scanobject
我们继续分析到 gcDrain 的L76,这里已经获取到了b,开始消费队列
1 | func scanobject(b uintptr, gcw *gcWork) { |
综上,我们可以发现,标灰就是标记并放进队列,标黑就是标记,所以当灰色对象从队列中取出后,我们就可以认为这个对象是黑色对象了
至此,gcDrain的标记工作分析完成,我们继续回到gcBgMarkWorker分析
gcMarkDone
gcMarkDone会将mark1阶段进入到mark2阶段, mark2阶段进入到mark termination阶段
mark1阶段: 包括所有root标记,全局缓存队列和本地缓存队列
mark2阶段:本地缓存队列会被禁用
1 | func gcMarkDone() { |
gcMarkTermination
结束标记,并进行清扫等工作
1 | func gcMarkTermination(nextTriggerRatio float64) { |
goSweep
清扫任务
1 | func gcSweep(mode gcMode) { |
sweepone
接下来我们就分析一下sweepone 清扫的流程
1 | func sweepone() uintptr { |
mspan.sweep
1 | func (s *mspan) sweep(preserve bool) bool { |
ok,至此Go的GC流程已经分析完成了,结合最上面开始的图,可能会容易理解一点
参考文档
- Golang源码探索(三) GC的实现原理
- 《Go语言学习笔记》
- 一张图了解三色标记法