Valgrind 报告干干净净,内存却在涨------我用 GDB 揪出了 47000 个泄漏的 Lua 闭包
C/C++ 层的内存泄漏,有 Valgrind、ASan、Heaptrack 这些成熟工具。可如果泄漏发生在 Lua 虚拟机里呢?Valgrind 对 LuaJIT 内部的对象分配是看不见的,ASan 更无从下手。这篇讲的就是:当所有现成工具都失灵时,怎么自己造一把"手术刀",把藏在虚拟机黑盒里的 47181 个泄漏对象一个个揪出来。
先抛结论:一个检测模块的 LuaJIT 虚拟机跑了七天后,内存利用率统计报到 58.91%,明显偏高。直觉告诉我里面有东西在堆积,但 Valgrind 报告干干净净------C++ 层没有任何泄漏。最后发现,问题是 47181 个 Lua 闭包,全部来自同一行源码。而找到它们,靠的是 GDB 和一个我自己写的命令。
一、现象:一个"黑盒"在悄悄变胖
这是一个负责 webshell 检测的 Lua 线程,跑在 LuaJIT 上。工作模式极简单:每 2 秒从队列取一个路径,扫描一次,进入下一轮循环。没有大数据量处理,按理说内存应该稳如老狗。
可跑了 7.6 天后,虚拟机内存利用率报到了 58.91%。对这么简单的检测线程,这个数字不该超过 20%。
我先做了分层判断------这是排查内存问题的第一性原则:先确定泄漏在哪一层。
- C++ 层 RSS:正常,没有持续增长;
- Lua 层
collectgarbage("count"):偏高,确认堆积在 Lua 层。
但"确认在 Lua 层"只是开了个头,接下来就撞墙了。collectgarbage("count") 只告诉你 Lua 一共 用了多少 KB,不告诉你是什么对象 在占------是 string?table?function?userdata?一概不知。而 /proc/<pid>/smaps、pmap 这些 Linux 工具,只能看到 LuaJIT 通过 mmap 向内核要的大块内存段,看不进段内具体是什么。
这就是难点:Lua 虚拟机对外是个黑盒。 C/C++ 有 Valgrind/ASan,Java 有 MAT/JProfiler,但 LuaJIT 层几乎没有开箱即用的内存分析工具。你明知道里面在堆积,却没有工具帮你看进去。
要打开这个黑盒,只有一条路:从 LuaJIT 的内部数据结构入手,自己造工具。而要造工具,得先理解 LuaJIT 的内存是怎么管的。
二、背景:LuaJIT 的内存与 GC,到底怎么工作
这一节是后面排查的地基,值得花两分钟搞清楚。
1. 一切皆 GCobj。 LuaJIT 里所有受 GC 管理的对象(字符串、表、函数闭包、userdata、协程......)都有一个统一的头部 GCheader,里头有个 gct 字段标识对象类型。Lua 闭包的类型是 LJ_TFUNC 下的 LClosure。关键点 :这些对象是 LuaJIT 自己的分配器管的,不走 malloc------所以 Valgrind/ASan 这些挂在 malloc/free 上的工具,对它们完全无感。这就是 C++ 层报告"干净"的原因。
2. 闭包不是声明,是对象。 这点对 C/C++ 程序员尤其反直觉。在 Lua 里,一个函数值在运行时对应一个 GCfuncL(Lua 闭包对象),它由一个函数原型 GCproto (编译期产生、只有一份)加上运行期捕获的 upvalue 组合而成。每当执行到"创建闭包"的字节码(FNEW),就新建一个 GCfuncL。原型共享,闭包按次创建------记住这句话,它是本案的题眼。
3. GC 是增量 mark-sweep,靠"步进"推进。 LuaJIT 默认的 GC 不是一次性 stop-the-world 扫完所有对象,而是增量 的:每执行一批 Lua 指令,就"顺带"做一小步标记/清扫工作,步长由 gcstepmul 控制,整轮的触发节奏由 gcpause 控制。这种设计的好处是不卡顿,代价是------如果对象的创建速度超过了 GC 步进回收的速度,GC 会逐渐落后。
4. 回收了 ≠ 还给系统。 LuaJIT 的 GC 对象按内存段(Segment)组织,一个段被小对象填满后,即使其中的对象后来被 GC 回收,这个段也不会立刻归还操作系统,而是留作后续分配复用。这和 glibc malloc 的 arena 碎片化是一个道理。所以"利用率统计高"里,可能掺了大量"已回收但未归还"的空间------这也是为什么有时候光盯着 RSS 会误判。
记住这四点,下面的排查你会看得很顺。
三、排查:给 Lua 闭包做一次"人口普查"
第一步:自己写一个 GDB 命令 lfuncstat
既然没有现成工具,就造一个。我基于上面的内部结构知识,写了个 GDB 自定义命令 lfuncstat,原理就三步:
- 遍历所有 Segment,拿到里面一个个 GCobj;
- 检查每个 GCobj 的
gct类型,筛出 LClosure(Lua 闭包); - 对每个闭包,顺着它的
GCproto拿到源文件名(chunk name)和定义行号(firstline),按"文件:行号"分组计数。
伪代码大致是这个意思:
ini
for seg in all_segments:
for obj in seg.objects:
if obj.gct == LJ_TFUNC and is_lua_closure(obj):
proto = obj.pc->proto # 闭包指向的函数原型
key = (proto.chunkname, proto.firstline)
stat[key].count += 1
stat[key].size += sizeof(closure) + obj.nupvalues * sizeof(upval)
开发成本大约2h------主要花在搞清 GCobj 的内存布局、Segment 的遍历方式、以及 GCproto 里 chunkname/firstline 这些字段的偏移上。但这是一次性投入:写好之后,后面任何 LuaJIT 内存问题,都能用它做"人口普查"。这就是自造工具的复利。
第二步:跑一把,瞬间锁定目标
连上检测模块进程,执行:
scss
(gdb) lfuncstat
Source Count Size(bytes) Upvalues
detect_worker.lua:407 47181 3019584 3
detect_worker.lua:151 512 32768 2
... (其他可忽略)
Total LClosure: 47914
47914 个闭包,47181 个来自第 407 行,占比 98.5%,每个 64 bytes(含 3 个 upvalue),合计吃掉 2.9 MB。
数字已经说明问题,但 Segment 的状态更刺眼:
go
Segment 1: ~1819 个 func 对象 (接近满载)
Segment 2: ~1819 个 func 对象
Segment 3: ~1819 个 func 对象
...
多个 Segment 几乎 100% 被 func 对象塞满。结合背景里讲的第 4 点------这些段即便被回收也不还给系统,于是利用率统计就被顶高了。到这里,"在哪一行、有多少、占多大"已经全部量化,剩下的就是去看那一行代码。
第三步:看第 407 行------循环里定义闭包
主循环大致长这样:
lua
while not llthreads.req_join() do -- 主循环,每 2 秒一轮
local path_str = path_q:recv(2000) -- 2 秒超时取一个待扫路径
...
-- 问题代码:每次循环都创建一个新闭包!
local function handle_cache_file(result)
if type(result.includes) == "table" and next(result.includes) ~= nil then
...
end
end
if scan_flag then
webshell_api.select_cache_db_all(handle_cache_file)
end
end
local function handle_cache_file(result) 定义在 while 循环体内部。
这就是那个"题眼"在现实里的样子。 还记得背景第 2 点吗?local function foo() ... end 在 Lua 里不是声明,它是 local foo; foo = function() ... end 的语法糖------每执行一次,FNEW 就新建一个 GCfuncL。写在循环外,整个模块生命周期只建一次;写在循环里,每轮迭代建一个新的。
每 2 秒一轮,7.6 天 = 328375 次迭代,理论上创建了 32 万个闭包。47181,只是其中没来得及被回收的那部分。
第四步:那 GC 为什么没把它们收掉?
你肯定会问:这些闭包每轮结束不就不可达了吗?GC 该回收啊。
确实回收了大部分 ------32 万次迭代只残留 47181 个,回收率约 85.6% 。问题出在背景第 3 点说的赛跑上:
- 创建侧:每 2 秒稳定产出一个新闭包;
- 回收侧:增量 GC 每执行一批指令推进一小步。
当这个检测线程的指令执行节奏不足以让 GC 步进追上创建速度时,GC 就持续欠债,稳态下始终积压着一批"已创建、还没轮到被清扫"的闭包。
这就是关键认知转折:不是 GC 没工作,而是 GC 在赛跑里落后了。 它不是经典意义上的"内存泄漏"(对象永远不可达、永远收不掉),而是一种"内存积压"------对象最终都会被回收,但来不及回收的那部分,在稳态下维持在一个很高的水位。85.6% 的回收率听起来很高,但剩下的 14.4%,在 7.6 天的时间尺度上,足够堆出 47000 个对象。再叠加 Segment 碎片化(回收了也不还系统),利用率统计看到的 58.91% 就是这么来的。
第五步:顺手排查同类问题
一个 bug 模式出现一次,几乎一定有第二次。回看 lfuncstat 输出,第 151 行也有 512 个残留闭包------同样定义在循环内部,同样的病根。只是它调用频率低,残留还没那么夸张。但放着不管,迟早是下一个"47000"。这就是量化工具的另一个价值:它顺带帮你把同类隐患一次性扫出来了。
四、修复:把闭包提升到循环外
lua
-- 提升到模块级别,整个生命周期只创建一次
local function handle_cache_file(result)
if type(result.includes) == "table" and next(result.includes) ~= nil then
...
end
end
while not llthreads.req_join() do
webshell_api.select_cache_db_all(handle_cache_file) -- 复用同一个闭包
end
但"提升"有个前提,也是容易翻车的地方:闭包不能依赖循环内的局部变量(upvalue) 。本例 handle_cache_file 只用传入的 result 参数,没捕获循环内的任何局部变量,可以安全提升。
如果它确实捕获了循环内的变量(比如循环计数器、当前扫描路径),就不能简单提升 ------因为提升到模块级后,闭包的 upvalue 绑定就变了,它再也拿不到循环内那个每轮都不同的变量。这种情况下,正确做法是把那些变量改成通过函数参数传入,而不是靠 upvalue 捕获。
修复后,整个模块生命周期只剩一个闭包对象,内存从 2.9 MB 降到 64 bytes。上线观察两周,利用率稳定在 15% 以下。
五、延伸:怎么给任何嵌入式 VM 造一把这样的"手术刀"
这套打法不只适用于 LuaJIT。任何嵌入了脚本运行时的 C/C++ 系统(C+++Lua、C+++Python、C+++JS 引擎),迟早都会遇到"VM 黑盒里在堆东西"的问题。通用方法论是:
- 确认泄漏在哪一层 :C/C++ 层 RSS 稳不稳?VM 层(
collectgarbage("count")、Python 的gc.get_stats())涨不涨?先把战场缩小到一层。 - 找到 VM 的"对象总入口" :LuaJIT 是 Segment 链 + GCobj,CPython 是各类型的对象池 +
gc模块的可追踪对象链。只要能遍历到所有对象、能区分类型,就能做"人口普查"。 - 按"创建位置"聚合 :泄漏/积压的关键不是"有多少对象",而是"它们从哪来"。Lua 靠
GCproto的 chunkname/firstline,Python 靠tracemalloc的分配栈。聚合到源码位置,根因往往一目了然。 - GDB 自定义命令是最低成本的载体:不用改 VM 源码、不用重新编译、可以在线上进程上直接跑。两天的开发成本,换来对整个 VM 内存的可观测性,绝对划算。
六、五条可以直接带走的经验
- 虚拟机层需要专用工具。 Valgrind/ASan 只能看 C/C++ 层,对 VM 内部透明。嵌了 Lua/Python,就得针对那个引擎的内部结构自己造观测手段。
- GC 回收率高 ≠ 没问题。 只要创建速率持续大于回收速率,残留就会无限累积。评估 GC 不能只看回收率,要看稳态残留量和增长趋势。
local function写在循环里 = 每轮新建闭包。 这个 Lua 语法陷阱,C/C++ 程序员尤其容易中招------因为它长得太像"声明"了。- 一个 bug 模式出现一次就全局搜索。 量化工具的输出顺带就把同类隐患列出来了,别只修第一个。
- 内存排查工具要跟着运行时走,而不是跟着语言走。 C++ 层 Valgrind/ASan,Lua 层 GDB 命令或 LuaJIT profiler,Python 层
tracemalloc。只在一层看,就会漏掉另一层。