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 层次上的锁,没有别的
办法可以破解掉。