这是Lua设计与实现专栏的第6篇文章,专栏由于工作原因已经停更很久了,最近有些闲暇时间可以继续对Lua5.3中的增量GC算法进行一个比较深入的研究,本文主要分为顶层设计和具体实现两个大块。 文章以lua5.3源码为背景进行讨论。
1.背景
和C#、Java类似,lua采用了Mark&Sweep的算法来进行垃圾回收,与之相对的还有个常用算法是Automatic Reference Counting(ARC)。Mark&Sweep的优点在于不用像ARC在每次赋值操作去操作引用计数(对于动态语言效率有较大影响),也不会有环形引用的问题,但是由于Mark&Sweep在触发时,需要从root节点全量遍历被引用到的节点,通常是比较耗时的操作。
在lua5.1之前,lua采用了双色的mark&sweep,它的大致流程如下:
1.1Mark阶段:
1.将root obj置黑
2.将root节点直接引用的节点置黑
3.递归将黑色节点引用到的节点置黑,直到不再有新增的黑色节点
1.2 Sweep阶段:
4.仍为白色的节点视为没有引用,从而将作为垃圾进行回收
该算法是一个不可中断的同步过程,非常容易造成CPU突刺。针对这个问题,lua从5.1以后便引入了增量GC的实现,将这样一整个同步的回收cycle,均摊到很多个可以增量执行的分步上,从而达到降低CPU突刺的目的。
2.增量Collector与Program的关系
先抛开细节,我们来看看一个增量Gollector在实际运行时与上层lua程序的关系。
2.1 运行时间线:
如上图所示,增量Collector与程序是交叉运行的,GC执行完一个step,就会把控制权交还给上层lua程序继续执行,上层执行一段时间又会触发下一个GC的step。而一个完整的mark&sweep的cycle,包含了上图中的多个GC Step。
基于这个运行时间线,Collector需要考虑的两个问题是:
(1)如何将一整个Mark&Sweep的cycle切分成逻辑上的子步骤。
(2)如何为每一个GC Step分配合理的工作量,使得一方面GC的频率可以满足应用程序对内存的需求,另一方面还要让每一个GC子步骤不至于造成应用程序的CPU突刺。
2.2 Collector与Mutator
以一个增量垃圾回收器的视角来看,上层lua程序是GC状态的修改器,这对于Collector来说是一个比较讨厌的东西,因为Mutator在两个GC step之间,会修改GC的状态。
这种Collector与Mutator的关系对增量GC设计者提出了更高的要求,这是因为Collector再一次被唤醒时,相关的GC状态可能已经发生了变化:比如之前访问过的黑色节点,又引用到了一个新的白色节点,如何让Collector知道这件事情从而防止这个白色节点被错误的sweep掉。这种情况在5.1之前的lua版本是不用考虑的,因为在之前版本下GC是一次执行到底的,中间不会插入应用程序的执行,从而不存在中间又被修改状态的情况。
3.增量GC的总体设计
从第2节中可以看出,增量Collector与Program的关系带来了3个设计层面无法回避的问题:
(1)如何将一整个Mark&Sweep的cycle切分成逻辑上的子步骤
(2)如何为每一个GC Step分配合理的工作量,使得GC尽量不影响Program的性能
(3)如何解决在两个GC Step间,Mutator会改变GC状态的问题
3.1,3.2和3.3节将介绍lua分别针对这3个问题进行设计和解决的方案。
3.1 GC状态机
针对问题(1),lua的做法是,使用了一个线性的状态机,将一整个Mark&Sweep的cycle拆分成若干子状态,每个子状态下去执行对应的action:
比如pause状态负责mark roots;propagate状态负责进行mark状态的传播,最终mark出所有reachable的节点;几个swp***状态负责清理unreachable节点的内存;callfin负责调用对象的finalizer。
这样做一定程度上将一个GC cycle的消耗分摊到了各个子状态下。如果把单个GC的状态作为可以增量的最小单位,一个粒度较大的增量GC模型可以如下图所示:
当然,lua实际的增量GC粒度要比这个模型小得多,具体可以参考3.2中的GC Step工作量评估。
3.2 GC Step工作量评估
如果只是以3.1模型中的以单个子状态为一个GC Step进行增量GC,实际上效果并不理想。这是因为:
(1)就算每个状态的工作量是相等的,那每个状态的执行时间也只是整个cycle的1/8,而一般整个cycle的时间,拿手游项目为例,通常为几十ms~几百ms不等,均摊到每个状态的耗时,也仍然有几ms~几十ms,仍然会造成明显的CPU突刺。
(2)更坏的情况是,各个子状态的耗时通常是非常不均等的,像propagate这种状态,要去全量递归的寻找reachable的节点,往往是一个耗时的大头,这样会导致在这个状态的耗时会非常突出。
基于以上两个考虑,可以得到的结论是,不能仅仅以GC的子状态为增量的最小单元。lua为了解决这个问题,引入了一个“打工还债”的模型:把内存申请(如newstring,newtable等)量化成增加债务,当欠债达到一定规模时,程序运行会从Program态转变为Collector态,Collector会去打工还债,打工的内容就是3.1中各个子状态下的action。为了进一步细化增量的最小单位,lua进一步对打工的工作量进行了评估,比如在propagate状态下,工作量就是traverse对象的内存量。有了这个债务与工作量的量化指标,那lua就可以把增量的单位进一步细化到工作量上,实际上一个GC_Step的情况可以如下面两张图所示:
可以发现,引入了“打工还债”的概念后,一个增量的GC_Step工作不再与state强绑定,而是与工作量绑定。单个Step下,只需要把当前的债务清零就可以了。这个债务清零的过程可以是只完成一个GC State的一小部分工作,也可以是跨越了多个GC State。
举个例子,比如当前在Propagate状态下,要完全完成该状态,需要遍历的对象数是1K个,内存量是50K。而当前的内存债务是2K,那单个GC_Step只需要遍历内存总量为2KB的对象数目即可,即大概遍历2/50*1000=40个对象后,就可以把运行权交还给应用程序。这样做可以大大降低了CPU的突刺。
再举个例子,比如当前Propagate状态已经快要完成了,还剩10个对象没有遍历,10个对象对应的内存量是0.5K,内存债务还是2K。这个时候,单个GC_Step就会在执行完propagate状态后,继续执行状态机后面的状态,直到这个债务被清零。这样做的好处是避免债务堆积,造成应用程序请求内存速度远超Collector清理速度的窘境。
这样,应用程序只需要基于自身的内存需求,通过调节参数来控制一次GC Step需要还债的工作量即可达到内存与CPU的平衡。
3.3 三色Mark&Sweep算法
对于问题(3)-如何解决在两个Step之间,Mutator会改变GC状态的问题。lua采用了下图的3色Mark&Sweep模型来解决:
该算法的主要步骤还是mark和sweep两个大阶段。mark负责标记当前正在被程序使用的obj,sweep阶段负责释放没有被程序使用的obj。
相对于lua5.1之前的GC模型,该模型的主要特点是:1.把GC的obj划分成3个颜色。
- 黑色节点:已经完全扫描过的obj。
- 灰色节点:在扫描黑色节点时候初步扫描到,但是还未完全扫描的obj,这类obj会被放到一个待处理列表中进行逐个完全扫描。
- 白色节点:还未被任何黑色节点所引用的obj(因为一旦被黑色节点引用将被置为黑色或灰色)。这里白色又被进一步细分为cur white和old white,lua会记录当前的cur white颜色,每个obj新创建的时候都是cur white,lua会在mark阶段结束的时候翻转这个cur white的位,从而使得这之前创建的白色obj都是old的,在sweep阶段能够得到正确释放。
2.引用屏障。
在mark阶段,黑色节点只能指向黑色或灰色节点,不能指向白色节点;灰色节点可以指向灰色节点或白色节点。换句话说,灰色节点充当了黑、白节点间的屏障作用(图中的红色虚线)。
这一屏障功能是合理的,想象一下,如果黑色节点跨越屏障引用到了白色节点,说明该白色节点实际上是被引用状态,不应该被GC释放。但是由于黑色节点已经遍历过,不会再重新遍历,会导致Collector在后面sweep阶段看到该obj为白色而将其错误的释放。
为了保证这一屏障功能,lua内部实现了barrier机制。主要有两种情况:
(1)前向barrier:将被黑色节点引用的白色节点置为黑色或灰色,这相当于让collector向前走了一步。比如设置一个黑色table的metatable时,会启动前向barrier机制,如果该metatable为白色,lua就会将其立即置为灰色。
(2)反向barrier:将引用白色节点的黑色节点重新置为灰色,这相当于让collector倒退了一步。比如对一个黑色的table添加key,lua会启动反向barrier,导致该table重新被标记为灰色,待后续重新mark。
3.gray链表。lua创建了一个gray链表,用来存储所有待处理的灰色节点(图中黄色连线)。一旦发现了被引用的obj,都会加入到该链表。一旦该链表清空了,说明所有被引用的节点都被处理完毕,mark阶段也到了尾声(实际lua实现还会更复杂一些,这里只是阐述大致的步骤,详情可以参考3.4中的子状态实现)。
基于该三色Mark&Sweep模型,Collector只要在被下一次增量唤醒时,继续处理gray链表里的内容即可。对于两次GC Step过程中,Mutator对GC状态的修改,也有了屏障功能来保证不会有状态错乱的情况。
4. 增量GC的实现细节
4.1 GC的关键数据结构
lua GC相关的数据结构都存在了global_State结构中,如下图所示:
其中比较关键的数据结构有:
- allgc:存放所有GC object的链表。每个GC object创建的时候,都会加入到该链表。
- finobj:存放所有带有析构函数(__gc)的GC obj链表。在把带有析构属性的metatable关联到具体obj时,该obj会从allgc链表移除,加入到该finobj链表。这类obj在释放之前,需要调用其finalizer。
- tobefnz:在mark阶段如果发现finobj链表中的某个节点没有引用,会从finobj移除并加入到该链表。该链表中的节点会在当个GC cycle末尾被调用finalizer,并在下一个cycle被释放掉。
这三个链表基本上组成了gc object的全量集合,给定一个GC object,任意时刻它一定在其中一个链表里。
- gray:mark阶段待遍历的object链表,主要用于propagate阶段的泛洪。
- grayagain:对于一些频繁变动的obj(比如黑色的table,又引用了白节点),会加入到该链表,这样做可以防止这种obj反复被mark了又revert回去。mark阶段末尾,会一次性处理该链表中的内容来防止反复。
- sweepgc:待sweep的链表,在sweep阶段会将该链表里的dead对象释放内存,非dead对象(非白色节点)重新置为白色。
- weak、ephemeron、allweak:这三个链表都跟weak table有关系,限于篇幅,这里不再展开。
4.2 各个GC子状态的具体实现
4.2.1 子状态的single step
每个子状态根据其能否支持内部增量分为了可拆分与不可拆分两个类型。一般内部工作量比较大的子状态,都被设计为了可拆分的,以达到按工作量为单位来做增量GC的目的。lua内部有一个叫做singlestep的函数,用来描述一个子状态可以被拆分的最小单元:
前文提到的GC_Step表示的是一次增量的内容,而一次增量就是以多个single step来构成的。
举个例子,pause状态就是不可拆分的,它的一个single step就可以把整个状态完成,包括mark完所有root节点。而propagate状态,一个single step只是遍历mark一个gray链表头部的obj,如果该状态下gary链表一共有100个obj的话,就需要通过100个single step来完成。把single step的概念带入到之前的状态序列图中,可以得到如下简化的示意图:
图中每个格子是一个single step,可拆分的state这边用了2个以上的single step来示意,不可拆分的state一定是单个single step。可以看到我们前文提到的GC_Step可以由1~n个single step来构成,主要取决于在执行当前step时的债务量。同时,正如我们上文提到的一样,GC_Step与state的关系有:(1)包含单个state的一部分;(2)包含整个state;(3)跨越多个state。
4.2.2 子状态实现
每个子状态的action具体实现如下:
- pause(不可拆分):重启一轮新的回收,标记所有的root节点,将其加入到gray链表中。标记完root节点后立即切换到propagate状态。工作量为root object中mark为黑的对象内存大小。
- propagate(可拆分):从gray链表中拿出头部obj,traverse该obj(即mark该obj直接引用的对象),根据引用对象的类型来决定是mark为black还是gray。如果引用的是string这种terminal的节点,直接置为black;如果该obj引用了一个table,需要将该table置灰,并加入到gray链表等待后续去traverse。如果该状态发现gray链表已空,则切换到atomic状态。
工作量为traverse了多少内存。比如traverse table的话就是该table开辟enry总内存量。 - atomic(不可拆分):这一步用来确保mark已经全部完成:
- 它主要会把前文提到的grayagain链表以及一些weak table相关的链表给遍历并完成mark。
- 同时,它还会遍历finobj链表,把其中仍为白色的obj移至tobefnz链表,以保证在GC cycle末尾能够正确地调用其finalizer。
- 最后,它会把global_State中的currentwhite标记翻转,以保证mark阶段新增的对象都切换为old white,在sweep阶段新增的对象都为cur white,从而可以正确释放old white对象。翻转white的示意图如下:
- swpallgc、swpfinobj、swptobefnz(均可拆分):这三个子状态完成了sweep阶段的大部分工作。它们分别扫描allgc、finobj、tobefnz三个链表,将dead的对象(old white对象)移除掉,并释放内存;同时,将还活着的对象的颜色重新刷回cur white以供下一个GC cycle进行正确的mark。
- swpend(不可拆分):sweep阶段的最后一步。这一步主要是检查全局的string table是否可以回收内存。判断的条件是看当前string table中的element数是否小于哈希桶数的1/4,如果是,会把桶数减半。
- callfin(可拆分):把tobefnz链表中颜色为old white的对象,调用其finalizer,并将这些obj重新加入到allgc等待下一个GC cycle进行释放。如果没有finalizer可跑了,进入到下一个cycle的pause状态。
值得注意的是,虽然每个增量GC_Step启动的条件都是债务大于0。但是在两个Cycle之间的第一个Step,通常会比较特殊。这是因为当上一个GC的cycle完成并切换到下一个cycle的pause状态后,欠债的数字会得到一个比较大的修复,通常会变为一个比较大的负值(具体计算方法参考4.3.2中间歇率的描述)。因此Collector通常会等待Program层执行一段较长时间才会进入到下一个cycle,这也是第一个子状态为什么叫做"pause"的由来。这个过程的示意图如下图所示:
4.3 GC提供给上层lua程序的接口
4.3.1 债务放大器stepmul
在我们上文提到的打工还债模型中,实际需要打工的量与债务的关系是可以配置的,lua源码的实现如下:
可以看到lua在实际计算debt时,会用一个叫stepmul的系数去乘以当前的debt来进行修正,。
而最终的打工量 = 修正后的dept + GCSTEPSIZE
其中GCSTEPSIZE是一个固定值,为2400字节。
上层应用程序可以通过collectgarbage("setstepmul")来设置这个放大器系数。
4.3.2 间歇率pause
间歇率pause用来设置下一个GC cycle启动的内存与上一个GC cycle结束时的实际内存占用的比例。默认值为200(表示为2倍)。
举个例子,比如我上一次GC cycle完成后,目前正在使用中的内存为5M,而我间歇率pause设置为200,那就意味着下一个GC cycle启动的条件为使用中的内存达到10M。
从源码中可以看出,在上一个cycle结束时,estimate就为当前实际使用内存,Collector会用这个值去乘以间歇率g->gcpause,得到下一个cycle启动所需的内存量,然后通过计算将这个差值转化到debt中去。像上面的那个例子,最终就会把g->Debt置为-5M,然后等到Debt大于0时启动下一轮GC。
这个间歇率的设置可以通过collectgarbage("setpause")接口来完成。
4.3.3 手动调用单步GC
单步GC也就是上文提到的GC_Step,通常来说这个GC_Step是Collector在债务大于0时自动调用的,但是应用程序侧也可以通过这个接口来手动制定GC的方案。
比如应用侧可以把pause设置到足够大,这样做相当于是屏蔽了lua的自动GC,然后可以在上层去根据时间、内存量、obj数量等因子自己去写一套分步GC的逻辑。
单步调用GC的接口为collectgarbage("step")。
5.总结
正如前文介绍的,lua5.1和5.3都使用了增量GC算法进行垃圾回收,相较于以前stop-the-world的双色GC,增量GC给上层应用提供了更好的CPU耗时体验,并且提供了更大的灵活性让程序可以基于一些参数或者策略去定制自己的GC流程。
lua5.4进一步引入了分代GC,这一块目前还没有时间研究,后续待研究完以后也会继续讨论这一块的设计。