トップティップス:Luaにおけるユニークなコンセプトと落とし穴の見分け方

API7.ai

October 12, 2022

OpenResty (NGINX + Lua)

前回の記事では、LuaJITのテーブル関連ライブラリ関数について学びました。これらの一般的な関数に加えて、今日はOpenRestyにおけるLuaの独特な概念やよくある落とし穴を紹介します。

弱いテーブル(Weak Table)

まず、Luaの独特な概念である_弱いテーブル_についてです。これはガベージコレクションに関連しています。他の高級言語と同様に、Luaは自動的にガベージコレクションを行います。実装を気にする必要はなく、明示的にGCを呼び出す必要もありません。ガベージコレクタは、参照されていないスペースを自動的に回収します。

しかし、単純な参照カウントだけでは不十分な場合があり、より柔軟なメカニズムが必要です。例えば、LuaオブジェクトFoo(_テーブル_や関数)をテーブルtbに挿入すると、そのオブジェクトFooへの参照が作成されます。Fooへの他の参照がなくなっても、tb内の参照は常に存在するため、GCはFooが占有するメモリを回収できません。この場合、私たちには2つの選択肢しかありません。

  • 1つは、Fooを手動で解放することです。
  • 2つ目は、メモリ内に常駐させることです。

例えば、以下のコードです。

$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
print(#tb) -- 2

collectgarbage()
print(#tb) -- 2

table.remove(tb, 1)
print(#tb) -- 1

しかし、使用していないオブジェクトがメモリを占有し続けるのは望ましくないでしょう。特にLuaJITには2Gのメモリ制限があります。手動での解放タイミングは容易ではなく、コードの複雑さを増します。

そこで_弱いテーブル_の出番です。その名前の通り、_弱いテーブル_はまずテーブルであり、その中のすべての要素が弱い参照です。概念は常に抽象的ですので、少し修正したコードを見てみましょう。

$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
setmetatable(tb, {__mode = "v"})
print(#tb)  -- 2

collectgarbage()
print(#tb) -- 0
'

使用されていないオブジェクトが解放されていることがわかります。最も重要なのは以下のコードです。

setmetatable(tb, {__mode = "v"})

見覚えがありますか?これはメタテーブルの操作です。そうです、メタテーブルに__modeフィールドがある場合、そのテーブルは_弱いテーブル_です。

  • __modeの値がkの場合、テーブルのキーは弱い参照です。
  • __modeの値がvの場合、テーブルの値は弱い参照です。
  • もちろん、kvに設定することもでき、このテーブルのキーと値の両方が弱い参照であることを示します。

これらの3つの_弱いテーブル_のいずれも、キーまたは値が回収されると、そのキーと値のオブジェクト全体が回収されます。

上記のコード例では、__modeの値はvで、tbは配列であり、配列の値はテーブルと関数オブジェクトなので、自動的に回収されます。しかし、__modeの値をkに変更すると、解放されません。例えば、以下のコードを見てください。

$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
setmetatable(tb, {__mode = "k"})
print(#tb)  -- 2

collectgarbage()
print(#tb) -- 2
'

ここでは、値が弱い参照である_弱いテーブル_、つまり配列型の_弱いテーブル_のみをデモンストレーションしました。当然、オブジェクトをキーとして使用してハッシュテーブル型の_弱いテーブル_を構築することもできます。例えば、以下の通りです。

$ resty -e 'local tb = {}
tb[{color = red}] = "red"
local fc = function() print("func") end
tb[fc] = "func"
fc = nil

setmetatable(tb, {__mode = "k"})
for k,v in pairs(tb) do
     print(v)
end

collectgarbage()
print("----------")
for k,v in pairs(tb) do
     print(v)
end
'

手動でcollectgarbage()を呼び出して強制的にGCを実行すると、tbテーブルのすべての要素が解放されます。もちろん、実際のコードではcollectgarbage()を手動で呼び出す必要はありません。バックグラウンドで自動的に実行され、私たちは気にする必要はありません。

しかし、collectgarbage()関数について言及したので、もう少し説明します。この関数にはいくつかの異なるオプションを渡すことができ、デフォルトはcollectで、これは完全なGCです。もう1つ有用なのはcountで、Luaが占有するメモリ空間の量を返します。この統計はメモリリークがあるかどうかを確認するのに役立ち、2Gの上限に近づかないように注意を促します。

_弱いテーブル_に関連するコードは実際に書くのが複雑で、理解しにくく、それに応じて隠れたバグも多くなります。急ぐ必要はありません。後で、_弱いテーブル_によるメモリリーク問題を紹介するオープンソースプロジェクトを紹介します。

クロージャとupvalue

次に、クロージャとupvalueについてです。以前にも強調したように、Luaではすべての値が第一級市民であり、関数も含まれます。これは、関数を変数に格納したり、引数として渡したり、別の関数の値として返したりできることを意味します。例えば、上記の_弱いテーブル_のサンプルコードに登場した以下のコードです。

tb[2] = function() print("func") end

これは匿名関数で、テーブルの値として格納されています。

Luaでは、以下のコードの2つの関数の定義は同等です。ただし、後者は関数を変数に代入する方法で、私たちがよく使う方法です。

local function foo() print("foo") end
local foo = fuction() print("foo") end

さらに、Luaは関数内に関数を書くことをサポートしています。つまり、ネストされた関数です。例えば、以下のサンプルコードです。

$ resty -e '
local function foo()
     local i = 1
     local function bar()
         i = i + 1
         print(i)
     end
     return bar
end

local fn = foo()
print(fn()) -- 2
'

bar関数がfoo関数内のローカル変数iを読み取り、その値を変更できることがわかります。これは、bar内で定義されていない変数でも可能です。この機能はレキシカルスコープと呼ばれます。

Luaのこれらの機能はクロージャの基礎です。クロージャは、単に別の関数のレキシカルスコープ内の変数にアクセスする関数です。

定義上、Luaのすべての関数は実際にはクロージャです。ネストしていなくてもです。これは、LuaコンパイラがLuaスクリプトの外側を取り、別の層のメイン関数でラップするためです。例えば、以下の簡単なコードです。

local foo, bar
local function fn()
     foo = 1
     bar = 2
end

コンパイル後、以下のようになります。

function main(...)
     local foo, bar
     local function fn()
         foo = 1
         bar = 2
     end
end

そして、関数fnはメイン関数の2つのローカル変数をキャプチャするため、これもクロージャです。

もちろん、クロージャの概念は多くの言語に存在し、Luaに固有のものではありません。比較対照することで、より理解を深めることができます。クロージャを理解して初めて、upvalueについて話すことができます。

upvalueはLuaに固有の概念で、クロージャ内でキャプチャされたレキシカルスコープ外の変数です。上記のコードを続けて見てみましょう。

local foo, bar
local function fn()
     foo = 1
     bar = 2
end

関数fnが、自身のレキシカルスコープ内にない2つのローカル変数foobarをキャプチャしていることがわかります。これらの変数は、実際には関数fnupvalueです。

よくある落とし穴

Luaのいくつかの概念を紹介した後、OpenResty開発で遭遇したLua関連の落とし穴について話します。

前回のセクションでは、Luaと他の開発言語の違いについて触れました。例えば、インデックスが1から始まること、デフォルトでグローバル変数であることなどです。OpenRestyの実際のコード開発では、LuaとLuaJITに関連する問題に遭遇することが多く、以下にいくつかの一般的なものを紹介します。

ここで注意点として、すべての落とし穴を知っていても、自分で実際に踏んでみないと印象に残らないでしょう。違いは、穴から這い出し、問題の核心を見つける方法がより良くなることです。

インデックスは0からか1からか?

最初の落とし穴は、Luaのインデックスが1から始まることです。これは以前にも繰り返し述べました。

しかし、これは完全な真実ではありません。なぜなら、LuaJITではffi.newで作成された配列は再び0からインデックスされるからです:

local buf = ffi_new("char[?]", 128)

したがって、上記のコードのbuf cdataにアクセスする場合、インデックスが0から始まることを覚えておいてください。FFIを使用してCとやり取りする際に、この点に特に注意してください。

正規表現マッチ

2つ目の落とし穴は、正規表現マッチの問題です。OpenRestyには、LuaのstringライブラリとOpenRestyのngx.re.* APIという2つの文字列マッチング方法が並行して存在します。

Luaの正規表現マッチは独自の形式であり、PCREとは異なる書き方です。以下は簡単な例です。

resty -e 'print(string.match("foo 123 bar", "%d%d%d"))'123

このコードは文字列から数値部分を抽出しますが、私たちが慣れ親しんだ正規表現とは全く異なることがわかります。Luaの正規表現マッチングライブラリは保守コストが高く、パフォーマンスも低いです。JITはこれを最適化できず、一度コンパイルされたパターンはキャッシュされません。

したがって、Luaの組み込みstringライブラリを使用してfindmatchなどを行う場合、正規表現のようなものが必要な場合は、ためらわずにOpenRestyのngx.reを使用してください。固定文字列を探す場合、stringライブラリをプレーンモードで呼び出すことを検討します。

以下は提案です:OpenRestyでは、常にOpenRestyのAPIを優先し、次にLuaJITのAPIを使用し、Luaライブラリは慎重に使用します。

JSONエンコードは配列とdictを区別しない

3つ目の落とし穴は、JSONエンコードが配列とdictを区別しないことです。Luaにはデータ構造が1つしかないため、_テーブル_が空の場合、JSONエンコード時にそれが配列か辞書かを判断する方法がありません。

resty -e 'local cjson = require "cjson"
local t = {}
print(cjson.encode(t))
'

例えば、上記のコードは{}を出力します。これは、OpenRestyのcjsonライブラリがデフォルトで空のテーブルを辞書としてエンコードすることを示しています。もちろん、encode_empty_table_as_object関数を使用してこのグローバルデフォルトを変更できます。

resty -e 'local cjson = require "cjson"
cjson.encode_empty_table_as_object(false)
local t = {}
print(cjson.encode(t))
'

今回は、空のテーブルが配列[]としてエンコードされます。

しかし、このグローバル設定は大きな影響を与えるため、特定のテーブルのエンコードルールを指定することはできるでしょうか?答えはもちろんイエスで、2つの方法があります。

1つ目の方法は、指定されたテーブルにuserdata cjson.empty_arrayを割り当てることです。これにより、JSONエンコード時に空の配列として扱われます。

$ resty -e 'local cjson = require "cjson"
local t = cjson.empty_array
print(cjson.encode(t))
'

しかし、指定されたテーブルが常に空であるかどうかわからない場合もあります。空の場合に配列としてエンコードしたい場合は、cjson.empty_array_mt関数を使用します。これが2つ目の方法です。

これは指定されたテーブルをマークし、テーブルが空の場合に配列としてエンコードします。cjson.empty_array_mtという名前からわかるように、これはmetatableを使用して設定されます。以下のコード操作のようにです。

$ resty -e 'local cjson = require "cjson"
local t = {}
setmetatable(t, cjson.empty_array_mt)
print(cjson.encode(t))
t = {123}
print(cjson.encode(t))
'

変数の数の制限

4つ目の落とし穴は、変数の数の制限です。Luaには、関数内のローカル変数の数とupvalueの数に上限があります。Luaのソースコードから確認できます。

/*
@@ LUAI_MAXVARSは関数ごとのローカル変数の最大数です
@*(250未満でなければなりません)。
*/
#define LUAI_MAXVARS            200


/*
@@ LUAI_MAXUPVALUESは関数ごとのupvalueの最大数です
@*(250未満でなければなりません)。
*/
#define LUAI_MAXUPVALUES        60

これらの2つの閾値は、それぞれ20060にハードコードされています。ソースコードを手動で修正してこれらの値を調整することはできますが、最大でも250にしか設定できません。

一般的に、この閾値を超えることはありません。しかし、OpenRestyのコードを書く際には、ローカル変数とupvalueを過剰に使用しないように注意し、do ... endをできるだけ使用してローカル変数とupvalueの数を減らすようにしてください。

例えば、以下の疑似コードを見てみましょう。

local re_find = ngx.re.find
function foo() ... end
function bar() ... end
function fn() ... end

もし関数fooだけがre_findを使用する場合、以下のように修正できます:

do
    local re_find = ngx.re.find
    function foo() ... end
end
function bar() ... end
function fn() ... end

まとめ

「もっと質問する」という観点から、Luaの250という閾値はどこから来たのでしょうか?これが今日の思考問題です。コメントを残して、この記事を同僚や友人と共有してください。一緒にコミュニケーションを取って改善していきましょう。