LuaJIT+FFIで共有メモリを試してみた

はじめに

openresty/lua-nginx-modulengx.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 にあります。

マルチプロセスで共有メモリを使う際の要約

排他制御に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#L1007ngx_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_mutexpthread_rwlockのFFI周りのコードはここから頂きました。感謝!

LuaJITのffi.metatypeの使い方も学べた

またThe Allegory SDKのコードを見て、ffi.metatypeの使い方も学びました。 pshared_rwlock.luapthread_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.Iserrors.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のunreachableZigの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 SDKfs.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 Objectsffi.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も気になるので今後試してみたいところです。