LuaJIT FFI 介紹,及其在 OpenResty 中的應用(下)

爲何 OpenResty 要用 FFI ?

看了上文以後,各位讀者可能會得出這樣的結論:
雖然 FFI 用起來很方便,可是性能會有些問題,因此仍是要慎用啊。編程

這又是一個 「FFI 方便可是性能不行」 的例子嗎?segmentfault

並非。上文提到,在編譯模式下,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,要比原來的實現更快(即便它們的核心邏輯是共享的),
有的甚至快上數倍。lua

若是你正在使用 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 層次上的鎖,沒有別的辦法能夠破解掉。

相關文章
相關標籤/搜索