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)
)
关于GC执行过程,有两个重要的全局变量:gcController和work
(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分别执行了多长时间等信息。
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向下一阶段过度的信息等等。
下面展开的内容会不时提到这两个变量。
默认的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中是如何应对的呢?
应用 标记——清扫算法的垃圾回收器不可避免地会造成内存碎片化。而分散的、大小不一的碎片化内存会增加内存分配的负担:
一方面碎片化内存可能降低内存使用率;
另一方面要找到大小合适的内存块的代价会因碎片化而增加。
应对这一问题的办法主要是使用多个链表,不同链表管理不同大小的内存块,这样就可以快速找到符合条件的内存分块了。
因为mutator通常不会频繁申请大块内存,所以多链表管理的内存块规格主要面向中小分块,既可以满足大部分内存分配需求,又避免维护大块空闲链表而压迫到内存。Go语言的内存管理是基于TCMalloc (Thread-Caching Malloc) 模型设计的,TCMalloc是一种典型的分级、多链表内存管理模型,可以很好的应对碎片化内存。
用户程序需要分配内存时自然不用直接和操作系统打交道,内存管理模块负责向操作系统申请内存并管理起来。Golang的内存管理分为三级:mheap,mcentral, mcache。
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提供全局span缓存,它按照spanclass类型区分共134个mcentral。每个mcentral管理一种spanclass的mspan,并且会将有空闲空间和没有空闲空间的mspan分别管理。
每个P都有一个mcache用作本地span缓存,与mcentral一样,每种规格类型对应scan和no-scan两个链表。小对象分配时先从本地mcache中获取,没有的话就去mcentral获取并设置到P,mcentral中也没有的话,会向mheap申请。
我们这里只简单了解Go语言的内存管理结构,接下来我们感兴趣的是与GC扫描和标记相关的元数据都记录在哪里,记录了些什么?
bss、globals等也有垃圾回收相关的位图标记,由编译器生成存储在可执行文件中。各模块对应自己的modualdata,根据其中存储的gcdatamask、gcbssmask等信息可以确定特定root节点是否需要添加到工作队列中。
协程栈也有对应的元数据存储在stackmap中,扫描协程栈时,通过对应元数据可以知道栈上的局部变量、参数、返回值等对象中哪些是存活的指针。
mheap中每个arena对应一个HeapArena,记录arena的元数据信息。HeapArena中有一个bitmap和一个spans字段。
bitmap中每两个bit对应标记arena中一个指针大小的word,也就是说bitmap中一个byte可以标记arena中连续四个指针大小的内存。每个word对应的两个bit中,低位bit用于标记是否为指针,0为非指针,1为指针;高位bit用于标记是否要继续扫描,高位bit为1就代表扫描完当前word并不能完成当前数据对象的扫描。
spans是一个*mspan类型的数组,用于记录当前arena中每一页对应到哪一个mspan。
基于HeapArena记录的元数据信息,我们只要知道一个对象的地址,就可以根据HeapArena.bitmap信息扫描它内部是否含有指针;也可以根据对象地址计算出它在哪一页,然后通过HeapArena.spans信息查到该对象存在哪一个mspan中。
而每个span都对应两个位图标记:mspan.allocBits和mspan.gcmarkBits。
allocBits中每一位用于标记一个对象存储单元是否已分配。
gcmarkBits中每一位用于标记一个对象是否存活。
了解了GC扫描与标记相关的元数据,我们现在可以总结一下Go语言GC中三色标记对应的操作了。
(1)着为灰色对应的操作就是把指针对应的gcmarkBits标记位置为1并加入工作队列;
(2)着为黑色对应的操作就是把指针对应的gcmarkBits标记位置为1。
(3)白色对象就是那些gcMarkBits中标记为0的对象。
前面提到了全局变量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之间关于写屏障记录的竞争问题。
从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的时间。
为了避免GC执行过程中,内存分配压力过大,还实现了GC Assist机制,包括“辅助标记”和“辅助清扫”。
如果协程要分配内存,而GC标记工作尚未完成,它就要负担一部分标记工作,要申请的内存越大,对应要负担的标记任务就越多,这是一种借贷偿还机制:
当前G要申请的内存大小对应它所负担的债务多少,债务越多,就需要做越多的标记工作来偿还债务。
不过后台mark worker每完成一定量标记任务就会在全局gcController这里存一笔信用(Credit),有债务需要偿还的G可以从gcController这里steal尽量多的信用来抵消自己所欠的债务。
不管是真正执行标记扫描任务,还是从gcController这里steal信用,如果这一次偿还了当前债务以后还有结余,就可以暂存到当前G这里用于抵消下次内存分配造成的债务。
此外,在清扫阶段内存分配可能会触发“辅助清扫”。
例如,直接从mheap分配大对象时,为了维持内存分配量与清扫页面数的线性关系,可能需要执行一定量的清扫工作。
再例如,从本地缓存中直接分配一个span时,若存在尚未清扫的可用span,也需要先清扫这个span再分配使用。
“辅助标记”和“辅助清扫”可以避免出现并发垃圾回收中,因过大的内存分配压力导致GC来不及回收的情况。
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使用率。