Valgrind 报告干干净净,内存却在涨——我用 GDB 揪出了 47000 个泄漏的 Lua 闭包

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>/smapspmap 这些 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,原理就三步:

  1. 遍历所有 Segment,拿到里面一个个 GCobj;
  2. 检查每个 GCobj 的 gct 类型,筛出 LClosure(Lua 闭包)
  3. 对每个闭包,顺着它的 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 的遍历方式、以及 GCprotochunkname/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 黑盒里在堆东西"的问题。通用方法论是:

  1. 确认泄漏在哪一层 :C/C++ 层 RSS 稳不稳?VM 层(collectgarbage("count")、Python 的 gc.get_stats())涨不涨?先把战场缩小到一层。
  2. 找到 VM 的"对象总入口" :LuaJIT 是 Segment 链 + GCobj,CPython 是各类型的对象池 + gc 模块的可追踪对象链。只要能遍历到所有对象、能区分类型,就能做"人口普查"。
  3. 按"创建位置"聚合 :泄漏/积压的关键不是"有多少对象",而是"它们从哪来"。Lua 靠 GCproto 的 chunkname/firstline,Python 靠 tracemalloc 的分配栈。聚合到源码位置,根因往往一目了然。
  4. GDB 自定义命令是最低成本的载体:不用改 VM 源码、不用重新编译、可以在线上进程上直接跑。两天的开发成本,换来对整个 VM 内存的可观测性,绝对划算。

六、五条可以直接带走的经验

  1. 虚拟机层需要专用工具。 Valgrind/ASan 只能看 C/C++ 层,对 VM 内部透明。嵌了 Lua/Python,就得针对那个引擎的内部结构自己造观测手段。
  2. GC 回收率高 ≠ 没问题。 只要创建速率持续大于回收速率,残留就会无限累积。评估 GC 不能只看回收率,要看稳态残留量和增长趋势
  3. local function 写在循环里 = 每轮新建闭包。 这个 Lua 语法陷阱,C/C++ 程序员尤其容易中招------因为它长得太像"声明"了。
  4. 一个 bug 模式出现一次就全局搜索。 量化工具的输出顺带就把同类隐患列出来了,别只修第一个。
  5. 内存排查工具要跟着运行时走,而不是跟着语言走。 C++ 层 Valgrind/ASan,Lua 层 GDB 命令或 LuaJIT profiler,Python 层 tracemalloc。只在一层看,就会漏掉另一层。
相关推荐
HLC++12 小时前
Linux的进程间通信
android·linux·服务器
华清远见IT开放实验室13 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设
groundhappy15 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
通信小小昕16 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫17 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生18 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.1119 小时前
linux基础IO
linux·运维·服务器
BelongPanda21 小时前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯21 小时前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_7779983421 小时前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器