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...

相關文章
相關標籤/搜索