LuaJIT FFI 介绍,及其在 OpenResty 中的应用(下)
为什么 OpenResty 要用 FFI ?
看了上文之后,各位读者可能会得出这样的结论:
虽然 FFI 用起来很方便,但是性能会有些问题,所以还是要慎用啊。
这又是一个 “FFI 方便但是性能不行” 的例子吗?
并不是。上文提到,在编译模式下,LuaJIT FFI 的性能会是解释模式下的十倍。所以当程序运行于编译模式时,用
FFI 并不会慢。
还有一笔账值得一算:调用 Lua CFunction 会迫使 LuaJIT 退回到解释模式,而通过 FFI 调用 C 函数则不会。
所以不能光计算 FFI 的开销,还要看因为不用 FFI,导致 Lua 代码无法被编译掉的损耗。
在代码中大量调用 Lua CFunction,会使得 LuaJIT 的 JIT tracing 变得支离破碎。
即使因为 stitch 的缘故,让剩余的部分能够被编译掉,stitch 本身也会带来些许开销。
这就是为什么 OpenResty 在已经有了一套用 Lua CFunction 实现的 API 的情况下,还开了 lua-resty-core 这个项目,
用 FFI 把部分 API 重新实现的缘故。另外,OpenResty 大部分新的 API 只提供 lua-resty-core 里面的 FFI 版本,
而不再有 Lua CFunction 实现了。
除了不会打断 tracing,FFI 实现的版本还有另一个优势:LuaJIT 能够在编译时优化 FFI 实现代码。
传统的 Lua CFunction 是这样的:宿主注册一个 CFunction,在这个 CFunction 里面调用 Lua C API 跟传进来的 lua_State
交互。由于它们没法被 JIT tracing,对于 LuaJIT 而言,这些操作处于黑盒当中,没法进行优化。
而对于 FFI,交互部分是用 Lua 实现的。这部分代码可以被 JIT tracing,并进行优化。这么一来,就能省去
不必要的类型转换和字符串创建的操作。
一个明显的例子是,lua-resty-core 里面的 ngx.re.match
实现,要比原来的 CFunction 实现快一倍。
事实上,大部分在 lua-resty-core 重新实现的 API,要比原来的实现更快(即使它们的核心逻辑是共享的),
有的甚至快上数倍。
如果你正在使用 OpenResty 开发项目,建议你现在就引入 lua-resty-core。
也许在不久的将来,lua-resty-core 就是个必选项了。
FFI pitfall & trick
在最后的部分,我们来看下 FFI 中的一些技巧或者说一些需要注意的坑。
这里面有些例子直接引用自 OpenResty 的相关项目。
0 base index VS 1 base index
大部分编程语言里面,数组下标从 0 开始。然而 Lua 却是从 1 开始。当我们好不容易习惯了 Lua 的特立独行后,
FFI array 又来了个 180 度转变。跟 C 一样,ffi.new
创建的数组下标从 0 开始。
如果程序中需要在 Lua table 和 FFI array 之间交换数据,一不小心就趟到坑里面去了。
对此,除了写完代码之后需要认真 review 一下,好像也没别的解决办法了。
cdata:NULL
为了表示 C 里面的 NULL,LuaJIT 引入了一个特殊的 cdata,名为 cdata:NULL。
cdata:NULL 有些行为让人不可思议:
local cdata_null = ffi.new("void*", nil)
print(tostring(cdata_null)) -- cdata:NULL
-- LuaJIT 设置了 cdata:NULL 的 __eq 方法,让它跟 nil 相等
if cdata_null == nil then
print('cdata:NULL is equal to nil')
end
-- 但不能违背 Lua 里面只有 nil 和 false 才是假值的铁律
if cdata_null then
print('...but it is not nil!')
end
不知道大家是怎么在 Lua 里面判断一个函数执行结果是否成功的,我本人常用的是 if not data then
这种写法。
然而遇到返回 NULL 的 FFI 函数,用这种写法就中计了。必须要用 if data ~= nil then
才行。
在代码中,最好要把 FFI 函数返回的 cdata:NULL 转换成标准的 Lua nil,不然调用该函数的人可能一不小心就掉坑了。
转递 const 字符串
如果你的 C 函数接受 const char *
或者等价的 const unsigned char/int8_t/... *
这样的参数类型,
可以直接传递 Lua string 进去,而无需另外准备一个 ffi.new
申请的数组。举个例子:
ffi.cdef[[
ngx_http_lua_regex_t *
ngx_http_lua_ffi_compile_regex(const unsigned char *pat,
size_t pat_len, int flags,
int pcre_opts, unsigned char *errstr,
size_t errstr_size);
]]
local errbuf = get_string_buf(MAX_ERR_MSG_LEN)
-- 对于 const unsigned char* pat,我们可以直接传递 Lua 字符串 regex,
-- 而对于非 const 的 errstr,我们需要额外申请一个 buffer
compiled = C.ngx_http_lua_ffi_compile_regex(regex, #regex,
flags, pcre_opts,
errbuf, MAX_ERR_MSG_LEN)
LuaJIT 会直接传递 Lua 字符串对象的地址进去。由于 Lua 字符串跟 C 一样,都是以 '0' 结尾的,
你可以像读取 C 字符串一样使用传进来的这一个 const 字符串。当然由于 strlen
的复杂度是 O(n) 的,
出于性能考虑,一般会在 Lua 层次上获取字符串长度,然后作为一个参数传递进去。
FFI buffer 复用
编写高性能的 LuaJIT 代码,有两个基本点:
- 尽可能地让代码能够被 JIT
- 尽可能地复用对象
lua-resty-core 里面就应用了一个小技巧,可以复用 ffi.new
创建的 buffer。
鉴于 lua_State
不是线程安全的,我们可以假设一个 lua_State
不会被两个线程同时调用到。同时绝大部分 FFI 调用的函数里面都不会 yield。
(你当然可以用 FFI 来调用,会 yield 某个 lua_State
的 C 函数,不过这并不违反“绝大部分”这一前提)
在以上两点的保证下,我们可以设置一个全局的 buffer,凡是需要临时 buffer 的 FFI 调用都可以从这个全局的 buffer 里面申请空间。
这里是 lua-resty-core 里面,base.get_string_buf
的实现:
local str_buf_size = 4096
local str_buf
local c_buf_type = ffi.typeof("char[?]")
function _M.get_string_buf(size, must_alloc)
-- ngx.log(ngx.ERR, "str buf size: ", str_buf_size)
if size > str_buf_size or must_alloc then
return ffi_new(c_buf_type, size)
end
if not str_buf then
str_buf = ffi_new(c_buf_type, str_buf_size)
end
return str_buf
end
用法:
-- regex.lua
local errbuf = get_string_buf(MAX_ERR_MSG_LEN)
compiled = C.ngx_http_lua_ffi_compile_regex(regex, #regex,
flags, pcre_opts,
errbuf, MAX_ERR_MSG_LEN)
考虑到 ffi.cast
把一个 cdata 转换成另一个 cdata 时,不会出现额外的内存分配,我们甚至可以
把这个全局 buffer 当作其他 cdata 使用,像这样:
-- response.lua
local ffi_str_type = ffi.typeof("ngx_http_lua_ffi_str_t*")
local ffi_str_size = ffi.sizeof("ngx_http_lua_ffi_str_t")
mvals_len = #value
buf = get_string_buf(ffi_str_size * mvals_len)
mvals = ffi_cast(ffi_str_type, buf)
FFI 符号检测
当一个 struct
被多次使用 ffi.cdef
定义时,LuaJIT 会抛出 "attempt to redefine" 异常。
如果这个结构体来自于 Nginx 或者一些常见第三库,难免会出现它在不同的文件里被重复定义的情况。
这时候可以应用一个小技巧,检查某个结构体是否已经被定义了:
if not pcall(ffi.typeof, "ngx_str_t") then
ffi.cdef[[
typedef struct {
size_t len;
const unsigned char *data;
} ngx_str_t;
]]
end
上述代码中,只有在找不到 ngx_str_t
类型时我们才会去定义 ngx_str_t
。这样一来,
就不用担心会有第三方库突然引入 ngx_str_t
类型了。
(不过依然有一个问题。如果第三方库定义的 XX 类型跟实际的 XX 类型不匹配,就会出现自己的定义是正确的,
但是代码运行时却会出错这种诡异的问题……)
有些时候,我们需要在 Lua 代码里面支持同一 C 库的不同版本。不同版本里面,同样功能的 API 可能有不同的名字。
在 C 代码里,我们通常会用 #define
的方式抹平这一差异。然而 ffi.cdef
并不支持 #define
。
好在 ffi.cdef
定义和实际使用是分离的。我们可以定义所有的名字,然后根据具体的符号是否存在,
选择对应的函数。像这样:
ffi.cdef[[
/* EVP_MD_CTX methods for OpenSSL < 1.1.0 */
EVP_MD_CTX *EVP_MD_CTX_create(void);
void EVP_MD_CTX_destroy(EVP_MD_CTX *ctx);
/* EVP_MD_CTX methods for OpenSSL >= 1.1.0 */
EVP_MD_CTX *EVP_MD_CTX_new(void);
void EVP_MD_CTX_free(EVP_MD_CTX *ctx);
]]
local evp_md_ctx_new
local evp_md_ctx_free
if not pcall(function () return C.EVP_MD_CTX_create end) then
evp_md_ctx_new = C.EVP_MD_CTX_new
evp_md_ctx_free = C.EVP_MD_CTX_free
else
evp_md_ctx_new = C.EVP_MD_CTX_create
evp_md_ctx_free = C.EVP_MD_CTX_destroy
end
当然也可以考虑写多一个 C 库作为中间层,封装不同版本上的差异。
获取资源后立刻调用 ffi.gc
经常会有这种情况,我们需要通过一个 C 函数获取在 C 层次上分配的资源(比如内存),然后
调用另一个 C 函数释放这一资源。一般的做法是,使用 ffi.gc
给这一资源注册对应的 GC
handler,保证该资源一定会被释放。
在这种情况下,务必在获取资源后立刻调用 ffi.gc
。
C++ 里面有一个 RAII 的概念,大体上既是在对象构造时获取资源,在对象析构时释放资源。
通过确定的对象析构时机,实现确定的资源释放。同样的思想可以应用到 LuaJIT FFI 上。
更何况,Lua 代码抛异常的机会比 C++ 里的多多了。假设获取资源和调用 ffi.gc
间隔着一些代码,
即使这些代码里里没有显式调用 error
,由于内存分配失败时,LuaJIT 会抛异常,所以只要它们涉及
到新对象的创建,就有可能会抛异常,导致 ffi.gc
不会被调用到。所以,请务必在成功获取
资源后,立刻调用 ffi.gc
。
不要在 Lua 代码中持有 C 层次上的锁
虽说锁也是一种在 C 层次上分配的资源,不过用 ffi.gc
并不能很好地处理它。不像 C++ 里面
的析构函数,LuaJIT 里面的 GC 调用时无法预期的。然而解锁的时机必须是确定的。
如果不用 ffi.gc
,而是手动调用解锁函数,则难免会遇到异常抛出时无法解锁的问题。
那如果把两种方法结合起来呢?就像 file:close
一样,调用者手动调用解锁函数,一旦异常
抛出时,则依赖 ffi.gc
保证锁最终能被解除。可惜的是,“最终还是能够解锁”并不能让人接受。
在鄙人看来,这种两难处境,除了从设计上就避免在 Lua 代码里持有 C 层次上的锁,没有别的
办法可以破解掉。
spacewander
make building blocks that people can understand and use easily, and people will work together to ...
make building blocks that people can understand and use easily, and people will work together to ...