LuaJIT+FFIで共有メモリを試してみた
はじめに
openresty/lua-nginx-module の ngx.shared.DICT のような仕組みが Apache Traffic Server™ の Lua Plugin にも欲しいなあと以前から思っていました。
私の場合は公式の Lua の実装ではなく LuaJIT を使用していますので、複数の LuaJIT の VM でデータを共有して排他制御しつつ読み書きしたいというわけです。NGINX の場合はワーカーがマルチプロセス構成なのでプロセス間で参照できる共有メモリが必要です。Traffic Server の場合はシングルプロセス・マルチスレッドですが、可能なら同じサーバ上で稼働している NGINX とも共有したいという思いがあって、そうなるとやはりマルチプロセスとなります。
マルチプロセスでの排他制御について、以前調べたときは良い方法を見つけられず諦めていたのですが、改めて検索してみると linux - Using pthread mutex shared between processes correctly - Stack Overflow というページが見つかりました。ここに書かれている手法を試してみたので、その際に調べたり試したりしたことをメモしておきます。
試してみたソースコードは https://github.com/hnakamur/luajit-pshared-mmapf-experiment にあります。
マルチプロセスで共有メモリを使う際の要約
- shm_openで共有メモリを作成または開いてmmapでメモリにマップすると複数のプロセス間で共有できる。
mmap
ではMAP_SHARED
かMAP_SHARED_VALIDATE
を指定。- 存在しない場合に作成する処理を排他制御するには
shm_open
でO_CREAT
とO_EXCL
を指定する。
- マップしたメモリ上に
pthread_mutex_t
かpthread_rwlock_t
のインスタンスを作って排他制御することができる- 複数プロセスで共有して使うため、作成時にpthread_mutexattr_setpsharedかpthread_rwlockattr_setpsharedで
PTHREAD_PROCESS_SHARED
を指定。 pthread_rwlock_t
はデフォルトではreader優先だが、非標準でGNU拡張のpthread_rwlockattr_setkind_npを使えばwriter優先にもできる。- 今回は試してないがman 7 shm_overviewによるとPOSIXセマフォを使う方法もある (man 7 sem_overview参照)。
- 複数プロセスで共有して使うため、作成時にpthread_mutexattr_setpsharedかpthread_rwlockattr_setpsharedで
排他制御にmutexとrwlockのどちらを使うかとrwlockのreader/writer starvationについて
NGINXは共有メモリの排他制御に独自実装のmutex (src/core/ngx_shmtx.h)を使っています。src/core/ngx_cycle.c#L413-L500で共有メモリを作成していて、そこから呼ばれるngx_init_zone_pool関数内のsrc/core/ngx_cycle.c#L1007でngx_shmtx_create関数を呼んでいます。
mutexではなくrwlockのほうが複数のreaderが同時に実行できて良さそうなのに、なぜmutexにしてるんだろう、なぜsrc/core/ngx_rwlock.hと単一プロセス用のはあるのにngx_shrwlock
は無いんだろうという素朴な疑問がありました。
embeddedmonologue - rwlock and reader/writer starvationというブログにrwlockではreader starvationとwriter starvationの両方を防ぐことはできないと説明されていました。利用ケースに応じて、reader優先にしてwriter starvationは許容するか、writer優先にしてreader starvationは許容するか、どちらかを選ぶ必要があります。
これを知って、利用ケースを限定できないような汎用的な仕組みを提供するとなるとmutexにしておいたほうが無難だなと納得しました。
The Allegory SDKがLuaJIT+FFIでコードを書く教材として役立つ
luapower - The LuaJIT distribution for Windows, Linux and OS X (2022-12-04現在証明書期限切れになってました。file-show-cert-info-server-shで調べるとnotAfter=Dec 3 07:11:35 2022 GMT
でした)のコードを時々参考にしていたのですが、更新停止のお知らせとアクティブに開発中のThe Allegory SDKへのリンクがあって知りました。
サーバサイドはLuaJITで書かれていてLinux、macOS、Windowsに対応しています。ですが私の場合はLinuxのみで良いのと、細かいところでいろいろ調整したいので、そのまま利用するのではなくコードを参考にしました。
pthread_mutex
とpthread_rwlock
のFFI周りのコードはここから頂きました。感謝!
LuaJITのffi.metatypeの使い方も学べた
またThe Allegory SDKのコードを見て、ffi.metatypeの使い方も学びました。
pshared_rwlock.luaでpthread_rwlock_t
をラップしてメソッドを追加するのに使っています。
Goのエラー処理を参考にして構造化エラーのコードを書いてみた
ここで突然Goの話になりますが、errors: add support for wrapping multiple errors · Issue #53435 · golang/goは個人的にはかなり嬉しいと思っています。今までだと複数エラーが出てもreturnで返せるのは1つだけなので、残りはログ出力するしかありませんでした。エラーのログは呼ばれた側で出力するのではなく、呼び出し側がにエラーを返してそちらで1回だけログ出力するのが理想だと私は思っています。呼ばれた側で出力するとログが一か所ではなく分散してしまい確認が大変なので。
また、Proposal: Structured Loggingが来たことで、構造化エラーを文字列化するのではなく構造を維持したままログ出力するのもしやすくなりそうということでこちらも期待しています。
ということでLuaJITでコードを書く場合も、エラーを構造化できないかと思って今回書いてみました (errors.lua)。といってもGoのerrors packageのようなerrors.Is
やerrors.As
のような判定の仕組みはなくて、エラーの値を構築するところだけです。
エラーはError
のインスタンスとして生成し、必要に応じて属性をフィールドとして設定できます。
ログ出力時はerror
メソッドを呼ぶとcJSONで文字列化します。
エラーインスタンスのフィールドを見てエラー処理を分岐する処理は以下のような感じで書けます。
local f, err = open(shm_name, map_len)
if err ~= nil then
if err.errno ~= errors.ENOENT and err.errno ~= errors.EACCES then
return nil, err
end
-- err.errnoがerrors.ENOENTかerrors.EACCESの場合のエラー処理
Visual Studio Code のLua拡張が便利
今回初めて使ってみたのですが便利でした。静的型付けではないLua言語でここまで出来るのかと驚きました。
コードフォーマットも出来ますし、ホバーで関数などの定義が表示されますし、Rename Symbolでは参照側のファイルも連動して変更されました (ただこれはされない場合もあるようです。詳しく調べてないです)。
またnilチェックしないで参照しているとPROBLEMSに警告が出るというのもすごいなと思いました。
ただ、Goっぽく local value, err = some_function()
のように値とエラーを返すようにしていると、errが非nilの場合にreturnで抜けると、その後はvalueは非nilなんだけどなー(と言っても関数の実装次第)、と思いつつ、上の警告を消すために以下のようにnilチェックを入れるようにしてみました。
unreachableはRustのunreachableやZigのunreachableの名前を頂きました。
local ms, err = mmap_shm.open_or_create(shm_name, map_len,
pshared_rwlock.PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP)
if err ~= nil then
print(err:error())
return
end
if ms == nil then
return errors.unreachable()
end
-- この下でmsを参照。
LuaJIT+FFIでuint64を扱うためのハック
今回の例の最終版では使わなくなっていますが、LuaJIT+FFIでuint64を扱うコードもThe Allegory SDKのfs.lua#L1375-L1388から頂きました (uint64.lua)。
Luaの数値型はfloat64で64bit整数の範囲の数は表せないので、以下の共用体を使って32bit整数を2つ指定して64bit整数を作ります (この共用体はLittle Endian用です)。
union {
struct { uint32_t lo; uint32_t hi; };
uint64_t x;
}
fs.lua#L1375-L1388ではインスタンスを1つだけ作って使いまわす方式ですが、uint64.luaでは、その都度インスタンスを生成する方式にしてみました。FFIの関数に2つ以上の64bit引数を渡す場合はこの方式が必要になるはずということで。
Creating cdata Objectsにffi.new()
を何度も呼ぶ場合はffi.typeof()
を一度読んで戻り値の関数をコンストラクタとして呼び出してインスタンス生成するほうがパフォーマンスが良いと書いてあったので、そのようにしてみました。
local ffi = require "ffi"
local uint64_union_t = ffi.typeof [[
union {
struct { uint32_t lo; uint32_t hi; };
uint64_t x;
}
]]
local function split(x)
local m = uint64_union_t()
m.x = x
return m.hi, m.lo
end
local function join(hi, lo)
local m = uint64_union_t()
m.hi, m.lo = hi, lo
return m.x
end
おわりに
長らく方法がわからずに諦めていたマルチプロセスでの共有メモリの使い方がlinux - Using pthread mutex shared between processes correctly - Stack Overflowで知れて非常にありがたいです。
また今回の例を書いてみてLuaJIT+FFIでの実装スキルが以前よりは上がった気がします。 The Allegory SDKは参考になりまくりで本当に感謝しています。
LuaJIT+FFIはffi.string(ptr [,len])でコピーが発生するという不利な点もあるのですが、まだまだお世話になるので感謝しつつ使っていきたいところです。
また、Building the fastest Lua interpreter.. automatically!のluajit-remake/luajit-remake: An ongoing attempt to re-engineer LuaJIT from scratchも気になるので今後試してみたいところです。