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

对 C 语言良好的亲和力,一直是 Lua 的优势之一。LuaJIT 在传统的 Lua C API 之外,额外提供 FFI 的方式来调用 C 函数,更是大大提升了跟 C 交互的便利度。
甚至有这么一种说法,虽然 LuaJIT 命名是 Lua + JIT,但是好多人是冲着 FFI 去用 LuaJIT 的。[1]

FFI 全称是 Foreign Function Interface,即一种在 A 语言中调用 B 语言的机制。通常来说,指其他语言调用 C 的函数。
既然是跨语言调用,就必须解决 C 函数查找和加载,以及 Lua 和 C 之间的类型转换的问题。

FFI 原理

先看第一个问题。

虽说从 Lua 里面调用 C 函数看上去像是魔法,不过说到底只是魔术师的手艺罢了。诀窍在于几个 API:
POSIX 的 dlopen 和 dlsym,以及 Windows 上的 LoadLibraryExA 和 GetProcAddress。
前者用于加载对应的链接库,后者用于查找并加载对应的函数符号。
鉴于我对 Windows API 基本上一无所知,下文我只讲我了解的 POSIX 环境下的操作。当然 Windows 环境下相差也不大。

请容我揭穿 FFI 的魔术把戏:

#include <dlfcn.h>

void *dlsym(void *handle, const char *symbol);
void *dlopen(const char *filename, int flags);
local ffi = require "ffi"
local lib = ffi.load('mylib')
lib.call_C_func()

上面的代码中,ffi.load 可以看作调用了 dlopen 去加载 mylib 链接库。
lib.call_C_func 相对于调用了 dlsym 以 mylib 作为 handle 参数,加载 call_C_func 这个符号。

这么一来,许多 FFI 的加载行为都能解释通了。

dlsym 有一个 RTLD_DEFAULT 伪 handler,它的作用是:

Find the first occurrence of the desired symbol using the default shared object search order. The search will include global symbols in the executable and its dependen‐
cies, as well as symbols in shared objects that were dynamically loaded with the RTLD_GLOBAL flag.

翻译过来,如果调用 dlsym 时指定 RTLD_DEFAULT,会按顺序从以下三个地方查找符号:

  1. 可执行程序自己的全局符号
  2. 它的依赖的符号
  3. dlopen 加载时指定 RTLD_GLOBAL flag 的链接库

FFI.C.call_C_func 其实就是以 RTLD_DEFAULT 作为 handle 参数,加载 call_C_func 这个符号。所以我们除了可以通过 FFI.C 访问 mkdir 这种系统自带的、出现在 libc 里面的函数,
还可以通过它访问 c_func_write_in_the_host 这种宿主程序实现的函数。另外 POSIX 环境下,ffi.load 允许通过指定 true 作为第二个参数的值,把链接库加载到全局,这其实就是在
dlopen 时额外加 RTLD_GLOBAL flag。由于 Windows 下对应的 API 只支持前两种查找位置,所以 ffi.load 的第二个参数是 POSIX 环境独有的。

(编译模式下情况有所不同,LuaJIT 此时不会走 dlsym,而是直接调用对应的 C 函数地址。[2])

现在我们已经可以加载目标符号了,但眼前有个问题:dlsym 返回的参数是 void* 类型的,怎么知道它是一个函数?
所以需要我们告诉 LuaJIT,你加载进来的符号是个什么东西。这就是 ffi.cdef 的意义。

LuaJIT 实现了一个 C header parser,可以解析 ffi.cdef 指定的字符串,生成对应的 CType 对象。CType 对象里面存储着 ffi.cdef 声明的各种 C 类型的信息。
通过这些信息,LuaJIT 可以知道 void* 的返回值“真正的”类型。

为什么我要用双引号把 真正的 给括起来呢?因为 C 里面并没有反射。所谓“真正的”类型,只是你告诉给 LuaJIT 的类型。有些时候,因为代码里的 bug,ffi.cdef 所定义的
类型跟链接库里面的类型对不上。由于 C 里面 void* 是可以顺便转换的,所以程序可能会继续执行。运气好的话会现场崩溃。运气不好的话可能会写坏其他地方,然后导致数据出错,
或者崩溃在某个不可能崩溃的地方。

举个例子,如果在 Lua 代码里面这么写:

ffi.cdef[[
typedef struct {
    int                     a;
    int                     b;
} my_data_t;
]]

而实际 C 代码里面的定义是:

typedef struct {
    int                     a;
    int                     b;
    int                     c; // <- 某次修改引入了 c ,但是忘记同步到 Lua 代码里面
} my_data_t;

如果在 C 代码里面访问 FFI 传递进来的 my_data_t.c,就会有内存越界的问题。

如何避免这种 bug ?

最基础的要求,你的程序需要有单元测试的覆盖,而且单元测试中需要检测内存的访问情况。在 Linux 上,你可以通过 Valgrind 或 ASAN 保证。在其他系统上也应该会有相应的工具,
这里就不展开说了。

其次,如果你有链接库的源代码,可以开发出一些工具来保证链接库代码里面的 C header 和 ffi.cdef 里面定义的类型能对得上。比方说,可以把 FFI binding 的代码和 C 代码放到一起,两者在构建时共享同一个 header。
不过比较坑的是 LuaJIT 的 C header parser 不支持 C preprocessor。比方说,假设 ffi.cdef 输入参数里面有 #define ...,会直接报错而不是忽略。

如果做不到共用 header,你还有一个选择,就是最小化暴露出来的字段数。可以参照 Pimpl[3] 的方式,把 Lua 用不到的字段藏到指针里面来。像这样:

ffi.cdef[[
struct my_inner_data_t;

typedef struct {
    my_inner_data_t *pimpl;
} my_data_t;
]]

说完严肃沉重的话题,让我插播一则趣闻。由于 ffi.cdef 生成的 CType 跟符号查找之间并不耦合,你可以用一次 ffi.cdef 来为不同的库声明同样的函数。

举个例子:

// 假如我们把如下的 C 代码编译成 ffi_lib.so
typedef unsigned int mode_t;
int mkdir(const char *pathname, mode_t mode) {
    printf("fake mkdir\n");
    return 0;
}
local ffi_lib = ffi.load('./ffi_lib.so')

ffi.cdef[[
typedef unsigned int mode_t;
int mkdir(const char *pathname, mode_t mode);
int kill(int pid, int sig);
]]
print(ffi.typeof(ffi.C.mkdir)) -- ctype<int ()>
print(ffi.typeof(ffi_lib.mkdir)) -- ctype<int ()>
print(ffi.typeof(ffi.C.mkdir) == ffi.typeof(ffi_lib.mkdir)) -- true

-- 注意 LuaJIT 这里偷了懒,没有把函数参数类型打印出来
-- 虽然 kill 和 mkdir 的类型看上去都是 int (),但是它们 CType 其实是不一样的
print(ffi.typeof(ffi.C.kill)) -- ctype<int ()>
print(ffi.typeof(ffi.C.mkdir) == ffi.typeof(ffi.C.kill)) -- false

-- CType 一样,从不同链接库加载来的符号并不一样
print(ffi.C.mkdir == ffi_lib.mkdir) -- false

ffi.C.mkdir("/tmp/test", 0) -- mkdir /tmp/test
ffi_lib.mkdir("/tmp/test", 0) -- print 'fake mkdir'

相比于确定 dlsym 返回值的实际类型,CType 有一个更为重要的用途:为 Lua 与 C 之间数据的转换提供信息。

为了表示 FFI 过程中的 C 对象,LuaJIT 在标准 Lua 外引入一种全新的类型,名为 cdata。
从链接库加载过来的符号,在 Lua 里面就是以 cdata 的形式存在。比如:

print(type(ffi_lib.mkdir)) -- cdata

ffi_lib.mkdir("/tmp/test", 0) 其实就是调用了某个 cdata 的 __call 这个 metamethod。

继续前面插播的趣闻,ffi.typeof 返回的其实也是一个 cdata。这个 cdata 里面存储着一个整数 ID。LuaJIT 会通过这个 CType ID 查找实际的 CType 类型。就像这样:

-- + 0 是为了让 LuaJIT 把 cdata 转换成 number,具体数值是运行时敲定的
print(ffi.typeof(ffi.C.kill) + 0) -- 128LL
print(ffi.typeof(ffi.C.mkdir) + 0) -- 125LL
print(ffi.typeof(ffi.C.mkdir) == ffi.typeof(ffi.C.kill)) -- 这下明白这个比较是怎么实现的吧?

有趣的是,ffi_lib 本身倒不是个 cdata,而是个 userdata。

除了加载的符号和执行 ffi.new/ffi.cast 之类的方法会创建 cdata 外,在 Lua 和 C 交互过程中,LuaJIT
也会创建 cdata。

举个例子,

local buf = ffi.new("char[?]", 5)
-- 虽然看上去有点违反直觉
-- 每次对 FFI 数组的读写操作都会产生 cdata
buf[0] = 36
local i = buf[0]

FFI 性能

既然聊到了 cdata 的创建,那么顺势可以开始讲性能方面的话题了。

众所周知,关于 FFI 的性能,有一个说法,解释模式下 LuaJIT 的 FFI 操作很慢,比编译模式下慢十倍。

这个说法是正确的。让我们看下为什么解释模式下 FFI 会这么慢。

假设有一段迭代 N*N 的 FFI 矩阵的代码。表面上看,你只是进行了 N*N 次访问操作。但实际上,在迭代过程中,一共创建了 N*N 个 cdata,并且进行了 N*N 次Lua 与 C 数据之间的转换。
其实还不止这些。cdata 到 C 数据的转换,其实是通过 metamethod 触发的。所以还要加上 N*N 次 metamethod 的调用。

可想而知,这些额外的操作一定非常昂贵。
这些操作有多昂贵呢?

我用 perf 记录了一段 FFI 数组写操作代码执行过程中的热点函数:
LuaJIT FFI 介绍,及其在 OpenResty 中的应用(上)

排在第一位的是 lj_cconv_ct_ct,一个 LuaJIT 作者专门注明的昂贵操作。我们需要用它来把 cdata 转换成
C 数据。
排在第五位的是 lj_cconv_ct_tv。我们需要用它来把 Lua 对象转换成 cdata。
第七位的 lj_cf_ffi_meta___newindex 和第八位的 lj_cdata_index 顾名思义,就是触发数据转换的 metamethod 调用。

这些函数调用,是我们做数组操作时不期望的,但却又是实现 Lua 到 C 数据的转换所必不可少的。这些函数调用,是我们做数组操作时不期望的,但却又是实现 Lua 到 C 数据的转换所必不可少的。

好在我们还有编译模式。编译模式下,LuaJIT 执行的是字节码 JIT 之后的汇编。在汇编代码里,Lua 变量不过是寄存器里面的值,C 变量也不过是寄存器里面的值。在这种模式下,我们终于能够甩掉 Lua 对象转换成 cdata 再转换成 C 数据这一过程了。

下面是同样的代码,在编译模式下执行时的函数热点。可以看到,原来排在第十位的 lj_str_new 上升到第一位,那些讨人厌的函数都不见了。
LuaJIT FFI 介绍,及其在 OpenResty 中的应用(上)

同样的代码,编译模式下的性能是解释模式下的十倍。

残酷的是,现实情况下你的 Lua 代码并不能一直跑在编译模式下。
由于本文的主题是 FFI 而不是 JIT,这里就不展开讲了。你可以往 Lua 代码里面添加

local dump = require "jit.dump"
dump.on(nil, output_file)

来 dump LuaJIT trace compile 的信息,来判断哪些代码跑在解释模式下,哪些代码会被 JIT。

在 GitHub 上有一些相关的项目,提供了对 LuaJIT jit dump 的可视化增强,比如:

  1. https://github.com/cloudflare...
  2. https://github.com/iponweb/du...

总之,解释模式下 FFI 很慢,如果你的代码里有许多 FFI 操作,确保你的代码尽可能地被 JIT 掉。

[1] 云风的BLOG:介绍几个和Lua有关的东西 https://blog.codingnow.com/20...
[2] When FFI Function Calls Beat Native C https://nullprogram.com/blog/...
[3] https://en.wikipedia.org/w/in...

相关推荐