Why Does lua-resty-core Perform Better?
API7.ai
September 30, 2022
前回の2つのレッスンで述べたように、Luaはコアを短くコンパクトに保つ埋め込み開発言語です。LuaをRedisやNGINXに埋め込んで、ビジネスロジックを柔軟に処理することができます。また、Luaでは既存のC関数やデータ構造を呼び出すことができ、車輪の再発明を避けることができます。
Luaでは、Lua C APIを使用してC関数を呼び出すことができ、LuaJITではFFIを使用できます。OpenRestyの場合、
- コアの
lua-nginx-module
では、C関数を呼び出すAPIはLua C APIを使用して実装されています。 lua-resty-core
では、lua-nginx-module
に既にあるAPIの一部がFFIモデルを使用して実装されています。
なぜFFIで実装する必要があるのか疑問に思うかもしれませんが、心配しないでください。ngx.base64_decodeというシンプルなAPIを例に、Lua C APIとFFI実装の違いを見てみましょう。これにより、その性能について直感的に理解することができます。
Lua CFunction
まず、lua-nginx-module
でLua C APIを使用してどのように実装されているかを見てみましょう。プロジェクトのコードでdecode_base64
を検索すると、ngx_http_lua_string.c
にその実装が見つかります。
lua_pushcfunction(L, ngx_http_lua_ngx_decode_base64);
lua_setfield(L, -2, "decode_base64");
上記のコードは見るのが面倒ですが、幸いなことに、lua_
で始まる2つの関数とその引数の具体的な役割を掘り下げる必要はありません。ここで登録されているCFunction、ngx_http_lua_ngx_decode_base64
が、公開されているAPIであるngx.base64_decode
に対応していることを知っていれば十分です。
次に、このCファイルでngx_http_lua_ngx_decode_base64
を検索し、ファイルの先頭で定義されている部分を見てみましょう。
static int ngx_http_lua_ngx_decode_base64(lua_State *L);
Luaから呼び出せるC関数のインターフェースは、Luaが要求する形式に従う必要があります。つまり、typedef int (*lua_CFunction)(lua_State* L)
です。これには、lua_State
型のポインタLが引数として含まれます。その戻り値の型は整数で、戻り値自体ではなく、返される値の数を示します。
以下にその実装を示します(ここではエラーハンドリングコードを削除しています)。
static int
ngx_http_lua_ngx_decode_base64(lua_State *L)
{
ngx_str_t p, src;
src.data = (u_char *) luaL_checklstring(L, 1, &src.len);
p.len = ngx_base64_decoded_length(src.len);
p.data = lua_newuserdata(L, p.len);
if (ngx_decode_base64(&p, &src) == NGX_OK) {
lua_pushlstring(L, (char *) p.data, p.len);
} else {
lua_pushnil(L);
}
return 1;
}
このコードの主な部分は、ngx_base64_decoded_length
とngx_decode_base64
で、どちらもNGINXが提供するC関数です。
Cで書かれた関数は、戻り値をLuaコードに渡すことができず、LuaとCの間で呼び出しパラメータと戻り値をスタックを介してやり取りする必要があります。これが、一見して理解できないコードが多い理由です。また、このコードはJITで追跡できないため、LuaJITにとってこれらの操作はブラックボックスであり、最適化できません。
LuaJIT FFI
FFIとは異なり、FFIのインタラクティブ部分はLuaで実装されており、JITで追跡して最適化することができます。もちろん、コードもより簡潔で理解しやすくなります。
base64_decode
の例を取り上げ、そのFFI実装がlua-resty-core
とlua-nginx-module
の2つのリポジトリに分散しているので、前者で実装されているコードを見てみましょう。
ngx.decode_base64 = function (s)
local slen = #s
local dlen = base64_decoded_length(slen)
local dst = get_string_buf(dlen)
local pdlen = get_size_ptr()
local ok = C.ngx_http_lua_ffi_decode_base64(s, slen, dst, pdlen)
if ok == 0 then
return nil
end
return ffi_string(dst, pdlen[0])
end
CFunctionと比較すると、FFI実装のコードは非常にシンプルで、その具体的な実装はlua-nginx-module
リポジトリのngx_http_lua_ffi_decode_base64
です。興味があれば、この関数の性能を自分で確認してみてください。非常にシンプルなので、ここではコードを掲載しません。
しかし、注意深く見ると、上記のコードスニペットにいくつかの関数命名規則があることに気づくでしょうか?
はい、OpenRestyのすべての関数には命名規則があり、名前からその使用方法を推測できます。例えば:
ngx_http_lua_ffi_
は、FFIを使用してNGINX HTTPリクエストを処理するLua関数です。ngx_http_lua_ngx_
は、C関数を使用してNGINX HTTPリクエストを処理するLua関数です。- その他の
ngx_
やlua_
で始まる関数は、それぞれNGINXとLuaの組み込み関数です。
さらに、OpenRestyのCコードには厳格なコード規約があり、公式のCコードスタイルガイドを読むことをお勧めします。これは、OpenRestyのCコードを学び、PRを提出したい開発者にとって必須のドキュメントです。そうでないと、PRがよく書かれていても、コードスタイルの問題で繰り返しコメントされ、変更を求められることになります。
FFIに関する詳細やAPIについては、公式のLuaJITチュートリアルとドキュメントを読むことをお勧めします。技術コラムは公式ドキュメントの代わりにはなりません。限られた時間で学習の道筋を示し、回り道を少なくすることはできますが、難しい問題はやはり自分で解決する必要があります。
LuaJIT FFI GC
FFIを使用する際、誰がFFIで要求されたメモリを管理するのか、Cで手動で解放するべきか、それともLuaJITが自動的に回収するべきか、混乱することがあります。
ここで簡単な原則があります:LuaJITは自身が割り当てたリソースのみを管理します。例えば、ffi.C.malloc
を使用してメモリブロックを要求した場合、対応するffi.C.free
で解放する必要があります。公式のLuaJITドキュメントには、同等の例があります。
local p = ffi.gc(ffi.C.malloc(n), ffi.C.free)
...
p = nil -- pへの最後の参照がなくなる。
-- GCは最終的にファイナライザを実行する: ffi.C.free(p)
このコードでは、ffi.C.malloc(n)
がメモリセクションを要求し、ffi.gc
がデストラクタコールバック関数ffi.C.free
を登録します。ffi.C.free
は、cdata p
がLuaJITによってGCされると自動的に呼び出され、Cレベルのメモリを解放します。そして、cdataはLuaJITによってGCされます。LuaJITは上記のコードでp
を自動的に解放します。
OpenRestyで大きなメモリチャンクを要求する場合、ffi.new
ではなくffi.C.malloc
を使用することをお勧めします。その理由も明らかです。
ffi.new
はcdata
を返し、これはLuaJITが管理するメモリの一部です。- LuaJIT GCにはメモリ管理の上限があり、OpenRestyのLuaJITにはGC64オプションが有効になっていないため、単一ワーカーのメモリ上限は2Gのみです。LuaJITのメモリ管理上限を超えると、エラーが発生します。
FFIを使用する際、メモリリークに特に注意する必要があります。しかし、誰でもミスを犯すことがあり、人間が書いたコードには常にバグがあります。
ここで、OpenRestyの強力な周辺テストとデバッグツールチェーンが役立ちます。
まず、テストについて話しましょう。OpenRestyシステムでは、Valgrindを使用してメモリリークを検出します。
前回のコースで紹介したテストフレームワークtest::nginx
には、メモリリーク検出モードがあり、ユニットテストケースセットを実行できます。環境変数TEST_NGINX_USE_VALGRIND=1
を設定する必要があります。公式のOpenRestyプロジェクトは、リリース前にこのモードで完全に登録され、後でテストセクションで詳細に説明します。
OpenRestyのCLI restyにも--valgrind
オプションがあり、テストケースを書いていなくても、単独でLuaコードを実行できます。
次に、デバッグツールを見てみましょう。
OpenRestyは、systemtapベースの拡張機能を提供し、OpenRestyプログラムのライブダイナミック分析を実行できます。このプロジェクトのツールセットでキーワードgc
を検索すると、lj-gc
とlj-gc-objs
の2つのツールが見つかります。
core dump
のようなオフライン分析には、OpenRestyはGDBツールセットを提供しており、gc
を検索すると、lgc
、lgcstat
、lgcpath
の3つのツールが見つかります。
これらのデバッグツールの具体的な使用方法は、後でデバッグセクションで詳しく説明するので、まずは印象を持っておいてください。結局のところ、OpenRestyにはこれらの問題を特定して解決するための専用のツールセットがあります。
lua-resty-core
上記の比較から、FFIアプローチはコードがよりクリーンであるだけでなく、LuaJITによって最適化できるため、より良い選択肢であることがわかります。OpenRestyはCFunction実装を非推奨とし、コードベースからパフォーマンスが削除されました。新しいAPIは現在、lua-resty-core
リポジトリでFFIを通じて実装されています。
2019年5月にリリースされたOpenResty 1.15.8.1以前は、lua-resty-core
がデフォルトで有効になっていなかったため、パフォーマンスの損失や潜在的なバグが発生していました。そのため、歴史的なバージョンを使用している場合は、手動でlua-resty-core
を有効にすることを強くお勧めします。init_by_lua
フェーズに1行のコードを追加するだけで済みます。
require "resty.core"
もちろん、遅ればせながら1.15.8.1リリースでlua_load_resty_core
ディレクティブが追加され、lua-resty-core
がデフォルトで有効になりました。
個人的には、OpenRestyはlua-resty-core
の有効化に関してまだ慎重すぎると感じています。オープンソースプロジェクトは、同様の機能をできるだけ早くデフォルトで有効にするべきです。
lua-resty-core
は、lua-nginx-module
プロジェクトの一部のAPI(ngx.re.match
、ngx.md5
など)を再実装しているだけでなく、いくつかの新しいAPI(ngx.ssl
、ngx.base64
、ngx.errlog
、ngx.process
、ngx.re.split
、ngx.resp.add_header
、ngx.balancer
、ngx.semaphore
など)も実装しています。これらについては、後でOpenResty APIの章で詳しく説明します。
まとめ
以上を踏まえて、FFIは良いものですが、パフォーマンスの銀の弾丸ではありません。その効率性の主な理由は、JITで追跡して最適化できることです。JITできないLuaコードを書いてインタプリタモードで実行する必要がある場合、FFIは効率が低下します。
では、どの操作がJITでき、どの操作ができないのでしょうか?JITできないコードを書かないようにするにはどうすればよいでしょうか?次のセクションでこれを明らかにします。
最後に、実践的な宿題です:lua-nginx-module
とlua-resty-core
の両方にある1つまたは2つのAPIを見つけ、パフォーマンステストで違いを比較してみてください。FFIのパフォーマンス向上がどれほど大きいかがわかります。
コメントを残していただけると嬉しいです。あなたの考えや成果を共有し、この記事を同僚や友人と共有して、交流と進歩を共にしましょう。