1. LuaJIT介绍
1.1 什么是LuaJIT
官方介绍:https://luajit.org/luajit.html
LuaJIT目前最新的版本是2.1,其支持的最新Lua语法格式是Lua5.1(最新的Lua版本是Lua5.4)。
1.2 LuaJIT可以用来干什么
LuaJIT包括两大块儿:luajit库、luajit程序
1.2.1 luajit程序
luajit程序是基于luajit库开发的一个LuaJIT的命令行程序。通过luajit程序可以执行lua脚本,也可以编写和执行lua程序。
1.2.2 luajit库
luajit库是luajit的核心,其实现了Lua5.1的所有基础语法,同时又实现了JIT功能。此外,通过LuaJIT的FFI库,可以实现从Lua脚本直接调用C动态库中的相关C函数。
2. 出问题项目中LuaJIT应用介绍
我们有个项目为了方便快捷的进行功能拓展开发,需要将Lua和C一起联合进行编码。
主体架构是:
- C开发了一个进程foo;
- 在进程foo中通过luajit库执行一些lua脚本(lua脚本是一些快速迭代的小功能);
- 为了提效,luajit执行的lua脚本中有部分功能是直接通过LuaJIT的FFI库加载C的动态库libfeature.so,并通过ffi.C方法直接调用动态库libfeature.so中的C函数(C函数名称:test_luajit_c_func);
补充说明:
- foo进程执行lua脚本是通过lua接口lua_pcall()函数调用lua脚本中的某个Lua函数(Lua函数名称:lua_function);
- foo进程维护了一个lua_State对象(本lua_State对象创建一次,长期使用,除非其关联的lua脚本内容变化);
- foo进程执行lua脚本的方式是通过lua_pcall()接口调用lua脚本中的一个指定名称的函数(lua_function)。
3. 问题现象描述
3.1 lua脚本
local ffi = require("ffi")
ffi.load("feature", true)
ffi.cdef([[
void test_luajit_c_func(const char *str);
]])
lua_function = function()
ffi.C.test_luajit_c_func("test luajit C function called")
end
3.2 问题现象
foo进程在运行过程中,通过lua_State对象执行上述的lua脚本时,偶尔会出现调用test_luajit_c_func()函数失败的情况。失败时LuaJIT库通过error信息提示找不到C函数test_luajit_c_func对应的符号*(undefined symbol: test_luajit_c_func)*。
4. 问题原因分析
4.1 问题分析和复现
通过问题现象分析,本问题是找不到C动态库的C函数。接下来通过分析LuaJIT的FFI源码,分析FFI库操作C库的逻辑。
4.1.1 FFI操作逻辑梳理
在分析了LuaJIT源码后发现FFI库调用C动态库中的C函数的逻辑如下:
- LuaJIT中通过专门的CLibrary结构体对象来和C动态库进行交互。包括:
1.1 和C动态库的交互:dlopen()、dlsym()、dlclose()的调用,及其dlopen()返回handle的维护;
1.2 缓存dlsym()查找到的C函数地址(CLibrary中有个GCtab对象,专门用于C函数地址缓存的),避免相同函数的重复查找。 - 调用ffi.load(“feature”, true)时,LuaJIT库会调用lj_clib_load()函数,再嵌套调用clib_loadlib()函数,通过clib_loadlib()函数调用了dlopen()函数加载C动态库;
- FFI加载的C动态库在卸载时会调用lj_clib_unload()函数,再嵌套调用clib_unloadlib()函数,通过clib_unloadlib()函数调用dlclose()函数卸载C动态库。lj_clib_unload()函数是在ffi对象被Lua虚拟机GC时调用的;
- 调用ffi.C.test_luajit_c_func()时,LuaJIT库会调用lj_clib_index()函数,在嵌套调用clib_getsym()函数,通过clib_getsym()函数调用dlsym()函数从C动态库中查找对应的C函数“test_luajit_c_func”。最后会还会在lj_clib_index()函数中缓存C函数地址在LuaJIT库中(缓存使用的是TValue对象);
- FFI库中还有一个默认的CLibrary对象,指向进程全局的动态库符号映射。该CLibrary对象的handle值是NULL。
4.1.2 LuaJIT库加入调试日志验证
针对LuaJIT的FFI库操作C动态库的相关逻辑分析,我们在FFI库的clib_loadlib()、clib_getsym()、lj_clib_default()、lj_clib_load()、lj_clib_unload()函数中加入调试日志,用于追踪LuaJIT操作C动态库feature的过程。增加的日志代码如下(以git diff形式呈现):
diff --git a/src/lj_clib.c b/src/lj_clib.c
index 513528c..94d1a4e 100644
--- a/src/lj_clib.c
+++ b/src/lj_clib.c
@@ -54,6 +54,10 @@ LJ_NORET LJ_NOINLINE static void clib_error_(lua_State *L)
#define CLIB_SOEXT "%s.so"
#endif
+#define LJ_LOGF(fmt, args...) printf("<%s@%d> " fmt "\n", __func__, __LINE__, ##args)
+
static const char *clib_extname(lua_State *L, const char *name)
{
if (!strchr(name, '/')
@@ -128,11 +132,14 @@ static void *clib_loadlib(lua_State *L, const char *name, int global)
if (!err) err = "dlopen failed";
lj_err_callermsg(L, err);
}
+ LJ_LOGF("L:%p, libname:%s, global:%s, handle:%p",
+ L, name, global ? "true" : "false", h);
return h;
}
static void clib_unloadlib(CLibrary *cl)
{
+ LJ_LOGF("cl:%p, cl->handle:%p", cl, cl->handle);
if (cl->handle && cl->handle != CLIB_DEFHANDLE)
dlclose(cl->handle);
}
@@ -140,6 +147,8 @@ static void clib_unloadlib(CLibrary *cl)
static void *clib_getsym(CLibrary *cl, const char *name)
{
void *p = dlsym(cl->handle, name);
+ LJ_LOGF("cl:%p, cl->handle:%p, funcname:%s, func:%p",
+ cl, cl->handle, name, p);
return p;
}
@@ -390,6 +399,8 @@ TValue *lj_clib_index(lua_State *L, CLibrary *cl, GCstr *name)
lj_gc_anybarriert(L, cl->cache);
}
}
+ LJ_LOGF("L:%p, cl:%p, cl->handle:%p, funcname:%s, func-tv:%p",
+ L, cl, cl->handle, strdata(name), tv);
return tv;
}
@@ -415,11 +426,14 @@ void lj_clib_load(lua_State *L, GCtab *mt, GCstr *name, int global)
void *handle = clib_loadlib(L, strdata(name), global);
CLibrary *cl = clib_new(L, mt);
cl->handle = handle;
+ LJ_LOGF("L:%p, libname:%s, global:%s, cl:%p, cl->handle:%p",
+ L, strdata(name), global ? "true" : "false", cl, cl->handle);
}
/* Unload a C library. */
void lj_clib_unload(CLibrary *cl)
{
+ LJ_LOGF("cl:%p, cl->handle:%p", cl, cl->handle);
clib_unloadlib(cl);
cl->handle = NULL;
}
@@ -429,6 +443,7 @@ void lj_clib_default(lua_State *L, GCtab *mt)
{
CLibrary *cl = clib_new(L, mt);
cl->handle = CLIB_DEFHANDLE;
+ LJ_LOGF("L:%p, cl:%p, cl->handle:%p", L, cl, cl->handle);
}
#endif
4.1.2.1 正常时日志情况
<lj_clib_default@446> L:0x7f1299216380, cl:0x7f1299223710, cl->handle:(nil)
<clib_loadlib@135> L:0x7f1299216380, libname:feature, global:true, handle:0x7f1298eedb00
<lj_clib_load@429> L:0x7f1299216380, libname:feature, global:true, cl:0x7f1299224130, cl->handle:0x7f1298eedb00
<clib_getsym@150> cl:0x7f1299223710, cl->handle:(nil), funcname:test_luajit_c_func, func:0x7f129a0539b3
<lj_clib_index@402> L:0x7f1299216380, cl:0x7f1299223710, cl->handle:(nil), funcname:test_luajit_c_func, func-tv:0x7f1299224278
<lj_clib_index@402> L:0x7f1299216380, cl:0x7f1299223710, cl->handle:(nil), funcname:test_luajit_c_func, func-tv:0x7f1299224278
4.1.2.2 出问题时日志情况
<lj_clib_default@446> L:0x7ff033ca8380, cl:0x7ff033ca5eb8, cl->handle:(nil)
<clib_loadlib@135> L:0x7ff033ca8380, libname:feature, global:true, handle:0x7fefff5c9000
<lj_clib_load@429> L:0x7ff033ca8380, libname:feature, global:true, cl:0x7ff033ca6508, cl->handle:0x7fefff5c9000
<lj_clib_unload@436> cl:0x7ff033ca6508, cl->handle:0x7fefff5c9000
<clib_unloadlib@142> cl:0x7ff033ca6508, cl->handle:0x7fefff5c9000
<clib_getsym@150> cl:0x7ff033ca5eb8, cl->handle:(nil), funcname:test_luajit_c_func, func:(nil)
<clib_getsym@150> cl:0x7ff033ca5eb8, cl->handle:(nil), funcname:test_luajit_c_func, func:(nil)
<clib_getsym@150> cl:0x7ff033ca5eb8, cl->handle:(nil), funcname:test_luajit_c_func, func:(nil)
4.1.3 根据日志分析结果
通过对比分析正常运行时和出错时的LuaJIT日志,发现:
- C动态库load时的CLibrary对象和ffi.C.test_luajit_c_func()调用C函数时的CLibrary对象不同;
- ffi.C.test_luajit_c_func()调用C函数时的CLibrary对象恒定为LuaJIT的default-CLibrary对象(请细看CLibrary的内存地址)。因此,每次ffi.C.test_luajit_c_func()调用其实都是从进程的全局空间中查找C函数的;
- 发生问题时,从日志可以清晰看出在ffi.C.test_luajit_c_func()函数调用前,通过ffi.load(“feature”, true)加载的C动态库已经被成功卸载了(日志中有lj_clib_unload()和clib_unloadlib()两个函数的调用)。说明ffi.load(“feature”, true)加载的对象被GC了;
4.2 本问题偶现原因分析
进程foo加载lua脚本时,在脚本ffi.load(“feature”, true)处LuaJIT库加载了C动态库(C进程中加载lua脚本时有调用lua_pcall()函数(调用方式:lua_pcall(0,0,0,0)))。且通过分析可以发现C动态库加载后的CLibrary对象是放在LuaJIT的lua栈空间的。
但是ffi.load(“feature”, true)加载C动态库的CLibrary对象在lua_pcall()函数调用结束时,已经从Lua栈空间中弹出(处于无引用,待回收状态)。
结合日志进一步分析得出:正常运行时ffi.load(“feature”, true)加载的CLibrary对象为被释放(未被GC);异常时被释放(被GC)。
同时,ffi.load(“feature”, true)加载的C动态库是加载到了foo进程的全局空间中的。所以,即使ffi.C.test_luajit_c_func()每次调用时的CLibrary对象不是正确的ffi.load(“feature”, true)得到的(default-CLibrary对象),但由于ffi.load(“feature”, true)的CLibrary对象还未被释放,则可以通过进程全局空间找到C函数test_luajit_c_func()并执行。
5. 问题解决方案
5.1 快速解决方案
由于我们在本次项目中是选择通过FFI库将C动态库加载到进程全局空间中的。所以,我们可以通过让FFI库加载的CLibrary对象在lua接口函数lua_pcall()调用退出后仍然有被引用(ffi.load()执行的结果赋值给一个变量即可),则可以保证其不会被释放。具体修改如下:
local ffi = require("ffi")
local clib_feature = ffi.load("feature", true)
ffi.cdef([[
void test_luajit_c_func(const char *str);
]])
lua_function = function()
ffi.C.test_luajit_c_func("test luajit C function called")
end
5.2 更好的解决方案
5.2.1 “快速解决方案”的弊端
前面的“快速解决方案”虽然解决了当前项目中遇到的问题,但是不够优雅和通用。主要原因如下:
4. 通过ffi.load(“feature”, false)加载C动态库,无法使用(因为其未加载到进程全局空间,所以用default-CLibrary对象无法找C动态库中的C函数);
5. 由于无法使用ffi.load(“feature”, false)模式加载C动态库,如果在项目中有多处通过LuaJIT潜入lua脚本,不同lua脚本中通过ffi.load()加载不同的C动态库,不同的C动态库中如果存在相同名称的接口函数。则使用ffi.load(“xxx”, true)方式加载所有的C动态库,然后通过ffi.C.xxx_func调用C函数就有可能会出问题。
5.2.2 优雅的解决方案
我个人认为更加优雅的解决方案需要具备如下特点:
- 通过ffi.load()加载的CLibrary对象不应该保存在lua的栈中;
- 可以应用ffi.load(“xxx”, false)方式将C动态库加载私有的空间中,从而实现不同lua_State对象加载的CLibrary对象在空间的隔离,最终保证其应用不会有交叉;
5.2.2.1 具体的修改方案
将所有ffi.load()加载的CLi brary对象保存在lua_State对象中。同时,在lua_State对象中维护一个全局的C函数地址的缓存,将所有ffi.C.xxx_c_func()的第一次调用得到C函数的地址缓存到这个全局的缓存中。
我clone了一份LuaJIT的最新代码,并按照上述方案思想进行了对应的修改。
本方案具体实现代码:https://github.com/xunmengzhe/LuaJIT