GoLang之GC系列三(GC底层)


注:Golang的GC自诞生起就在进行不断的优化,上面的内容依据1.14版本源码,粗略的描述了GC的执行过程与一些典型问题,但是依然可以看到GC中为了解决各种问题、提升性能,实现了很多精妙的设计,个中细节未能在本篇中体现,因为我们的描绘是“粗线条”的~

1.三种GC模式

Golang中垃圾回收支持三种模式:
(1)gcBackgroundMode,默认模式,标记与清扫过程都是并发执行的;
(2)gcForceMode,只在清扫阶段支持并发;
(3)gcForceBlockMode,GC全程需要STW。

const (
     //sweep是清扫的意思
    gcBackgroundMode gcMode = iota // concurrent GC and sweep
    gcForceMode                    // stop-the-world GC now, concurrent sweep
    gcForceBlockMode               // stop-the-world GC now and STW sweep (forced by user)
)

2.两个全局变量

关于GC执行过程,有两个重要的全局变量:gcController和work

2.1gcController

(1)gcController主要用于支持标记工作顺利执行。

var gcController gcControllerState
type gcControllerState struct {
    scanWork int64
    bgScanCredit int64
    assistTime int64
    dedicatedMarkTime int64
    fractionalMarkTime int64
    idleMarkTime int64
    markStartTime int64

    dedicatedMarkWorkersNeeded int64
    fractionalUtilizationGoal float64
    ......
}

gcController会记录一个mark cycle(标记周期)中不同类型的mark worker是否还需要启动,是否需要进行assist mark(辅助标记),已经执行了多少扫描工作,以及不同类型的mark worker分别执行了多长时间等信息。

2.2work

work用于存储全局信息

var work struct {
    full  lfstack          // lock-free list of full blocks workbuf
    ......
    bytesMarked uint64

    markrootNext uint32 // next markroot job
    markrootJobs uint32 // number of markroot jobs

    nFlushCacheRoots                               int
    nDataRoots, nBSSRoots, nSpanRoots, nStackRoots int

    markDoneSema uint32

    bgMarkReady note   // signal background mark worker has started
    bgMarkDone  uint32 // cas to 1 when at a background mark completion point
    mode gcMode
    ......
}

work提供全局工作队列缓存,并记录栈、数据段等需要扫描的root节点的相关信息;还会记录当前是第几个GC cycle,当前GC cycle已经标记了多少字节,已经STW了多长时间,以及控制GC向下一阶段过度的信息等等。
下面展开的内容会不时提到这两个变量。

3.并发GC模式

图片

默认的gcBackgroundMode下GC执行的大致过程如下:

Mark Setup

完成上一轮GC未完成的清扫工作;
为每个P创建一个mark worker协程,这些后台mark worker创建后很快陷入休眠,等待到标记阶段得到调度(findRunnableGCWorker);

第一次STW

开启写屏障;
开启新一轮GC,gcphase置为"_GCMark";
在work中记录bss段、数据段、栈中那些root节点的必要信息,为root节点标记工作做准备;

StartTheWorld,进入并发标记阶段。

后台mark worker得到调度执行时,会根据gcController中记录的相关信息决定worker的类型,这主要影 响worker的让出条件。但不管什么类型的worker都会先执行未完成的root标记工作,扫描协程栈时,只会暂停对应协程,通过stacmap标记扫描,结束后再将其恢复。

root标记工作完成后,需要继续追踪的root节点已经被记录到工作队列中,后台mark worker会继续处理工作队列中的节点,它们就是所谓的灰色节点。

通过灰色节点可能发现更多灰色节点加入工作队列,处理完的灰色节点成为黑色节点。

“***同GC并发执行的用户程序,源码与GC相关书籍中都称其为“mutator”(赋值器)*
标记阶段,mutator与GC并发执行,写入指针时会触发写屏障,把相关节点记录到写屏障缓冲区中,按需flush到工作队列。
而且,在GC标记任务完成前,新分配的对象都会被直接着为黑色。
当没有root标记任务与灰色节点时,GC就可以进入Mark Termination阶段了。

第二次STW

gcphase置为"_GCMarkTermination"
停止后台mark worker和assist worker
gphase置为"_GCOff"
关闭写屏障

Start The World,进入清扫阶段

进入_GCOff阶段以后,再新分配的对象就是白色的了。
runtime.main在程序初始化时会创建用于清扫的协程bgsweep,到清扫阶段,这个后台的sweeper会被加入到run queue中,它得到调度执行时会执行清扫任务。清扫工作也是增量进行的,而这一阶段,并发执行的mutator需要分配内存时可能需要先执行一定清扫工作。

可以看到Go语言的GC采用标记——清扫算法,默认的工作模式支持主体并发与增量回收,只在必要的阶段采用STW的方式。
那么我们前面两篇内容涉及到的相关问题,在Go语言的GC中是如何应对的呢?

4.如何应对碎片化内存?

应用 标记——清扫算法的垃圾回收器不可避免地会造成内存碎片化。而分散的、大小不一的碎片化内存会增加内存分配的负担:

一方面碎片化内存可能降低内存使用率;

另一方面要找到大小合适的内存块的代价会因碎片化而增加。

应对这一问题的办法主要是使用多个链表,不同链表管理不同大小的内存块,这样就可以快速找到符合条件的内存分块了。
因为mutator通常不会频繁申请大块内存,所以多链表管理的内存块规格主要面向中小分块,既可以满足大部分内存分配需求,又避免维护大块空闲链表而压迫到内存。

Go语言的内存管理是基于TCMalloc (Thread-Caching Malloc) 模型设计的,TCMalloc是一种典型的分级、多链表内存管理模型,可以很好的应对碎片化内存。

4.1.Golang内存管理大致结构

用户程序需要分配内存时自然不用直接和操作系统打交道,内存管理模块负责向操作系统申请内存并管理起来。Golang的内存管理分为三级:mheap,mcentral, mcache。

mheap

mheap管理着虚拟地址空间中一大段连续的内存,通常所谓的从堆分配内存,就是指从这里分配。
这段内存以8K为一页,多个页组成一个span,多个span组成一个arena。

span对应的数据结构是mspan,每个span都只存储一种大小的元素,类型规格记录在mspan.spanClass中,类型规格覆盖了小于等于32K的66种大小,类型编号1~66。大于32K的大对象直接在mheap中分配,对应mspan的类型编号为0,这样一共有67种。

mspan.spanClass除了记录对应mspan存储的元素规格类型外,还记录着该span存储的元素是否含有指针,含有指针的属于scan类型,不含指针的属于no-scan类型。对于no-scan类型的mspan,GC并不关心。
由于协程栈也是从堆上分配的,也在mheap管理的这些span中,mspan.spanState会记录该span是用作堆内存,还是用作栈内存。

图片

mheap.central

mheap.central提供全局span缓存,它按照spanclass类型区分共134个mcentral。每个mcentral管理一种spanclass的mspan,并且会将有空闲空间和没有空闲空间的mspan分别管理。

图片

p.mcache

每个P都有一个mcache用作本地span缓存,与mcentral一样,每种规格类型对应scan和no-scan两个链表。小对象分配时先从本地mcache中获取,没有的话就去mcentral获取并设置到P,mcentral中也没有的话,会向mheap申请。

图片

我们这里只简单了解Go语言的内存管理结构,接下来我们感兴趣的是与GC扫描和标记相关的元数据都记录在哪里,记录了些什么?

4.2扫描和标记相关元数据

4.2.1root节点扫描

bss、globals等也有垃圾回收相关的位图标记,由编译器生成存储在可执行文件中。各模块对应自己的modualdata,根据其中存储的gcdatamask、gcbssmask等信息可以确定特定root节点是否需要添加到工作队列中。

协程栈也有对应的元数据存储在stackmap中,扫描协程栈时,通过对应元数据可以知道栈上的局部变量、参数、返回值等对象中哪些是存活的指针。

4.2.2堆扫描与标记

mheap中每个arena对应一个HeapArena,记录arena的元数据信息。HeapArena中有一个bitmap和一个spans字段。

bitmap

bitmap中每两个bit对应标记arena中一个指针大小的word,也就是说bitmap中一个byte可以标记arena中连续四个指针大小的内存。每个word对应的两个bit中,低位bit用于标记是否为指针,0为非指针,1为指针;高位bit用于标记是否要继续扫描,高位bit为1就代表扫描完当前word并不能完成当前数据对象的扫描。

image-20220310121517484

spans

spans是一个*mspan类型的数组,用于记录当前arena中每一页对应到哪一个mspan。

图片

基于HeapArena记录的元数据信息,我们只要知道一个对象的地址,就可以根据HeapArena.bitmap信息扫描它内部是否含有指针;也可以根据对象地址计算出它在哪一页,然后通过HeapArena.spans信息查到该对象存在哪一个mspan中。

图片

而每个span都对应两个位图标记:mspan.allocBits和mspan.gcmarkBits。

mspan.allocBits

allocBits中每一位用于标记一个对象存储单元是否已分配。

图片

mspan.gcmarkBits

gcmarkBits中每一位用于标记一个对象是否存活。

图片

了解了GC扫描与标记相关的元数据,我们现在可以总结一下Go语言GC中三色标记对应的操作了。

4.3Golang中GC的三色标记

(1)着为灰色对应的操作就是把指针对应的gcmarkBits标记位置为1并加入工作队列;
(2)着为黑色对应的操作就是把指针对应的gcmarkBits标记位置为1。
(3)白色对象就是那些gcMarkBits中标记为0的对象。

5.并发标记的分工问题?写屏障记录集的竞争问题?

前面提到了全局变量work中存储着全局工作队列缓存(work.full),其实每个P都有一个本地工作队列(p.gcw)和一个写屏障缓冲(p.wbBuf)。

p.gcw中有两个workbuf:wbuf1和wbuf2,添加任务时总是从wbuf1添加,wbuf1满了就交换wbuf1和wbuf2,如果还是满的,就把当前wbuf1的工作flush到全局工作缓存中去。

图片

mark worker执行GC标记工作消耗工作队列时,会处理本地工作队列和全局工作缓存中工作量的均衡问题(runtime.gcDrain和runtime.gcDrainN中)。
(1)如果全局工作缓存为空,就把当前p的工作分一些到全局工作队列中。具体做法是:

如果wbuf2不为空,就把wbuf2整个flush到全局工作缓存中;

如果wbuf2为空,wbuf1中元素个数大于4,就把wbuf1中一半的工作放到全局工作缓存中。

(2)如果本地工作队列为空,就从全局工作缓存获取任务放到本地队列中。

通过区分本地工作队列与全局工作缓存,缓解了执行并发标记工作时操作工作队列的竞争问题。

而mutator触发写屏障时并不会直接操作工作队列,而是把相关指针写入当前p的写屏障缓冲区(p.wbBuf)中。当wbBuf已满或mark worker通过工作队列获取不到任务时,会把写屏障缓冲内容flush到工作缓存中,这样避免了mutator与GC之间关于写屏障记录的竞争问题。

6.怎么缩短GC的暂停时间?

从GC执行过程可以看到,GC只在回收周期开始与标记结束时采用STW进行必要的同步,标记工作和清扫工作都是并发执行的,而且清扫工作是懒惰的,一部分开销分摊到了内存分配过程中。

第一次STW时,只需开启写屏障,进行必要的初始化工作。而下面的设计也为缩短第二次STW的时间做出了贡献。
(1)许多垃圾回收器会忽略向globals的写操作,但是这就要在mark termination阶段重新扫描所有globals,会增加第二次STW的时间。Go语言转而在将堆上的指针写入globals时设置写屏障,到mark termination阶段便无需重新扫描所有globals,进而缩短第二次STW的时间。
(2)Go语言采用混合写屏障,写屏障伪代码如下:

writePointer(slot, ptr):
    shade(*slot)
    if any stack is grey:
        shade(ptr)
    *slot = ptr

我们在之前介绍过“插入写屏障”与“删除写屏障”,混合写屏障将二者进行融合,同时对写入目标的原指针与新指针进行着色操作。

在引入混合写屏障之前只有插入写屏障,但是这需要对所有堆、栈的写操作都开启写屏障,代价太大。

为了改善这个问题,改为忽略协程栈上的写屏障,只在标记结束阶段重新扫描那些被激活的栈帧。但是Go语言通常会有大量活跃的协程,这就导致第二次STW时重新扫描协程栈的时间太长。

如果在当前栈忽略写屏障的前提下,能够保障写入栈上的数据对象不会被hiding,就不用在第二次STW时重新扫描这些栈帧了,而删除写屏障恰好可以保障这一点。

图片

如上图所示,当前G的栈帧中A已经完成扫描,然后G执行:

(1)把old写入栈上的本地变量A;

(2)把新指针ptr写入slot。

上述第一步操作因栈上没有插入写屏障,不会标记old指针。

图片

而第二步将抵达old的唯一路径切断,old就不能被GC发现了。

图片

如果slot已经标记为黑色,栈上的C还未被扫描,如下图所示:

图片

接下来G执行:

(1)把ptr写入slot;

(2)切断C到ptr的可达路径。

上面第二步操作没有删除写屏障,不会标记ptr。为了避免将白色对象写入堆上的黑色对象,就要靠插入写屏障,在写入slot时标记新指针。

图片

至于伪代码中标记新指针前判断当前栈是否为灰色,是因为如果当前是已经完成扫描的黑色栈,那么像示例中的C和ptr一定已经被标记了,插入写屏障这里就没必要再标记一次了。

所以,使用混合写屏障既不用在当前栈帧设置写屏障,也不用在第二次STW时重新扫描所有活跃G的堆栈,缩短了第二次STW的时间。

7.并发GC如何缓解内存分配压力?

为了避免GC执行过程中,内存分配压力过大,还实现了GC Assist机制,包括“辅助标记”和“辅助清扫”。
如果协程要分配内存,而GC标记工作尚未完成,它就要负担一部分标记工作,要申请的内存越大,对应要负担的标记任务就越多,这是一种借贷偿还机制:
当前G要申请的内存大小对应它所负担的债务多少,债务越多,就需要做越多的标记工作来偿还债务。
不过后台mark worker每完成一定量标记任务就会在全局gcController这里存一笔信用(Credit),有债务需要偿还的G可以从gcController这里steal尽量多的信用来抵消自己所欠的债务。

图片

不管是真正执行标记扫描任务,还是从gcController这里steal信用,如果这一次偿还了当前债务以后还有结余,就可以暂存到当前G这里用于抵消下次内存分配造成的债务。

此外,在清扫阶段内存分配可能会触发“辅助清扫”。

例如,直接从mheap分配大对象时,为了维持内存分配量与清扫页面数的线性关系,可能需要执行一定量的清扫工作。

再例如,从本地缓存中直接分配一个span时,若存在尚未清扫的可用span,也需要先清扫这个span再分配使用。

“辅助标记”和“辅助清扫”可以避免出现并发垃圾回收中,因过大的内存分配压力导致GC来不及回收的情况。

8.如何控制GC的CPU使用率?

GC默认的CPU目标使用率为25%,在GC执行的初始化阶段,会根据当前CPU核数乘以CPU目标使用率来计算需要启动的mark worker数量。

为了应对计算结果不为整数的情况,会对该结果进行rounding(+0.5)。但是又怕这样的rounding会和目标使用率出现显著偏差,所以在mark worker中引入了不同的工作模式:
(1)Dedicated模式的worker会执行标记任务直到被抢占;
(2)Fractional模式的worker除了被抢占外,还可以在达到目标使用率时主动让出。

例如,如果有四个核,4*25%=1,只需要启动一个Dedicated模式的worker。
如果有六个核,6*25%=1.5,rounding以后等于2,误差=2/1.5-1=1/3,误差超过0.3,所以Dedicated模式的worker要是有2个就超出目标使用率太多了,需要减去一个,再启用一个Fractional模式的worker来辅助完成额外的目标。

gcController中会记录可以启动的Dedicated模式的worker数量,还会记录Fractional模式的worker需要完成的使用率目标(fractionalUtilizationGoal )。例如上面六核的情况下,fractionalUtilizationGoal=(1.5-1)/6。

调度器执行findRunnableGcWorker恢复mark worker时,需要设置worker运行的模式:

1)如果Dedicated模式的worker数目还没有达到上限,就设置为Dedicated模式;

2)否则,就要看是否需要Fractional模式的worker辅助工作,需要的话就设置为Fractional模式。
P会记录自己执行Fractional模式的worker的时间,如果当前P执行Fractional模式的时间与本轮标记工作已经执行的时间的比率达到fractionalUtilizationGoal,Fractional模式的worker就可以主动让出了。
通过上面的方式,可以有效的控制GC的CPU使用率。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

GoGo在努力

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值