LuaJIT FFI 介绍,及其在 OpenResty 中的应用

2022-07-28 10:07:29

为什么 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 代码,有两个基本点:

  1. 尽可能地让代码能够被 JIT
  2. 尽可能地复用对象

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

  • 作者:weixin_33937913
  • 原文链接:https://blog.csdn.net/weixin_33937913/article/details/88750012
    更新时间:2022-07-28 10:07:29