最近我一直在看lua gc,阶段性做一下总结。网上很多文章没讲明白,我也当弥补下这个问题。文章以我看的lua 5.3.6做分析。

lua 垃圾回收发展历程

5.0 及以前使用的是双色标记清除法,gc 过程会停止主程序执行,直到 gc 完成;
5.1 实现了增量式 gc,采用三色标记法,gc 过程分多步,与主程序交替执行;
5.2 实现了分代 gc,实际效果不理想;
5.3 移除了分代 gc;
5.4 重新实现了分代 gc

两种标记清除算法

标记清除算法,就是遍历所有对象,将还在使用的对象打上标记,遍历完成后,没有标记的对象就是垃圾对象,要清理掉。

所以,系统至少需要记录两个数据集合,一个是所有的对象集合,一个是还在使用的对象集合。在 lua 中,还在使用的对象集合包括了全局变量、 栈中对象、处于激活状态的协程等。

双色标记清除法

  • white状态:待访问状态。表示对象还没有被访问到。
  • black状态:已访问状态。表示对象已经被访问到了。
  • 1. 初始时,所有对象都是标记white
    2. 将root GC遍历对象,将访问过的对象标记black
    3. 结束后,white的即为不可达对象,进行回收。

    gc 过程不可中断,需要暂停主程序。gc 过程中,white可能是未访问的对象,也可能是不可达对象,如果中断了,得从头开始。

    三色标记清除法

  • white状态:待访问状态。表示对象还没有被访问到。
  • gray状态:待扫描状态。表示对象已经被访问到了,但是对象对其他对象的引用还没有全部访问。
  • black状态:已扫描状态。表示对象已经被访问到了,并且对象对其他对象的引用已经全部访问了。
  • 1. 初始时,所有对象都标记white;
    2. 将root GC遍历对象,标记gray,并加入gray链表;
    3. 从gray链表中取出对象:
    ① 将该对象引用到的其他对象标记 gray,并加入gray链表;
    ② 将该对象标记 black。
    4. 重复步骤3,直至gray链表为空时结束;
    5. 结束后,white的即为不可达对象,进行回收。

    gc可以中断,与主程序交替执行。black对象引用white对象后,black对象可回退为gray,或者white对象向前标记为gray,具体操作看对象类型,如果是table,回退为gray可避免频繁检查。

    1. lua 需要gc的是哪些数据?
    2. lua gc是怎么触发的?
    3. lua 怎么判定数据可达?
    4. 为什么用两种白色标记?
    5. lua gc调优?
    6. lua gc什么情况下会卡顿?

    1. lua 需要gc的是哪些数据?

    所有对象都要接受gc管理回收,但实际参与计算的,为table, string, closure, userdata, thread(coroutines), proto, upvalue 。

    2. lua gc是怎么触发的?

    2.1 自动触发 gc

    ** Does one step of collection when debt becomes positive. ** 'condchangemem' is used only for heavy tests #define luaC_condGC(L,pre,pos) \ { if (G(L)->GCdebt > 0) { pre; luaC_step(L); pos;}; \ condchangemem(L,pre,pos); } #define luaC_checkGC(L) luaC_condGC(L,(void)0,(void)0)

    在以下代码中,使用 luaC_checkGC 检查 gc 阈值 GCdebt ,当 GCdebt 大于0 时,执行 gc
    1、创建新数据时 string, thread, userdata, table, closure
    3、语法解析时
    4、错误发生时
    5、字符串拼接时 concat
    6、栈增长时

    备注:这里提下 GCdebt,字面意思就是 gc 债务,用以调节 gc 是否触发。当 GCdebt 大于0时,触发 gc, 而且,gc 单次的工作量也受这个参数影响。另外,跟这个参数有关的,lua 申请的总内存不是 totalbytes,而是 totalbytes + GCdebt,totalbytes 会根据 GCdebt 变化而变化,始终保持 totalbytes + GCdebt 为内存总量

    2.2 手动触发 gc
    使用 lua API:
    collectgarbage "step"
    collectgarbage "collect"

    3. lua 怎么判定数据可达?

    从 GC根集合(root set) 可访问的对象:
    gc root set包含三部分:
    1、主协程 g->mainthread,其栈记录了当前用到的所有对象
    2、注册表 g->l_registry,包含了全局table(_G),记录了全局变量和全局模块,还包括已加载的模块表 package.loaded
    3、全局元表 g->mt,每种数据类型各一个,预留9个,暂时只有table和string的实现,效果如io模块的f:read()和 string模块的s:len()

    备注:在 lua 5.0/5.1 中,rootgc 是指所有可回收对象,在 lua 5.2 之后, 取消了 rootgc 定义,改为 allgc。 一般情况下,root gc 是指存活对象,但具体项目得看作者定义,root 只表达了“根”集合,所以,root gc 表达所有可回收对象集合,或是所有存活对象集合都没有什么问题。

    4. 为什么用两种白色标记?

    假设只有一种白色标记,在gc过程中产生的新数据,标记白色的话,等到gc清理阶段,白色数据会被清理掉。

    那么,新数据标记成黑色是否可行?
    先说结论,可行,但可能会造成内存不及时清理。

    看下 lua 新建数据的方法:

    新加的数据,都会加到 g->allgc (可回收对象链表)头部,如果设置为黑色,在 gc GCSatomic阶段后不会再从头遍历 g->allgc,导致本次 gc 结束后黑色标记无法变为白色,数据要等到下下次 gc 才能被清理掉。

    5.lua gc 调优?

    collectgarbage ([opt [, arg]])
    这个函数是 lua 垃圾回收的通用接口,通过不同参数,对外提供了不同的功能:

    "collect" 做一次完整的垃圾收集循环。 这是默认选项。 "stop" 停止垃圾收集器的运行。 在调用重启前,收集器只会因显式的调用运行。 "restart" 重启垃圾收集器的自动运行。 "count" 以 K 字节数为单位返回 Lua 使用的总内存数。 这个值有小数部分,所以只需要乘上 1024 就能得到 Lua 使用的准确字节数(除非溢出)。 "step" 单步运行垃圾收集器。 步长“大小”由 arg 控制。 传入 0 时,收集器步进(不可分割的)一步。 传入非 0 值, 收集器收集相当于 Lua 分配这些多(K 字节)内存的工作。 如果收集器结束一个循环将返回 true 。 "setpause" 将 arg 设为收集器的 间歇率 。 返回 间歇率 的前一个值。 "setstepmul" 将 arg 设为收集器的 步进倍率 。 返回 步进倍率 的前一个值。 "isrunning" 返回表示收集器是否在工作的布尔值 (即未被停止)。

    以上来源于云风的lua文档翻译,是我找到关于这块最好的中文注释了。

    5.1 调整 gc 步进倍率

    collectgarbage("setstepmul", Number)

    值越小,gc步进倍率越小,默认值200,最小值40,这个参数的作用是什么?

    先看下 g->gcstepmul 的代码,可以看出,lua 单步 gc 受这个参数影响。

    ** get GC debt and convert it from Kb to 'work units' (avoid zero debt ** and overflows) static l_mem getdebt (global_State *g) { l_mem debt = g->GCdebt; if (debt <= 0) return 0; else { debt = (debt / STEPMULADJ) + 1; debt = debt * g->gcstepmul; return debt; ** performs a basic GC step when collector is running void luaC_step (lua_State *L) { global_State *g = G(L); l_mem debt = getdebt(g); lu_mem work = singlestep(L); /* perform one single step */ debt -= work; } while (debt > -GCSTEPSIZE && g->gcstate != GCSpause); debt = (debt / g->gcstepmul) * STEPMULADJ; /* convert 'work units' to Kb */ luaE_setdebt(g, debt);

    再看下 STEPMULADJ 和 g->gcstepmul 的默认值:

    #define STEPMULADJ 200 #if !defined(LUAI_GCMUL) #define LUAI_GCMUL 200 /* GC runs 'twice the speed' of memory allocation */ #endif g->gcstepmul = LUAI_GCMUL;

    这个参数影响了什么?

    以上代码可以看出,就算 gc 什么事没做 (singlestep 返回0),debt 都会加 STEPMULADJ,保证 gc 向前推进。
    但实际每次增加的 gc 工作 (work),为 g->gcstepmul,也就是说,g->gcstepmul 的值越大,单步能处理的 gc 工作 (work)就越多

    5.2 调整 gc 间歇率参数

    collectgarbage("setpause", Number)

    设置 gcpause,并返回原来的 gcpause
    值越小,表示gc暂停频率越低,也就是说,gc变得越频繁。默认值为200,为2倍暂停阈值大小,表示申请内存小于实际使用的2倍时,暂停gc

    debt = gettotalbytes(g) - g->GCestimate * g->gcpause / PAUSEADJ; #define PAUSEADJ 100

    5.3 单步运行垃圾收集器

    collectgarbage("step", Number)

    值为空或0,表示 gc 前进一步; 否则表示增加多少 debt,检查一次是否 gc,但未必会触发 gc
    在 gc 暂停期间,此接口仍可执行 gc,且不改变 gc 原来的状态

    6. lua gc什么情况下会卡顿?

    先说下 gc 状态机

    GCSpause 开启新一轮 gc,遍历 rootgc 开始 mark(标记),table/proto/closure/thread 对象标记为 gray,加入 g->gray 链表,string/userdata 对象标记为黑色。遍历完成后,切换到 GCSpropagate GCSpropagate 每次从 g->gray 链表取出一个对象,先标记为 black。如果对象是弱表或 thread,则加入 g->grayagain 链表,然后遍历这个对象的子项开始 mark,把没有标记的对象都标记了。当 g->gray 链表为空,切换到 GCSatomic GCSatomic 再次遍历 rootgc 开始 mark,对 GCSpropagate 期间可能的改变再重新标记,将未标记的对象加入 g->gray 链表。遍历 g->gray 链表取所有对象完成标记。遍历 g->grayagain 链表取所有对象完成标记,遍历弱表,将白色的项置为nil。遍历 g->finobj 链表,把白色的对象移到 g->tobefnz 链表。遍历 g->tobefnz 链表,完成 mark。切换当前白色到另一种白色。切换到 GCSswpallgc GCSswpallgc 每次取 g->allgc 链表一定数量的对象, 将还是上一种白色的对象清理掉。同时将黑色对象标记为当前白色。当 g->allgc遍历完成,切换到 GCSswpfinobj GCSswpfinobj 同上处理 g->finobj 链表。切换到 GCSswptobefnz GCSswptobefnz 同上处理 g->tobefnz 链表。切换到 GCSswpend GCSswpend 收缩全局 string 的hash表,保证hash桶利用率超过 1/4, 切换到 GCScallfin GCScallfin 每次取 g->tobefnz 链表一定数量的对象,将对象标记为白色,加入 g->allgc 链表 。 执行对象的 __gc meta函数。当 g->tobefnz 为空时,切换到 GCSpause

    再重点说下以上提到的几个变量:
    g->allgc: 所有可回收的对象链表。
    g->finobj: 带 __gc meta函数的对象链表。
    g->tobefnz: 没有引用的带 __gc meta函数的对象链表。
    新建对象时,对象会加入 g->allgc 链表,当对象设置 __gc meta函数时,这个对象会从 g->allgc 移到 g->finobj 链表,当这个对象不再引用后,从 g->finobj 移到 g->tobefnz,执行 __gc meta函数后,对象再移回到 g->allgc,等下一次 gc 清理。

    g->gray: 等待遍历的对象链表
    g->grayagain: 等待 g->gray 为空后,再遍历的对象链表
    g->grayagain 的用途:
    1. GCSpropagate 阶段的弱表。要扫描完所有对象后,才可以对弱表进行处理。
    2. 黑色 table 引用白色对象时。如果又加入 g->gray,会导致 table 又被反复扫描
    3. 协程。要等协程栈中对象都处理完,才可以对协程进行处理。

    好了,回到题目:lua gc什么情况下会卡顿?
    以上看出, GCSatomic 最有可能造成卡顿。
    1、GCSatomic 不能分步执行
    2、GCSpropagate 分步执行是有副作用的,这段时间, rootgc 可能会引用新的白色对象,已标记黑色的 table 引用了白色对象,这些数据都需要在 GCSatomic 重新遍历和标记
    3、弱表处理,没有分步执行

    所以,以下几种情况 GCSatomic 的开销会比较大:
    1、系统不断产生大量大 table
    2、不断有大量大 table 引用新数据
    3、大量使用弱表

    文章到这里就结束了,感谢阅读,有问题可以撩我。后面我找机会再研究下 lua 5.4 gc

    参考链接:
    http://wiki.luajit.org/new-garbage-collector
    https://www.lua.org/wshop18/Ierusalimschy.pdf
    http://cloudwu.github.io/lua53doc/manual.html