Lua 增量 GC

Lua GC :三色标记、增量回收与写屏障

从 Stop-the-World 到增量

Lua 5.0:简单的代价

在 Lua 5.0 时代,GC 算法非常朴素:双倍阈值,全量回收

  • 逻辑:当内存使用量达到上次 GC 后的两倍时,虚拟机暂停所有业务逻辑,跑一遍完整的“标记-清除”。
  • 后果:这就是著名的 Stop-the-World (STW)。如果你的 Lua 内存占用了 1GB,GC 触发的那一瞬间,整个游戏或服务可能会卡顿几百毫秒甚至更久。

Lua 5.1+:三色增量标记(Tri-color Incremental Mark & Sweep)

为了解决卡顿,Lua 5.1 引入了增量 GC。GC 过程与业务代码交替运行。

为了实现“可中断、可恢复”的扫描,Lua 引入了三色标记法

核心算法:三色标记与双白设计

GC 的本质是从根节点(_G、Registry、栈)出发,找到所有可达对象。在增量扫描过程中,对象被标记为三种颜色:

颜色的含义

  • ⚪ 白色 (White)
  • 含义:当前还未被 GC 访问到的对象,或者已死亡的对象。
  • 初始状态:所有新创建的对象默认为白色。
  • 结局:如果 GC 结束后还是白色,说明不可达,回收
  • ⚫ 黑色 (Black)
  • 含义已扫描完毕。该对象及其引用的所有子对象都已经被访问过了。
  • 特性:GC 这一轮不会再回头看它。
  • 🔘 灰色 (Gray)
  • 含义待处理。该对象已被访问(从白色变成了灰色),但它引用的子对象还没扫描完。
  • 作用:灰色是当前扫描的边缘(Frontier),保存在一个链表中。

巧妙的“双白”设计 (The Two Whites)

我在研究源码时发现一个有趣的细节:Lua 的白色其实细分为 White1White2。这是为了解决什么问题?

场景: GC 标记阶段已经快结束了(大部分是黑色),此时业务代码突然创建了一个新对象。

  1. 新对象默认是白色
  2. 如果不加区分,在接下来的“清除阶段”,GC 会认为这个白色的新对象是“没被引用的垃圾”并把它回收掉——这会导致严重 Bug

解决方案: Lua 使用一个全局的位掩码 CurrentWhite

  • 本轮 GC:只回收标记为“旧白色”的对象。
  • 新创建对象:标记为“新白色”(CurrentWhite)。
  • 结果:新对象在本轮 GC 中被视为“安全”,留到下一轮再处理。在清除阶段结束后,Lua 会轮转 CurrentWhite 的值。

状态机的流转:GC 的生命周期

Lua 的增量 GC 是通过一个状态机驱动的。每次调用 luaC_step,GC 就会向前推进一步。以下是主要的阶段:

① GCSpause (暂停/初始化)

GC 的起点。

  • 将根节点(主线程、全局表 _G、Registry 等)标记为灰色,并加入灰色链表。
  • 此时,通过根节点的一波操作,我们有了第一批待扫描的灰色对象。

② GCSpropagate (传播 - 最耗时的阶段)

这是“增量”体现最明显的地方。

  • 操作:从灰色链表中弹出一个对象,将其标记为黑色,然后遍历它引用的所有子对象:
  • 如果是白色,将其染成灰色并加入链表。
  • 控制:这个过程不是一次性做完的,而是受 gcstepmul 控制,每次只处理一部分就暂停,把 CPU 让给业务逻辑。

③ GCSatomic (原子阶段 - 必须一次做完)

这是这一轮 GC 中唯一需要 Stop-the-World 的时刻,但通常很快。

  • 目的:因为 Propagate 阶段是并发的,业务代码可能在此期间修改了引用(比如把一个白色对象赋值给了一个已经黑色的 Table)。如果不修正,那个白色对象会被误删。
  • 操作
  1. 重新遍历“灰名单”(GrayAgain,由写屏障产生,详见下文)。
  2. 扫描弱引用表(Weak Tables)。
  3. 完成所有剩余的标记工作。

④ GCSsweepstring (字符串清理)

  • 特点:Lua 的短字符串是单独管理的(通常存储在全局哈希表中)。因为字符串没有子引用,清理非常快,直接从哈希表中移除未标记的字符串。

⑤ GCSsweep (普通对象清理)

  • 操作:遍历所有 GCObject 链表。
  • 如果是黑色:说明活着,重置为当前白色(为下一轮做准备)。
  • 如果是旧白色:说明死了,free 掉内存。

⑥ GCSfinalize (终结)

  • 处理所有实现了 __gc 元方法的 Userdata。这些对象不会立即释放,而是会被放入一个单独的列表,等待虚拟机调用它们的析构函数。

并发下的“黑指向白”与写屏障

GCSpropagate 阶段,GC 和业务逻辑是交替运行的。这就带来了一个严重的数据竞争问题。

问题场景

  1. GC 标记了 Table A 为黑色(认为 A 已经扫描完了)。
  2. 业务代码执行 A.x = B,其中 B 是一个白色对象。
  3. 因为 A 已经是黑色,GC 不会再扫描它。
  4. 后果:B 作为一个被引用的对象,却因为没被扫描到,保持白色。在清除阶段,B 会被当垃圾回收,导致 A.x 变成了野指针!

解决方案:写屏障 (Write Barrier)

为了维护**“黑色对象不能指向白色对象”**这一不变量(Invariant),Lua 在每次修改引用(table set/assignment)时,会触发写屏障。

写屏障有两种主要形式:

1. 前进屏障 (Forward Barrier) - “把白色染灰”

如果你把白色对象 B 赋给黑色对象 A,屏障会立即把 B 染成灰色

  • 意义:既然 A 引用了 B,那 B 肯定是有用的,赶紧把 B 拉进待扫描清单。
  • 应用:通常用于将新对象赋值给老对象时。用于 Closure (Upvalues)、Userdata 等。因为这些对象通常不像 Table 那样频繁修改引用。

2. 后退屏障 (Backward Barrier) - “把黑色回退变灰”

如果你把白色对象 B 赋给黑色对象 A,屏障把 A 变回灰色,扔进 grayagain 列表。

  • 意义:A 你虽然扫过了,但你现在又有了新欢,GC 稍后得回头再查你一次。
  • 应用:通常用于 Table 等容器。这是因为 Table 容易频繁变动,如果用前进屏障(把子对象变灰),下次 Table 再变动时又要触发屏障;而把 Table 变灰后,它就留在 Gray 链表中,之后再往里面塞白色对象就不需要触发屏障了(因为父对象已经是灰的了)。

源码窥探

让我们看看 Lua 内部宏 luaC_barrier 的简化逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/* 伪代码逻辑:写屏障 */
#define luaC_barrier(L, p, v) {  \
    /* p 是父对象(Table),v 是子对象(Value) */ \
    if (isblack(p) && iswhite(v)) { \
        /* 触发屏障:黑色指向了白色 */ \
        luaC_barrier_(L, obj2gco(p), obj2gco(v)); \
    } \
}

void luaC_barrier_ (lua_State *L, GCObject *p, GCObject *v) {
    /* 保持黑白不变性 */
    if (isdead(L, v)) return; // 已经死的不管
    
    // 策略:通常是将 v (子对象) 标灰 (Forward Barrier)
    // 或者将 p (父对象) 放入 grayagain 列表 (Backward Barrier)
    reallymarkobject(L, v); 
}

正是这个 luaC_barrier,保证了增量 GC 的正确性。它就像一个监视器,拦截了所有的赋值操作。

GC触发的时机

假设我们执行以下代码:

1
local i = 1234

这行代码会触发 GC 吗?答案是不会

根据 Lua 的内存模型(如上文 TValue 结构所示):

  • 值类型(Value Types):如 nil, boolean, number (integer/float)。这些数据直接存放在 Lua 栈的 TValue 结构体中。它们随着栈帧的销毁而自动消失,不涉及堆内存分配(malloc),因此不计入 GC 的债务(GCdebt),自然也不会触发 GC 步进。
  • 引用类型(Reference Types):如 table, function, string (长字符串), userdata。这些对象存活在堆上,才是 GC 主要“追杀”的目标。

在 Lua 虚拟机源码中,几乎所有涉及内存分配的操作(如 lua_newtable, lua_pushstring)之后,都会调用一个检查宏:luaC_checkGC

1
2
3
4
5
6
#define luaC_checkGC(L)  { \
    if (G(L)->GCdebt > 0) { \
        luaC_step(L); \
    } \
    condchangemem(L,pre,pos); \
}
  • GCdebt (GC 债务):这是一个核心概念。每当你分配 1 字节内存,GCdebt 就会增加 1。
  • 判断逻辑:只要“债务”大于 0,Lua 就会认为“如果你制造了垃圾(或者申请了新内存),你就得负责清理一点垃圾”。
  • luaC_step:这就是执行一次 GC“步进”的函数。它不会跑完整个 GC 流程,而是只跑一小步(具体跑多远,由 stepmul 决定)。

控制 GC 的速率

了解了原理,我们就能看懂 collectgarbage 的两个参数了:

pause (暂停系数)

  • 含义:GC的间歇率, 将 GCdebt 设置为一个巨大的负数来实现的, 默认值200(即 200%)。
  • 逻辑:如果当前内存是 10MB,GC 结束后回收到 5MB。那么下一次 GC 启动的阈值是 5MB * 200% = 10MB
  • 值越小,GC 越勤快,内存占用越低,但 CPU 消耗越高。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static void setpause (global_State *g) {
  l_mem threshold, debt;
  l_mem estimate = g->GCestimate / 100;  // 获取估算内存
  l_mem pause = (g->gcpause != 0) ? g->gcpause : 200; // 默认 200
  
  /* 计算我们要等待多少内存分配 */
  threshold = (pause * estimate); 
  
  /* 设置债务 = 估算值 - 阈值 
     因为 阈值 通常 > 估算值,所以这里 debt 是负数
  */
  debt = gettotalbytes(g) - threshold; 
  luaE_setdebt(g, debt);
}

stepmul (步进倍率)

  • 含义:控制 GC 扫描的速度。默认值 200(200%)。
  • 逻辑:分配了 1 字节内存(增加了 1 字节债务),GC 需要执行等同于 2 字节内存的工作量,才能把这 1 字节的债务“抵消”掉。
  • 如果你发现内存飙升得比 GC 回收得快(Alloc > Free),说明 GC 跑慢了,需要调大 stepmul
  • 如果你发现游戏明显卡顿,可能是 GC 一次步进占用 CPU 太多,尝试调小 stepmul 让切片更细。
使用 Hugo 构建
主题 StackJimmy 设计