トップティップス:Luaにおけるユニークなコンセプトと落とし穴の見分け方
API7.ai
October 12, 2022
前回の記事では、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つのローカル変数foo
とbar
をキャプチャしていることがわかります。これらの変数は、実際には関数fn
のupvalue
です。
よくある落とし穴
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ライブラリを使用してfind
やmatch
などを行う場合、正規表現のようなものが必要な場合は、ためらわずに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つの閾値は、それぞれ200
と60
にハードコードされています。ソースコードを手動で修正してこれらの値を調整することはできますが、最大でも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
という閾値はどこから来たのでしょうか?これが今日の思考問題です。コメントを残して、この記事を同僚や友人と共有してください。一緒にコミュニケーションを取って改善していきましょう。