Go语言GC实现原理及源码分析
2023-11-14
| 2023-11-23
0  |  阅读时长 0 分钟
Created time
Nov 13, 2023 04:45 PM
date
status
category
Origin
summary
tags
type
URL
icon
password
slug
转载请声明出处哦~,本篇文章发布于luozhiyun的博客:https://www.luozhiyun.com/archives/475
本文使用的 Go 的源码1.15.7
在线运行GO语言短代码:https://www.bejson.com/runcode/golang/
垃圾回收处理的是对象,那什么是对象呢?在 Go 语言中,所谓的“对象”通常指的是在内存中分配的数据实体。这些对象可以是基本数据类型(如整数、字符串)、复合数据类型(如结构体、数组、切片、映射)、或是函数和方法。在 Go 的垃圾回收(GC)上下文中,特别指的是那些被分配在堆上的数据实体,因为它们需要由垃圾回收器管理。这些对象在 Go 语言的内存管理和垃圾回收中扮演着关键角色。这个过程帮助 Go 运行时有效地管理内存,确保不再被使用的内存得以释放,并减少内存泄漏的风险。
需要注意的是,垃圾回收器的运行是由 Go 运行时环境控制的,通常是自动进行的,而不需要程序员直接干预。在实际的 Go 程序中,很少需要直接与垃圾回收器交互。

介绍

三色标记法

三色标记法将对象的颜色分为了黑、灰、白,三种颜色。
  • 黑色:该对象已经被标记过了,且该对象下的属性也全部都被标记过了(程序所需要的对象),并确认为需要保留的对象;
  • 灰色:该对象已经被标记过了,但该对象下的属性没有全被标记完(GC需要从此对象中去寻找垃圾)。表示被标记过但尚未被完全扫描的对象,可能包含指向尚未发现的白色对象的引用;
  • 白色:该对象没有被标记过(对象垃圾)。白色对象则是尚未被标记的对象,如果在垃圾回收过程结束时仍然是白色,那么它们会被视为垃圾并被清理;
在垃圾收集器开始工作时,从 GC Roots 开始进行遍历访问,访问步骤可以分为下面几步:
  1. GC Roots 根对象会被标记成灰色;
  1. 然后从灰色集合中获取对象,将其标记为黑色,将该对象引用到的对象标记为灰色;
  1. 重复步骤2,直到没有灰色集合可以标记为止;
  1. 结束后,剩下的没有被标记的白色对象即为 GC Roots 不可达,可以进行回收。
流程大概如下:
notion image
下面我们来说说三色标记法会存在的问题。

三色标记法所存在问题

多标-浮动垃圾问题

假设 E 已经被标记过了(变成灰色了),此时 D 和 E 断开了引用,按理来说对象 E/F/G 应该被回收的,但是因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。
这部分本应该回收 但是没有回收到的内存,被称之为“浮动垃圾”。过程如下图所示:
notion image

漏标-悬挂指针问题

除了上面多标的问题,还有就是漏标问题。当 GC 线程已经遍历到 E 变成灰色,D变成黑色时,灰色 E 断开引用白色 G ,黑色 D 引用了白色 G。此时切回 GC 线程继续跑,因为 E 已经没有对 G 的引用了,所以不会将 G 放到灰色集合。尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:G 会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的,这也是 Go 需要在 GC 时解决的问题。
notion image

内存屏障

为了解决上面的悬挂指针问题,我们需要引入屏障技术来保障数据的一致性。
内存屏障,是一种屏障指令,它能使CPU或编译器对在该屏障指令之前和之后发出的内存操作强制执行排序约束,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。
那么为了在标记算法中保证正确性,那么我们需要达成下面任意一个条件:
  • 强三色不变性(strong tri-color invariant):黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性(weak tri-color invariant):即便黑色对象指向白色对象,那么从灰色对象出发,总存在一条可以找到该白色对象的路径;
根据操作类型的不同,我们可以将内存屏障分成 Read barrier(读屏障)和 Write barrier(写屏障)两种,在 Go 中都是使用 Write barrier(写屏障),原因在《Uniprocessor Garbage Collection Techniques》也提到了:
对于一个不需要对象拷贝的垃圾回收器来说, Read barrier(读屏障)代价是很高的,因为对于这类垃圾回收器来说是不需要保存读操作的版本指针问题。相对来说 Write barrier(写屏障)代码更小,因为堆中的写操作远远小于堆中的读操作。
来下面我们看看 Write barrier(写屏障)是如何做到这一点的。

Dijkstra Write barrier

Go 1.7 之前使用的是 Dijkstra Write barrier(写屏障),使用的实现类似下面伪代码:
如果该对象是白色的话,shade(ptr)会将对象标记成灰色。这样可以保证强三色不变性,它会保证 ptr 指针指向的对象在赋值给 *slot 前不是白色。
如下,根对象指向的 D 对象标记成黑色并将 D 对象指向的对象 E 标记成灰色;如果 D 断开对 E 的引用,改成引用 B 对象,那么这时触发写屏障将 B 对象标记成灰色。
notion image
Dijkstra Write barrier虽然实现非常的简单,并且也能保证强三色不变性,但是在《Proposal: Eliminate STW stack re-scanning》中也提出了它具有一些缺点:
因为栈上的对象在垃圾收集中也会被认为是根对象,所以要么为栈上的对象增加写屏障,但这会大幅度增加写入指针的额外开销;要么当发生栈上的写操作时,将栈标记为恒灰(permagrey)。
Go 1.7 的时候选择的是将栈标记为恒灰,但需要在标记终止阶段 STW 时对这些栈进行重新扫描(re-scan)。原因如下所描述:

Yuasa Write barrier

Yuasa Write barrier 是 Yuasa 在《Real-time garbage collection on general-purpose machines》中提出的一种删除屏障(deletion barrier)技术。其思想是当赋值器从灰色或白色对象中删除白色指针时,通过写屏障将这一行为通知给并发执行的回收器。
该算法会使用如下所示的写屏障保证增量或者并发执行垃圾收集时程序的正确性,伪代码实现如下:
为了防止丢失从灰色对象到白色对象的路径,应该假设 *slot 可能会变为黑色, 为了确保 ptr 不会在被赋值到 slot 前变为白色,shade(slot) 会先将 *slot 标记为灰色, 进而该写操作总是创造了一条灰色到灰色或者灰色到白色对象的路径,这样删除写屏障就可以保证弱三色不变性,老对象引用的下游对象一定可以被灰色对象引用。
notion image

Hybrid write barrier

上面说了在 Go 1.7 之前使用的是 Dijkstra Write barrier(写屏障)来保证三色不变性。Go 在重新扫描的时候必须保证对象的引用不会改变,因此会进行暂停程序(STW)、将所有栈对象标记为灰色并重新扫描,这通常会消耗10~100 毫秒的时间。
通过 Proposal: Eliminate STW stack re-scanning https://go.googlesource.com/proposal/+/master/design/17503-eliminate-rescan.md 的介绍,可以知道为了消除重新扫描所带来的性能损耗,Go 在 1.8 的时候使用 Hybrid write barrier(混合写屏障),结合了 Yuasa write barrier 和 Dijkstra write barrier ,实现的伪代码如下:
这样做不仅简化 GC 的流程,同时减少标记终止阶段的重扫成本。混合写屏障的基本思想是:
翻译过来就是:对正在被覆盖的对象进行着色,且如果当前栈未扫描完成, 则同样对指针进行着色。
同时,在GC的过程中所有新分配的对象都会立刻变为黑色,在内存分配的时候 go\src\runtime\malloc.go 的 mallocgc 函数中可以看到:
在垃圾收集的标记阶段,将新建的对象标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收。

分析

GC phase 垃圾收集阶段

GC 相关的代码在runtime/mgc.go文件下。通过注释介绍我们可以知道 GC 一共分为4个阶段:
    1. sweep termination(清理终止)
    2. 会触发 STW ,所有的 P(处理器) 都会进入 safe-point(安全点);
    3. 清理未被清理的 span ,不知道什么是 span 的同学可以看看我的:详解Go中内存分配源码实现 https://www.luozhiyun.com/archives/434;
    1. the mark phase(标记阶段)
    2. _GCoff GC 状态 改成 _GCmark,开启 Write Barrier (写入屏障)、mutator assists(协助线程),将根对象入队;
    3. 恢复程序执行,mark workers(标记进程)和 mutator assists(协助线程)会开始并发标记内存中的对象。对于任何指针写入和新的指针值,都会被写屏障覆盖,而所有新创建的对象都会被直接标记成黑色;
    4. GC 执行根节点的标记,这包括扫描所有的栈、全局对象以及不在堆中的运行时数据结构。扫描goroutine 栈绘导致 goroutine 停止,并对栈上找到的所有指针加置灰,然后继续执行 goroutine。
    5. GC 在遍历灰色对象队列的时候,会将灰色对象变成黑色,并将该对象指向的对象置灰;
    6. GC 会使用分布式终止算法(distributed termination algorithm)来检测何时不再有根标记作业或灰色对象,如果没有了 GC 会转为mark termination(标记终止);
    1. mark termination(标记终止)
    2. STW,然后将 GC 阶段转为 _GCmarktermination,关闭 GC 工作线程以及 mutator assists(协助线程);
    3. 执行清理,如 flush mcache;
    1. the sweep phase(清理阶段)
    2. 将 GC 状态转变至 _GCoff,初始化清理状态并关闭 Write Barrier(写入屏障);
    3. 恢复程序执行,从此开始新创建的对象都是白色的;
    4. 后台并发清理所有的内存管理单元
需要注意的是,上面提到了 mutator assists,因为有一种情况:
因为 GC 标记的工作是分配 25% 的 CPU 来进行 GC 操作,所以有可能 GC 的标记工作线程比应用程序的分配内存慢,导致永远标记不完,那么这个时候就需要应用程序的线程来协助完成标记工作:

下次 GC 时机

下次 GC 的时机可以通过一个环境变量 GOGC 来控制,默认是 100 ,即增长 100% 的堆内存才会触发 GC。
官方的解释是,如果当前使用了 4M 内存,那么下次 GC 将会在内存达到 8M 的时候。下面我们看看一个具体的例子:
需要注意的是,大家在做实验的时候推荐使用 Linux 环境,如果没有 Linux 环境可以像我一样在 win10 下跑了一个虚拟机,然后用 vscode 远程到 Linux 进行实验的,大家不妨试一下。
编译好之后,可以使用 gctrace 跟踪 GC 情况:
上面展示了 3 次 GC 的情况,下面我们看看具体的含义是什么:
notion image
从上面的 GC 内存信息中可以看到,在 GC 标记开始之前的时候堆大小是 4MB,由于标记工作是并发进行的,所以当标记完成的时候堆中被使用的大小是 5MB,这表示有 1MB 的内存分配是发生在 GC 期间。最后我们可以看到 GC 标记完之后存活的堆大小只有 1MB,这也表示可以在堆占用内存达到 2MB 的时候开始下一轮 GC。
从上面我们可以看到 Goal 部分的内存大小是 5MB,和实际的 In-Use After 部分的内存占用情况相等,但是在很多复杂的情况下是不相等的,因为 Goal 部分的内存大小是基于当前内存的使用情况进行推算的。

触发 GC 条件

触发 GC 条件是由 gcTrigger.test来进行校验的,下面我们看看 gcTrigger.test如何判定是否需要触发垃圾收集:
gcTriggerTime 的触发时间是由 forcegcperiod 决定的,默认是2分钟。下面我们主要看看堆内存大小触发 GC 的情况。
gcTriggerHeap 堆内存的分配达到达控制器计算的触发堆大小,heap_live 值会在内存分配的时候进行计算,gc_trigger 的计算是在 runtime.gcSetTriggerRatio函数中进行的。
gcSetTriggerRatio 函数会根据计算出来的 triggerRatio 来获取下次触发 GC 的堆大小是多少。triggerRatio 每次GC后都会调整,计算 triggerRatio 的函数是 gcControllerState.endCycle中进行的,gcControllerState.endCycle 会在 MarkDone 中被调用的。
对于 triggerRatio 总体来说还是比较复杂的,我们可以根据偏离值来得知:
  • 实际增长率越大, 触发系数偏移值越小, 小于0时下次触发GC会提早;
  • CPU占用率越大, 触发系数偏移值越小, 小于0时下次触发GC会提早;
  • 原触发系数越大, 触发系数偏移值越小, 小于0时下次触发GC会提早;
通过上面的分析,也解释了为什么在 GODEBUG=gctrace=1分析中明明堆内存还没达到 2倍却被提前执行了,主要还是受 triggerError 偏移量的影响导致的。

开始 GC

我们在测试的时候可以调用 runtime.GC来手动的触发 GC。但实际上,触发 GC 的入口一般不会手动调用。正常触发 GC 应该是在申请内存时会调用 runtime.mallocgc或者是 Go 后台的监控线程 sysmon 定时检查调用 runtime.forcegchelper
  1. 首先会获取 GC 的循环次数,然后调用 gcWaitOnMark 等待上一个循环的标记终止、标记和清除终止阶段完成;
  1. 调用 gcStart 触发新一轮的 GC,并且会调用 gcWaitOnMark 等待当前的循环的标记终止、标记和清除终止阶段完成;
  1. 调用 sweepone 等待清理全部待处理的内存管理单元,然后调用 Gosched 让出 P;
  1. 完成本轮垃圾收集的清理工作后,调用 mProf_PostSweep 将该阶段的堆内存状态快照发布出来;

GC 启动

下图是比较完整的GC流程,可作为看源码时候的导航:
notion image
GC Start
gcStart 函数比较长,下面分段来看看 gcStart:
  1. 两次调用 trigger.test检查是否满足垃圾收集的条件,这个函数我们在上面讲过了;
  1. 调用 semacquire(&work.startSema) 上锁,调用 gcBgMarkStartWorkers启动后台标记任务,这个我们后面重点说;
  1. 对 work 结构体做初始化工作,设置垃圾收集需要的 Goroutine 数量以及已完成的GC 次数等;
  1. 在开始 GC 之前调用 gcController.startCycle 清理控制器的状态,标记新一轮GC已开始;
  1. 调用 setGCPhase 设置全局变量中的GC状态为 _GCmark ,然后启用写屏障;
  1. 调用 gcBgMarkPrepare 初始化后台扫描需要的状态;
  1. 调用 gcMarkRootPrepare 将扫描栈上、全局变量等根对象并将它们加入队列;
  1. 调用 gcMarkTinyAllocs 标记所有 tiny alloc 内存块;
  1. 设置 gcBlackenEnabled ,启用 mutator assists(协助线程);
  1. 记录完标记开始的时间后,调用 startTheWorldWithSema 启动程序,后台任务也会开始标记堆中的对象;
下面这张图显示了 gcStart 过程中状态变化,以及 STW 停顿的方法,写屏障启用的周期:
notion image
GC Start2
上面只是粗略的说一下各个函数的作用,下面来分析一些重要的函数。

startCycle

这里需要注意的是 dedicatedMarkWorkersNeeded 与 fractionalUtilizationGoal 的计算过程,这个会在计算 work 工作模式的用到。

标记 tiny alloc

tiny block 这个数据结构也在内存分配那一节讲过了,这里主要是会把所有 P 中的 mcache 中的 tiny 找到并进行标记,然后把它加到 gcwork 标记队列,至于什么是 gcwork 标记队列,我们下面在执行标记的地方会讲到。

write Barrier 写屏障

在设置 GC 阶段标记的时候会根据当前的设置的值来判断是否需要开启 write Barrier :
编译器会在src\cmd\compile\internal\ssa\writebarrier.go中调用 writebarrier 函数,就如同它的注释所说:
在执行 Store, Move, Zero 等汇编操作的时候加入写屏障。
我们可以通过 dlv 断点找到 gcWriteBarrier 汇编代码的位置在 go/src/runtime/asm_amd64.s:1395。该汇编函数会调用 runtime.wbBufFlush将 write barrier 的缓存任务添加到 GC 的工作队列中进行处理。
写屏障这里其实也是和并发标记是一样的套路,可以看完并发标记再过来看。wbBufFlush1 会遍历write barrier 缓存,然后调用 findObject 查找到对象之后使用标志位进行标记,最后将对象加入到 gcWork队列中进行扫描,并 重置 write barrier 缓存。

stopTheWorldWithSema 与 startTheWorldWithSema

stopTheWorldWithSema 与 startTheWorldWithSema 是一对用于暂停和恢复程序的核心函数。
这个方法会通过 sched.stopwait来检测是否所有的 P 都已暂停。首先会通过调用 preemptall 发送抢占信号进行抢占所有运行中的 G,然后遍历 P 将所有状态为 _Psyscall、空闲的 P 都暂停,如果仍有需要停止的P, 则等待它们停止。
startTheWorldWithSema 就显得简单的多,首先从 netpoller 中获取待处理的任务并加入全局队列;然后遍历 P 链表,唤醒有可运行任务的P。

创建后台标记 Worker

gcBgMarkStartWorkers 会为全局每个 P 创建用于执行后台标记任务的 Goroutine,每一个 Goroutine 都会运行 gcBgMarkWorker,notetsleepg 会等待 gcBgMarkWorker 通知信号量 bgMarkReady 再继续。
这里虽然为每个 P 启动了一个后台标记任务, 但是可以同时工作的只有 25%,调度器在调度循环 runtime.schedule中通过调用 gcController.findRunnableGCWorker方法进行控制。
在看这个方法之前,先来了解一个概念, Mark Worker Mode 标记工作模式,目前来说有三种,这三种是为了保证后台的标记线程的利用率。
通过代码注释可以知道:
  • gcMarkWorkerDedicatedMode :P 专门负责标记对象,不会被调度器抢占;
  • gcMarkWorkerFractionalMode:主要是由于现在默认标记线程的占用率要为 25%,所以如果 CPU 核数不是4的倍数,就无法除得整数,启动该类型的工作模式帮助垃圾收集达到利用率的目标;
  • gcMarkWorkerIdleMode:表示 P 当前只有标记线程在跑,没有其他可以执行的 G ,它会运行垃圾收集的标记任务直到被抢占;
在 findRunnableGCWorker 会通过 dedicatedMarkWorkersNeeded 来决定是否采用 gcMarkWorkerDedicatedMode 的 Mark Worker Mode 标记工作模式。dedicatedMarkWorkersNeeded 是在 gcControllerState.startCycle中进行初始化。
公式我就不贴了,在 gcControllerState.startCycle已经讲过了,通俗来说如果当前是 8 核 CPU,那么 dedicatedMarkWorkersNeeded 为 2 ,如果是 6 核 CPU,因为无法被 4 整除,计算得 dedicatedMarkWorkersNeeded 为 1,所以需要上面得 gcMarkWorkerFractionalMode 模式来保证 CPU 的利用率。
gcMarkWorkerIdleMode 会在调度器执行 findrunnable 抢占的时候调用:
看过我的《详解Go语言调度循环源码实现》的同学应该都知道,抢占调度运行到这里的时候,通常是 P 抢占不到 G 了,打算进行休眠了,因此在休眠之前可以安全的进行标记任务的执行。
没看过调度循环的同学可以看这里:详解Go语言调度循环源码实现 https://www.luozhiyun.com/archives/448

并发扫描标记

并发扫描标记可以大概概括为以下几个部分:
  1. 将当前传入的 P 打包成 parkInfo ,然后调用 gopark 让当前 G 进入休眠,在休眠前会将 P 的 gcBgMarkWorker 与 G 进行绑定,等待唤醒;
  1. 根据 Mark Worker Mode 调用不同的策略调用 gcDrain 执行标记;
  1. 判断是否所有后台标记任务都完成, 并且没有更多的任务,调用 gcMarkDone 准备进入完成标记阶段;

后台标记休眠等待

在 gcBgMarkStartWorkers 中我们看到,它会遍历所有的 P ,然后为每个 P 创建一个负责 Mark Work 的 G,这里虽然为每个 P 启动了一个后台标记任务, 但是不可能每个 P 都会去执行标记任务,后台标记任务默认资源占用率是 25%,所以 gcBgMarkWorker 中会初始化 park 并将 G 和 P 的 gcBgMarkWorker 进行绑定后进行休眠。
调度器在调度循环 runtime.schedule中通过调用 gcController.findRunnableGCWorker方法进行控制,让哪些 Mark Work 可以执行,上面代码已经贴过了,这里就不重复了。
notion image

后台标记

在唤醒后,我们会根据 gcMarkWorkerMode 选择不同的标记执行策略,不同的执行策略都会调用 runtime.gcDrain :
在上面已经讲了不同的 Mark Worker Mode 的区别,不记得的同学可以往上翻一下。执行标记这部分主要在 switch 判断中,根据不同的模式传入不同的参数到 gcDrain 函数中执行。
需要注意的是,传入到 gcDrain 中的是一个 gcWork 的结构体,它相当于每个 P 的私有缓存空间,存放需要被扫描的对象,为垃圾收集器提供了生产和消费任务的抽象,,该结构体持有了两个重要的工作缓冲区 wbuf1wbuf2
notion image
当我们向该结构体中增加或者删除对象时,它总会先操作 wbuf1 缓冲区,一旦 wbuf1 缓冲区空间不足或者没有对象,会触发缓冲区的切换,而当两个缓冲区空间都不足或者都为空时,会从全局的工作缓冲区中插入或者获取对象:
继续上面的 gcBgMarkWorker 方法,在标记完之后就要进行标记完成:
gcBgMarkWorker 会根据 incnwait 来检查是否是最后一个 worker,然后调用 gcMarkWorkAvailable 函数来校验 gcwork的任务和全局任务是否已经全部都处理完了,如果都确认没问题,那么调用 gcMarkDone 进入完成标记阶段。

标记扫描

下面我们来看看 gcDrain:
gcDrain 函数在开始的时候,会根据 flags 不同而选择不同的策略。
  • gcDrainUntilPreempt:当 G 被抢占时返回;
  • gcDrainIdle:调用 runtime.pollWork,当 P 上包含其他待执行 G 时返回;
  • gcDrainFractional:调用 runtime.pollFractionalWorkerExit,当 CPU 的占用率超过 fractionalUtilizationGoal 的 20% 时返回;
设置完 check 变量后就可以执行 runtime.markroot进行根对象扫描,每次扫描完毕都会调用 check 函数校验是否应该退出标记任务,如果是那么就跳到 done 代码块中退出标记。
完成标记后会获取待执行的任务:
这里在获取缓存队列之前会调用 runtime.gcWork.balance,会将 gcWork 缓存一部分工作放回全局队列中,这个方法主要是用来平衡一下不同 P 的负载情况。
然后获取 gcWork 的缓存任务,并将获取到的任务交给 scanobject 执行,该函数会从传入的位置开始扫描,并会给找到的活跃对象上色。runtime.gcFlushBgCredit 会记录这次扫描的内存字节数用于减少辅助标记的工作量。
这里我来总结一下 gcWork 出入队情况。gcWork 的出队就是我们上面的 scanobject 方法,会获取到 gcWork 缓存对象并执行,但是同时如果找到活跃对象也会再次的入队到 gcWork 中。
除了 scanobject 以外,写屏障、根对象扫描和栈扫描都会向 gcWork 中增加额外的灰色对象等待处理。
notion image
根标记
看到上面扫描的BSS和Date相关的内存块的时候我也是感到非常的疑惑,我们结合维基百科 Data segment https://en.wikipedia.org/wiki/Data_segment 的解释可以看到:
Data 段通常是提前被初始化的全局变量,BSS 段通常是没有被初始化的数据。
因为涉及到太多缓存、数据段、栈内存的扫,很多位操作和指针操作,相关代码实现比较复杂。下面简单看看 scanblock,scanstack。
scanblock
scanstack
greyobject
对象扫描

辅助标记 mutator assists

在分析的一开始也提到了一些关于 mutator assists 的作用,主要是为了防止 heap 增速太快, 在GC 执行的过程中如果同时运行的 G 分配了内存, 那么这个 G 会被要求辅助 GC 做一部分的工作,它遵循一条非常简单并且朴实的原则,分配多少内存就需要完成多少标记任务
mutator assists 的入口是在 go\src\runtime\malloc.go 的mallocgc 函数中:
mallocgc 在分配内存的时候每次都会检查 gcAssistBytes 字段是否为负值,这个字段存储了当前 Goroutine 辅助标记的对象字节数。如果为负数,那么会调用 gcAssistAlloc 从全局信用 bgScanCredit 中获取:
如果全局信用仍然不足将当前 Goroutine 陷入休眠 ,加入全局的辅助标记队列并等待后台标记任务的唤醒。
扫描内存时调用 gcFlushBgCredit 会负责唤醒辅助标记 Goroutine :
gcFlushBgCredit 会获取睡眠的辅助队列 Goroutine ,如果当前信用足够,那么就会将辅助 Goroutine 唤醒,如果还有剩余的,那么就会将这些标记任务都会加入全局信用。
总体来说是如下的一套机制:
notion image

完成标记

上面我们在 gcBgMarkWorker 中分析了,在标记完成后会调用 gcMarkDone 执行标记完成操作。
gcMarkDone 会调用 forEachP 函数遍历所有的 P ,并将对应 P 中的 gcWork 中的任务移动到全局队列中,如果 gcWork 中有任务那么会将 gcMarkDoneFlushed 加1,遍历完所有的 P 之后会判断如果 gcMarkDoneFlushed 不为0,那么跳转到 top 标记位继续循环执行,直到本地队列中没有任务为止。
接下来会关将 gcBlackenEnabled 设置为0,表示关闭辅助标记协程以及后台标记;唤醒被阻塞的辅助标记协程;调用 schedEnableUser 恢复用户 Goroutine 的调度;需要注意的是,目前处在 STW 阶段,所以被唤醒的 Goroutine 不会立马执行,会等到 STW 结束后才执行。
最后调用 gcMarkTermination 执行标记终止。

标记终止

gcMarkTermination 主要是做一些确认工作以及统计工作。进入到这个方法首先会将 GC 阶段设置到 _GCmarktermination,然后调用 gcMark 方法确认是否所有的 GC 标记工作已经完成。接着将 GC 阶段设置到 _GCoff,调用 gcSweep 开始清理工作。接着就是省略的数据统计相关的代码,包括正在使用的内存大小、GC 时间、CPU 利用率等。最后做一些确认工作,如确保每个 P 的 mcache 都被 flush ,栈都释放了,workbuf 都转移到 free list 以便回收等。

后台清扫

gcSweep 主要做的是重置清理阶段的相关状态,然后唤醒 sweep 清扫 Goroutine 。后台清扫任务是在初始化 main Goroutine 的时候调用 bgsweep 设置的:
gcenable
bgsweep
bgsweep 的清扫任务实际上是由 sweepone 进行的,它会在堆内存中查找待清理的 span,并且会返回清扫了多少 page 到 heap 中,返回 ^uintptr(0)表示没有东西需要清扫:
在查找 span 的时候会通过 state 状态以及 sweepgen 是否等于 mheap.sweepgen - 2 来判断是否需要清扫该 span。最终会通过 mspan.sweep 来进行清扫。
下面简单看一下 sweep的实现:

总结

以前我在使用 java 的时候只是粗浅的了解了一下标记清除算法,这是第一次去深入理解 Go 语言的三色算法给我带来的收益非常的巨大。这篇文章过了很久才写出来是因为这篇文章和内存分配、循环调度的关联关系非常的大,所以必须要弄懂前两篇才能理解 GC 的原理。
限于我自己工作与学习时间的关系,没有很清楚的讲解好根标记相关的代码,如果自己感兴趣的话不妨可以从 gcBgMarkWorker 来研究一下。

Reference

Visualizing Garbage Collection Algorithms
Golang源码探索(三) GC的实现原理 https://www.cnblogs.com/zkweb/p/7880099.html
Real-time garbage collection on general-purpose machines http://www.yuasa.kuis.kyoto-u.ac.jp/~yuasa/YuasasSnapshot.pdf
On-the-Fly Garbage Collection: An Exercise in Cooperation https://lamport.azurewebsites.net/pubs/garbage.pdf
notion image
posted @ 2021-03-25 15:00 luozhiyun 阅读(5172) 评论(0) 编辑 收藏 举报
  • GoGC
  • 最近收集的一些免费 wordpress 主题Go语言项目.gitignore 文件分享
    Loading...