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 的白色其实细分为 White1 和 White2。这是为了解决什么问题?
场景: GC 标记阶段已经快结束了(大部分是黑色),此时业务代码突然创建了一个新对象。
- 新对象默认是白色。
- 如果不加区分,在接下来的“清除阶段”,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)。如果不修正,那个白色对象会被误删。
- 操作:
- 重新遍历“灰名单”(GrayAgain,由写屏障产生,详见下文)。
- 扫描弱引用表(Weak Tables)。
- 完成所有剩余的标记工作。
④ GCSsweepstring (字符串清理)
- 特点:Lua 的短字符串是单独管理的(通常存储在全局哈希表中)。因为字符串没有子引用,清理非常快,直接从哈希表中移除未标记的字符串。
⑤ GCSsweep (普通对象清理)
- 操作:遍历所有 GCObject 链表。
- 如果是黑色:说明活着,重置为当前白色(为下一轮做准备)。
- 如果是旧白色:说明死了,free 掉内存。
⑥ GCSfinalize (终结)
- 处理所有实现了
__gc元方法的 Userdata。这些对象不会立即释放,而是会被放入一个单独的列表,等待虚拟机调用它们的析构函数。
并发下的“黑指向白”与写屏障
在 GCSpropagate 阶段,GC 和业务逻辑是交替运行的。这就带来了一个严重的数据竞争问题。
问题场景:
- GC 标记了 Table A 为黑色(认为 A 已经扫描完了)。
- 业务代码执行
A.x = B,其中 B 是一个白色对象。 - 因为 A 已经是黑色,GC 不会再扫描它。
- 后果: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 的简化逻辑:
| |
正是这个 luaC_barrier,保证了增量 GC 的正确性。它就像一个监视器,拦截了所有的赋值操作。
GC触发的时机
假设我们执行以下代码:
| |
这行代码会触发 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。
| |
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 消耗越高。
| |
stepmul (步进倍率)
- 含义:控制 GC 扫描的速度。默认值 200(200%)。
- 逻辑:分配了 1 字节内存(增加了 1 字节债务),GC 需要执行等同于 2 字节内存的工作量,才能把这 1 字节的债务“抵消”掉。
- 如果你发现内存飙升得比 GC 回收得快(Alloc > Free),说明 GC 跑慢了,需要调大
stepmul。 - 如果你发现游戏明显卡顿,可能是 GC 一次步进占用 CPU 太多,尝试调小
stepmul让切片更细。