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

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

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

FFI 原理

先看第一个问题。git

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

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

#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 这个符号。web

这么一来,许多 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 数组写操做代码执行过程当中的热点函数:
jit off perf

排在第一位的是 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 上升到第一位,那些讨人厌的函数都不见了。
jit on perf

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

残酷的是,现实状况下你的 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...

相关文章
相关标签/搜索