如何用 Valgrind 检测使用 LuaJIT FFI 过程中的内存泄漏
什么情况下可能会有内存泄漏
给带 GC 的语言写 C binding 一向是件让人迷糊的事。到底应该在 C 手工释放资源呢,还是依靠 GC 来回收?
还好 LuaJIT FFI 提供了很好用的 ffi.gc
方法。该方法允许给 cdata 对象注册在 gc 时调用的回调,它能让你在 Lua 领域里完成 C 手工释放资源的事。
C++ 提倡用一种叫 RAII 的方式管理你的资源。简单地说,就是创建对象时获取,销毁对象时释放。我们可以在 LuaJIT FFI 里借鉴同样的做法,在调用 resource = ffi.C.xx_create
等申请资源的函数之后,立即补上一行 ffi.gc(resource, ...)
来注册释放资源的函数。尽量避免尝试手动释放资源!即使不考虑 error
对执行路径的影响,在每个出口都补上一模一样的逻辑会够你受的(用 goto
也差不多,只是稍稍好一点)。
有些时候,ffi.C.xx_create
返回的不是具体的 cdata,而是整型的 handle。这会儿需要用 ffi.metatype
把 ffi.gc
包装一下:
local resource_type = ffi.metatype("struct {int handle;}", { __gc = free_resource }) local function free_resource(handle) ... end resource = ffi.new(resource_type) resource.handle = ffi.C.xx_create()
回到小标题,如果你没能把申请资源和释放资源的步骤放一起,那么内存泄露多半会在前方等你。写代码的时候切记这一点。
在单元测试中检查内存泄漏
当然要想保障代码里不存在内存泄露,严格按照 RAII 规范编写代码并不够。毕竟圣人千虑,必有一失;何况你我凡胎?显而易见,我们需要一个侦测内存泄漏的工具。在这方面首选 Valgrind。
Valgrind 只能检查程序运行路径上的内存问题。所以要想最大化 Valgrind 检查的覆盖面,最好结合单元测试一起跑。这样单元测试覆盖到的地方,内存检查也能覆盖到。
鉴于 OpenResty 在这方面提供了一套工具集,而且我写这篇文章也是为了解决 OpenResty 应用开发中的一些问题,所以请允许我先以 OpenResty 应用为例,说说如何预防内存泄漏。
TEST_NGINX_USE_VALGRIND=1
OpenResty 官方的测试框架 test-nginx
内置了对 Valgrind 的支持。你所需的,不过是加个 TEST_NGINX_USE_VALGRIND=1
环境变量。测试框架看到该环境变量的存在后,会在启动 Nginx 的时候,前面加上 valgrind --leak-check
等选项。这样 Valgrind 就会去检查 Nginx 内部的内存分配。一旦 FFI 调用中存在内存泄漏,Valgrind 便会报告出来。效果与用 Valgrind 运行一个普通的二进制程序无异。
$opts = "--tool=memcheck --leak-check=full --show-possibly-lost=no"; if (-f 'valgrind.suppress') { # 如果 valgrind.suppress 存在,用它来消除警告 $cmd = "valgrind --num-callers=100 -q $opts --gen-suppressions=all --suppressions=valgrind.suppress $cmd"; } else { $cmd = "valgrind --num-callers=100 -q $opts --gen-suppressions=all $cmd"; }
由于 Valgrind 会显著拖慢托管程序的运行速度,你通常还需要另一个环境变量 TEST_NGINX_SLEEP
设置 test-nginx
测试框架的超时时间,以免遭遇各种奇怪的错误。最后完整可用的运行方式如下:
TEST_NGINX_USE_VALGRIND=1 TEST_NGINX_SLEEP=1 prove -r t
实际运行一下,你会发现输出来的“错误”非常多,甚至可能会出现尴尬的内容:
==10898== More than 1000 different errors detected. I'm not reporting any more. ==10898== Final error counts will be inaccurate. Go fix your program!
不用担心!大部分都是 faise positive(假阳性)。你只需弄一个 valgrind.suppress 来消除错误。由于我们只关注内存泄漏问题,这里简单粗暴地关闭其他错误输出:
{ <insert_a_suppression_name_here> Memcheck:Cond obj:* } ...
还有一类 Nginx 或 LuaJIT 相关的内存泄漏报告,我们可以把它们也一并消除掉:
{ <insert_a_suppression_name_here> Memcheck:Leak fun:malloc fun:ngx_alloc } ...
现在再跑一次测试,如果还有报错,应该就是你的 FFI 代码问题了。背景噪音消除了,问题排查就清晰多了。
注意默认情况下 Valgrind 的检测结果不会影响退出码,所以为了跟 CI 配合,需要 grep 一下具体的报错:
TEST_NGINX_USE_VALGRIND=1 TEST_NGINX_SLEEP=1 prove -r t 2>&1 | grep -B 3 -A 20 "match-leak-kinds: definite" # 忽略测试失败或 grep 不到东西的场景 test $? -eq 0 && exit 1 # 否则正常退出(一遍我们会跑两次测试,第一次不带 Valgrind。所以第二次测试失败(比如由于超时)不会影响最终的正确性) exit 0
这样一旦 Valgrind 报告中出现了 "match-leak-kinds: definite" 字眼,测试就会失败。
非 test-nginx 下的内存泄漏检测
如果用的不是 test-nginx
那一套,又该怎么检测内存泄漏呢?
我们可以照搬 test-nginx 的原理,加塞 Valgrind 参数进去。比如,如果测试集只依赖 LuaJIT 本身,你可以这么运行:
opts="--tool=memcheck --leak-check=full --show-possibly-lost=no --error-exitcode=42" valgrind --num-callers=100 -q $opts --gen-suppressions=all [--suppressions=valgrind.suppress] luajit ...
不像 test-nginx
,这里不再需要 grep 一下。通过指定 --error-exitcode
,一旦 Valgrind 发现了错误,会以指定的错误码退出。
如果测试集基于 resty 命令行工具驱动,可以用 resty 的 --valgrind
选项。
如果测试集基于 busted 测试框架,可以改造下调用方式。
首先,创建一个 test_valgrind.lua
文件,绕过 luajit -e
无法传参的缺陷。
require "busted.runner"({ standalone = false })
然后用 Valgrind 运行 luajit:
valgrind --error-exitcode=42 --tool=memcheck \ --gen-suppressions=all --suppressions=valgrind.suppress \ luajit test_valgrind.lua .