本文內容,由我在 OpenResty Con 2018 上的同名演講的演講稿整理而來。nginx
PPT 能夠在 這裏 下載,由於內容比較多,我就不在這裏一張張貼出來了。有些內容須要結合 PPT 才能理解,請多包涵。git
編寫正確且高效的應用,最爲關鍵是一系列軟件工程上的實踐,像測試、code review、灰度、監控、壓測等等。不過因爲這是 OpenResty 大會上的演講,我會專一於講講 OpenResty 和 LuaJIT 的一些小細節,幫助各位聽衆避免線上踩坑。github
按慣例,得先自我介紹下。spacewander,這個是個人 GitHub 暱稱。我目前在 OpenResty Inc. 公司工做。算法
先從 OpenResty 開始講吧。數組
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
的時間間隔。網絡
要想區分不一樣 worker 進程,一般的作法是用 ngx.worker.id()
。須要注意的是,有些時候多個 worker 進程可能會有一樣的 id,好比在 reload 或者 binary upgrade 的時候。socket
Nginx 在 reload 的時候,會有兩組 worker 進程。新的 worker 們會接替老的 worker,但直到老 worker 退出以前,這兩組 worker 是同時運行的。若是 shdict 的配置不變,這兩組進程甚至有一樣的共享內存空間,因此在用 worker id 做爲 shdict key 的時候,這種邊界狀況須要考慮下。ide
Nginx 在 binary upgrade 的時候,則會有兩組 master+workers。這兩組進程並不共享內存空間,因此用 worker id 做爲 shdict key 時,不用關心這種狀況。可是當你用 worker id 做爲外部服務或者文件系統的 key 時,仍是要注意下的。一種可能的解決方案是引入 parent pid 做爲前綴,而後處理好 key 變化時,數據遷移的邏輯。
若是 shdict 裏面的數據超過了事先分配好的內存大小限制,OpenResty 會根據 LRU 算法,清除現有的數據。我發現許多狀況下,咱們會忽視這一事實,認爲 shdict 裏面的空間必定足以容納全部數據。不過倘若你是一個厭惡風險的人,能夠考慮只用 safe_
開頭的一系列 API 來操做 shdict。
這一類 API 在 shdict 不夠空間時,會失敗,而不是默默地擠掉當前的數據。
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()
至關地快。在本演講的最後,我會解釋爲何會這樣。
在 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 天然會帶來內存的持續上漲,但其上漲的速度通常而言並不顯著。緣由有二:
另外一個挑戰來自於較爲隱晦的地方。當前 entry thread 會把它所建立的每一個協程,記錄到一個鏈表裏。而各類協程 API,大都須要訪問這個鏈表。若是 timer 或者長鏈接持續大量地建立協程,會致使協程 API 變得愈來愈慢。就目前的狀況,要想解決這個問題,須要對協程進行復用,避免無限制地建立協程。
講完 OpenResty,讓咱們看看 LuaJIT。
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 內部函數。
前面講到,咱們能夠給某些 API 傳遞 table 而不是 concat 以後的字符串。可能有人會懷疑,建立 table 開銷不會比 concat 字符串大嗎?
其實這裏的 table,是能夠複用的,無需每次都建立。
若是你的函數裏面沒有 yield,你能夠直接拿個 local 變量,每次都複用這個變量。爲了不影響到其餘函數,咱們這裏用了個 do block
把相關的變量都包起來。
若是你的函數裏面有 yield,你能夠經過 lua-tablepool
這個庫實現 table 的回收複用。
一個衆所周知的事實:若是數組 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 的數組大小超過 8000,unpack 會拋異常。
除了用 table,也能夠考慮下用 FFI buffer 做爲字節數組。FFI buffer 的好處在於內存佔用少。壞處呢,一個是周邊的 API 支持少,用起來不像 table 那麼方便;另外一個是,若是不能被 JIT 編譯的話,FFI 操做很昂貴。
固然 FFI buffer 也是能夠複用的,複用方法跟 table 差很少。有興趣的聽衆能夠看看 lua-resty-core 的 get_string_buf
這個方法。
LuaJIT 有一個編譯選項 LUAJIT_NUMMODE,控制對 number 類型的處理方式。它的默認值爲 1。當咱們把它在編譯時設置爲 2 時,對於可以用 32 位整數表示的 number,LuaJIT 會用 int32 表示,而不是一律用 double 來表示。一般來講,設置 LUAJIT_NUMMODE=2 會讓程序快一點,由於 CPU 更擅長對整數進行計算。可是也不必定,由於影響性能的因素很是複雜,具體問題須要具體分析。後面我會給你們看一個例子,LUAJIT_NUMMODE=2 會讓程序更慢。
終於講到重頭戲,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,就沒有這個問題。
最後咱們來看下爲何一樣的函數, 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,這方面的提高須要結合具體上下文分析。