Why Does lua-resty-core Perform Better?

API7.ai

September 30, 2022

OpenResty (NGINX + Lua)

前回の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_lengthngx_decode_base64で、どちらもNGINXが提供するC関数です。

Cで書かれた関数は、戻り値をLuaコードに渡すことができず、LuaとCの間で呼び出しパラメータと戻り値をスタックを介してやり取りする必要があります。これが、一見して理解できないコードが多い理由です。また、このコードはJITで追跡できないため、LuaJITにとってこれらの操作はブラックボックスであり、最適化できません。

LuaJIT FFI

FFIとは異なり、FFIのインタラクティブ部分はLuaで実装されており、JITで追跡して最適化することができます。もちろん、コードもより簡潔で理解しやすくなります。

base64_decodeの例を取り上げ、そのFFI実装がlua-resty-corelua-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を使用することをお勧めします。その理由も明らかです。

  1. ffi.newcdataを返し、これはLuaJITが管理するメモリの一部です。
  2. 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-gclj-gc-objsの2つのツールが見つかります。

core dumpのようなオフライン分析には、OpenRestyはGDBツールセットを提供しており、gcを検索すると、lgclgcstatlgcpathの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.matchngx.md5など)を再実装しているだけでなく、いくつかの新しいAPI(ngx.sslngx.base64ngx.errlogngx.processngx.re.splitngx.resp.add_headerngx.balancerngx.semaphoreなど)も実装しています。これらについては、後でOpenResty APIの章で詳しく説明します。

まとめ

以上を踏まえて、FFIは良いものですが、パフォーマンスの銀の弾丸ではありません。その効率性の主な理由は、JITで追跡して最適化できることです。JITできないLuaコードを書いてインタプリタモードで実行する必要がある場合、FFIは効率が低下します。

では、どの操作がJITでき、どの操作ができないのでしょうか?JITできないコードを書かないようにするにはどうすればよいでしょうか?次のセクションでこれを明らかにします。

最後に、実践的な宿題です:lua-nginx-modulelua-resty-coreの両方にある1つまたは2つのAPIを見つけ、パフォーマンステストで違いを比較してみてください。FFIのパフォーマンス向上がどれほど大きいかがわかります。

コメントを残していただけると嬉しいです。あなたの考えや成果を共有し、この記事を同僚や友人と共有して、交流と進歩を共にしましょう。