本文內容,由我在 OpenResty Con 2018 上的同名演講的演講稿整理而來。
PPT 可以在 這里 下載,因為內容比較多,我就不在這里一張張貼出來了。有些內容需要結合 PPT 才能理解,請多包涵。
編寫正確且高效的應用,最為關鍵是一系列軟件工程上的實踐,像測試、code review、灰度、監控、壓測等等。不過由於這是 OpenResty 大會上的演講,我會專注於講講 OpenResty 和 LuaJIT 的一些小細節,幫助各位聽眾避免線上踩坑。
自我介紹
按慣例,得先自我介紹下。spacewander,這個是我的 GitHub 昵稱。我目前在 OpenResty Inc. 公司工作。
第一部分
先從 OpenResty 開始講吧。
init_by_lua*
init_by_lua*
是 OpenResty 目前唯一運行在 master 進程里的階段。它運行的時機非常靠前,就在 Nginx 剛解析完配置之后。
這就意味着,只要運行 Nginx 可執行文件,init_by_lua*
里面的代碼就會被調用。有些時候,我們運行 Nginx 可執行文件,並不是想啟動它的服務。比如在調用 nginx -t
檢查配置文件是否正確,或者在調用 nginx -s
控制當前的 master 進程的時候。如果你的代碼里包含對本進程以外的資源的修改,這種意料之外的執行是不受歡迎的。那怎么避免呢?對於 -s
,可以通過直接用 kill
發送信號的方式代替。對於 -t
,略微復雜一點,可以通過 FFI 的方式獲取當前 Nginx 進程的命令行參數,判斷其中是否包含 -t
選項。PPT 上面包含了這么做的代碼。
如果 init_by_lua*
里面的代碼執行時間過長,比如啟動時會先從遠程服務器加載數據,可能會帶來另一個問題。大多數部署腳本里面,檢測 Nginx 進程是否順利啟動,是通過查看 nginx.pid
這個文件實現的。由於 Nginx 是在執行完這一部分 Lua 代碼后才會創建 nginx.pid
文件,如果執行時間過長,可能會在部署時造成誤判。這時候可能需要恰當地增加查看 nginx.pid
的時間間隔。
ngx.worker.id()
要想區分不同 worker 進程,通常的做法是用 ngx.worker.id()
。需要注意的是,有些時候多個 worker 進程可能會有同樣的 id,比如在 reload 或者 binary upgrade 的時候。
Nginx 在 reload 的時候,會有兩組 worker 進程。新的 worker 們會接替老的 worker,但直到老 worker 退出之前,這兩組 worker 是同時運行的。如果 shdict 的配置不變,這兩組進程甚至有同樣的共享內存空間,所以在用 worker id 作為 shdict key 的時候,這種邊界情況需要考慮下。
Nginx 在 binary upgrade 的時候,則會有兩組 master+workers。這兩組進程並不共享內存空間,所以用 worker id 作為 shdict key 時,不用關心這種情況。但是當你用 worker id 作為外部服務或者文件系統的 key 時,還是要注意下的。一種可能的解決方案是引入 parent pid 作為前綴,然后處理好 key 變化時,數據遷移的邏輯。
shdict is a LRU cache
如果 shdict 里面的數據超過了事先分配好的內存大小限制,OpenResty 會根據 LRU 算法,清除現有的數據。我發現許多情況下,我們會忽視這一事實,認為 shdict 里面的空間一定足以容納所有數據。不過假若你是一個厭惡風險的人,可以考慮只用 safe_
開頭的一系列 API 來操作 shdict。
這一類 API 在 shdict 不夠空間時,會失敗,而不是默默地擠掉當前的數據。
update time
Nginx 對時間的緩存非常激進,只有在開始新的一個事件循環時才會更新緩存的時間。對於服務器,這似乎足夠了;但對於應用代碼,一不小心可能就踩坑了。
通俗易懂的說法,如果你的 Lua 代碼不 yield,那么從頭到尾獲取到的時間都是緩存的結果。另外,如果多個請求同處一個事件循環里面,而其中一個請求產生了阻塞操作,那么執行剩下的請求時,緩存的時間跟真正的時間就會有明顯的差別。所以,在進行了耗時操作之后,可能需要調用下 ngx.update_time
。如果需要准確的時間戳,不應直接調用 ngx.now()
。
接下來讓我們比較下幾種 ngx.now()
的替代品。
在上圖中,我們以 os.time()
作為基准,比較下各方的性能。我們看到,ngx.update_time() + ngx.now()
的組合是最耗時的,因為 ngx.update_time()
除了要獲取當前的時間外,還要更新一系列時間字符串。值得一提的是,我們自己實現 current_ms_time()
無論是性能還是時間的精准度都比 os.time()
要好,可見在 JIT 下,FFI 實現可以擊敗無法被 JIT 的內置函數。另外我們也會看到,resty.core
版本的 ngx.now()
和 ngx.update_time()
相當地快。在本演講的最后,我會解釋為什么會這樣。
有限的 timer
在 OpenResty 里面,timer 的個數是有限的。
首先每個 timer 都是一個 fake request,在 Nginx 這邊看來,每個 timer 其實都是一個請求。跟真實的 request 一樣,它也會占據一個連接。所以你的 worker_connections
要足夠大,即使 timer 並不會真的建立一個網絡連接。另外,OpenResty 還有兩個參數限制了 timer 的總數,lua_max_pending_timers
和 lua_max_running_timers
,需要保證它們夠大。另外如果啟動 timer 時沒有足夠的內存,也是會失敗的。如果可以的話,盡可能用 ngx.timer.every
來啟動定期的 timer。用 ngx.timer.at
反復啟動 timer 的話,一旦每次啟動失敗,那就真的失敗了。
既然每個 timer 都是一個請求,那么如果你每個網絡請求都會啟動一個甚至多個 timer,性能自然好不到哪兒去。最簡單的優化辦法是引入批處理,避免不斷創建 timer,你也可以考慮下隊列,甚至更為復雜的時間輪。不過要想復用 timer,還要面對額外的挑戰……
第一個挑戰來自於 Nginx 的每請求內存池。只有在請求結束時,Nginx 才會釋放這一內存池內所有的內存。而前面已經說了,每個 timer 在 Nginx 看來都是一個請求,所以某種意義上,一個 timer 就像是一個長連接,尤其當這個 timer 會一直運行到進程結束時。長時間運行的 timer 自然會帶來內存的持續上漲,但其上漲的速度一般而言並不顯著。原因有二:
- 許多 OpenResty API 實際上只會分配 Lua 層面上的內存。而這部分內存是在 LuaJIT GC 管理下的。在引入了 lua-resty-core 后更是如此。
- cosocket 的 send/receive 操作,會有 buffer 復用機制,使得其內存占用不會無限制增加。
另一個挑戰來自於較為隱晦的地方。當前 entry thread 會把它所創建的每個協程,記錄到一個鏈表里。而各種協程 API,大都需要訪問這個鏈表。如果 timer 或者長連接持續大量地創建協程,會導致協程 API 變得越來越慢。就目前的情況,要想解決這個問題,需要對協程進行復用,避免無限制地創建協程。
第二部分
講完 OpenResty,讓我們看看 LuaJIT。
不可變的 string
Lua 跟其他大部分語言有一點不一樣,就是它的字符串是不可變的。不變字符串自然有些優點,比如減低內存占用、比較字符串時可以直接比較它的內存地址等等。但是缺點也不少。在其他語言里面,當我們想修改一個字符串部分內容,比如大小寫轉換,我們可以直接改變對應的位置上的 byte。畢竟字符串通常就是一個字節數組(byte array)。但是這事要在 Lua 里面做,非得拷貝一個新字符串不可。而且由於要保證每種字符串都只有一個實例,lj_str_new
需要對實際的字符串內容做 hash,然后用它查找該內容是否已經創建了對應的實例。
既然說到做 hash,那么自然得提到 hash 碰撞。對於那些 hash 值一樣的字符串,LuaJIT 把它們存儲在鏈表里。如果許多字符串有着一樣的 hash 值,那么這個鏈表就會很長,原來 O(1) 的開銷會退化為 O(n)。這就是所謂的 hash 碰撞。不幸的是,LuaJIT 的默認的字符串 hash 函數就有這樣的問題。在網上你能找到一些相關的報告。
OpenResty 自帶的 LuaJIT 用硬件加速的 CRC32 函數替換了默認的字符串 hash 函數,降低了發生 hash 碰撞的風險。需要說明的是,只有在支持 SSE 4.2 指令集的 x64 平台上才會啟用這一函數。
即使 hash 碰撞的問題可以避免,lj_str_new
依然是一個既頻繁又耗時的函數。
最好的優化就是不做。比如如果只是想查看字符串里面的字符,可以用 string.byte
代替 string.sub
。
OpenResty 里面,也有許多 API 支持在 C 層面上完成字符串的拼接,無需調用 lj_str_new
,比如 cosocket 的 send、ngx.say
和 ngx.log
。
它們接受多個參數,或者數組 table,在 C 層面上拼接成字符串。這里的數組 table 甚至可以是嵌套的。
誰可以代替字節數組
LuaJIT 缺乏字節數組,這是個痛點,尤其是在做協議轉換的時候。一個通常的代替品是用數組 table。另一個是借助 FFI,申請一塊名符其實的字節數組。
這里有些操作數組 table 的方法。有兩個需要解釋下:
table.new
是 LuaJIT 獨有的方法,允許在創建 table 時指定大小,減少后面 resize 的成本。
table.clone
是 OpenResty 自帶的 LuaJIT 的方法,允許對一個 table 做淺復制。它內部調用了 lj_tab_dup
這個 LuaJIT 內部函數。
buffer 復用
前面講到,我們可以給某些 API 傳遞 table 而不是 concat 之后的字符串。可能有人會懷疑,創建 table 開銷不會比 concat 字符串大嗎?
其實這里的 table,是可以復用的,無需每次都創建。
如果你的函數里面沒有 yield,你可以直接拿個 local 變量,每次都復用這個變量。為了避免影響到其他函數,我們這里用了個 do block
把相關的變量都包起來。
如果你的函數里面有 yield,你可以通過 lua-tablepool
這個庫實現 table 的回收復用。
避免在 table 中間存儲 nil
一個眾所周知的事實:如果數組 table 中間有 nil,獲取到的長度可能會不准。Lua 可能會把某個 nil 的位置作為這個 table 的結尾。
不過較少為人所知的是,nil 也會影響 unpack 的結果。由於 unpack 返回的結果個數取決於 table 的長度,所以如果獲取的長度不准,unpack 返回的結果數也會不准。如果我們 unpack 前面的 table,就只會返回第一個數 0. 另外,Lua 里常用的兩種迭代數組的方式,for i in ipairs()
和 for i = 1, #table
,在處理數組中的 nil 的方式上有所不同。前者每次迭代時都會檢查當前元素是不是 nil,如果是的話結束迭代。
盡可能不要把數組 table 中的某個元素置為 nil,應該用 ngx.null 作為占位符。
有上限的 unpack
既然提到了 unpack,順便提下 unpack 也是有大小限制的。如果 unpack 的數組大小超過 8000,unpack 會拋異常。
FFI buffer 作為 byte[]
除了用 table,也可以考慮下用 FFI buffer 作為字節數組。FFI buffer 的好處在於內存占用少。壞處呢,一個是周邊的 API 支持少,用起來不像 table 那么方便;另一個是,如果不能被 JIT 編譯的話,FFI 操作很昂貴。
當然 FFI buffer 也是可以復用的,復用方法跟 table 差不多。有興趣的聽眾可以看看 lua-resty-core 的 get_string_buf
這個方法。
LUAJIT_NUMMODE
LuaJIT 有一個編譯選項 LUAJIT_NUMMODE,控制對 number 類型的處理方式。它的默認值為 1。當我們把它在編譯時設置為 2 時,對於能夠用 32 位整數表示的 number,LuaJIT 會用 int32 表示,而不是一概用 double 來表示。通常來說,設置 LUAJIT_NUMMODE=2 會讓程序快一點,因為 CPU 更擅長對整數進行計算。但是也不一定,因為影響性能的因素非常復雜,具體問題需要具體分析。后面我會給大家看一個例子,LUAJIT_NUMMODE=2 會讓程序更慢。
JIT
終於講到重頭戲,LuaJIT 的 JIT 編譯。LuaJIT 采用 Tracing JIT 來記錄並實時編譯字節碼。當某個循環或者函數調用足夠熱時,LuaJIT 會開始記錄執行的字節碼,進行優化后生成 IR,然后把 IR 編譯成 mcode。你可以在上面兩個文檔中找到對 字節碼 和 IR 的一些說明。
你可以在 LuaJIT 代碼中添加下面兩行代碼,把這一過程 dump 到指定文件中:
local dump = require "jit.dump"
dump.on("abimsrtx", filepath)
```
讓我們看一個實際的例子。
這個例子是為了展示 JIT 過程而設計的,我們可以從 dump 輸出中看到不少信息。
從 Trace 2 的 bytecode 部分可以看到,Tracing JIT 在 tracing 的時候是跨函數的。
從 Trace 2 的 IR 部分可以看到,string.rep
等操作被移到了 LOOP 以外,因為它的結果在整個循環中是不變的。
在 IR 里面有一個有意思的輸出:
CALLXS [0x7f248ac41180]
從對應的 base_encoding.lua
代碼可以看出,這里其實是通過 FFI 調用了某個 so 里面的函數。
在最終生成的 mcode 里面,我們也能找到對應的 call 0x7f248ac41180
。
為什么 FFI 在 JIT 下性能會比解釋器模式下快很多呢?原因在於解釋器模式下,LuaJIT FFI 需要實現 Lua 和 C 數據間的 marshal 和 unmarshal。而在 JIT 模式下,兩者的交互都是匯編層面上的。
我們可以看到,不少 IR 左邊有個 >
,這表示這個 IR 是作為 guard 存在的。Trace 是沒有分支的,一旦發生 guard 不能滿足的情況,會退出當前 trace 進入解釋器模式。
看下 LOOP 里面這個 NE 0069
這個 IR。結合上一個 IR,可以知道它的意思是判斷 % 5 != 4
。我們可以找到對應的 mcode:
7f24b63bfeca mov ebx, eax
7f24b63bfecc mov esi, 0x5
7f24b63bfed1 mov edi, ebp
7f24b63bfed3 call 0x7f248c2e68a0 ->lj_vm_modi
7f24b63bfed8 mov rdi, [rsp+0x8]
7f24b63bfedd cmp eax, +0x04
7f24b63bfee0 jz 0x7f24b63b002c ->7
我們可以看到,這里面插了個 jz 0x7f24b63b002c
的判斷。也就是如果不符合 != 4
的條件,就會跳到 0x7f24b63b002c
這個地址,而不是繼續執行下去。旁邊有一個 ->7
的標記,表示退出時用 snapshot 7 里面的數據恢復解釋器模式。snapshot 7 就在 NE 0069
的上面。需要解釋下,snapshot 的輸出和 IR 的輸出是並行的,只是恰好在 NE 0069
上面,兩者輸出的位置並無因果性。
再往下拉,我們會看到 TRACE 2 多次 exit 7
。當另一個分支足夠熱時,會從原來的 TRACE 里面生成一個 side trace,也就是這里的 TRACE 3. 然后 TRACE 3 追蹤到 unpack 這里的沒了。因為 unpack 是 NYI 的,JIT 沒法 tracing 下去。不過好在 LuaJIT 支持 stitch,可以繞過 NYI 語句,生成新的 TRACE 4.有點像下了高速,開了段路后又重上高速。
side trace 有一個問題,就是它們在結束后,會跳回到 root trace 的開頭。像 TRACE 4 的最后一個指令,就是跳到 TRACE 2 的開頭。我們知道,TRACE 4 是從 LOOP 里面長出來的,然而 TRACE 4 結束后會跳到 TRACE 2 開頭,也就是像 string.rep
這樣的操作,每次在 TRACE 4 執行完之后都會再走一遍,哪怕它的結果在整個循環里是不變的。
讓我們看下第二個例子。這是段在 Lua 里面算 CRC32 的程序。然后改動了兩行代碼,用 FFI buffer 替換了 table,它的性能是原來的 2.5 倍。我會從 jit.dump
輸出的角度解釋為什么前后差別那么大。
why_byte_level_slow
是 table 版本的 dump,而 crc32_ffi
是 FFI 版本的 dump。這兩個 dump 的 TRACE 1,都是一樣的字節碼,但是兩者 IR 的 LOOP 中間部分不一樣。拋去相似的部分不談,可以看出 table 版本多了個 ABC,也就是 array boundary check
。然后比較下 mcode 對應部分,table 版本有 23 個指令,而 FFI 版本只有 17 個指令。
但是 LOOP 部分從 23 個指令減少到 17 個跟 2.5 倍提升對不上。顯然還有第二個因素在起作用。
看下 table 版本的 dump,你會發現它的 TRACE 數量很多,而且相似。仔細看,你會發現,有些地方從 table 中加載的數據類型是 num,而有些地方是 int。比如 TRACE 1 的 ALOAD 是 num,而 TRACE 2 的 ALOAD 是 int。這個 dump 是在 LUAJIT_NUMMODE=2 的情況下生成的。前面提到,這種模式下,LuaJIT 會盡可能把數值當作 int32 處理。但是 CRC32 表里面,有些數字超過了 int32,只能作為 double 處理。由於這兩種類型需要生成不同的 mcode,導致大量 side trace 的生成。在 FFI 版本里,由於我們指定 CRC32 表的類型為 unsigned int,就沒有這個問題。
why lua-resty-core is faster
最后我們來看下為什么同樣的函數, lua-resty-core 里面的版本會更快。這是同樣一段使用了 ngx.re.find
的代碼,在 CFunction 和 FFI+JIT 兩個版本下生成的火焰圖。我們可以看到,CFunction 版本的火焰圖里面有大量 lua_xxxx
這樣的函數的開銷,而 FFI+JIT 版本里面,就沒有這些函數。
由於 JIT 時可以優化掉 FFI 調用的數據交換過程,所以當一個 API 在數據交換上耗費的比重越多,改寫成 FFI 時帶來的性能提升越大。
比如 ngx.re.find (數據交換復雜)
比如 ngx.time (C 部分的邏輯簡單,大部分耗時在數據交換上)
反之,如果一個 API 耗費在數據交換的比重小,則 FFI 化帶來的提升就小,比如 ngx.md5。
FFI 改造還能減少 stitch,這方面的提升需要結合具體上下文分析。