What Is the Difference Between LuaJIT And Standard Lua?

API7.ai

September 23, 2022

OpenResty (NGINX + Lua)

OpenRestyのもう一つの基盤であるLuaJITについて学びましょう。今日の投稿の中心部分は、LuaとLuaJITの基本的でありながらあまり知られていない側面に焦点を当てます。

Luaの基礎については、検索エンジンやLuaの書籍を通じて学ぶことができます。特に、Luaの作者による書籍『Programming in Lua』をお勧めします。

もちろん、OpenRestyで正しいLuaJITコードを書くためのハードルは高くありませんが、効率的なLuaJITコードを書くのは簡単ではありません。これについては、後ほどOpenRestyのパフォーマンス最適化のセクションで詳しく説明します。

まず、LuaJITがOpenRestyの全体アーキテクチャの中でどのように位置づけられているかを見てみましょう。

OpenResty Architecture

前述の通り、OpenRestyのWorkerプロセスはMasterプロセスをフォークして得られます。Masterプロセス内のLuaJIT仮想マシンもフォークされます。同じWorker内のすべてのWorkerプロセスはこのLuaJIT仮想マシンを共有し、Luaコードの実行はこの仮想マシン内で行われます。

これがOpenRestyの基本的な動作原理です。これについては、今後の記事でさらに詳しく説明します。今日は、LuaとLuaJITの関係を整理することから始めましょう。

標準LuaとLuaJITの関係

まず重要な点を確認しましょう。

標準LuaとLuaJITは異なるものです。LuaJITはLua 5.1の構文のみ互換性があります。

標準Luaの最新バージョンは現在5.4.4で、LuaJITの最新バージョンは2.1.0-beta3です。数年前の古いバージョンのOpenRestyでは、コンパイル時に標準Lua VMまたはLuaJIT VMのどちらかを実行環境として選択できましたが、現在は標準Luaのサポートが削除され、LuaJITのみがサポートされています。

LuaJITの構文はLua 5.1と互換性があり、オプションでLua 5.2と5.3の構文もサポートしています。そのため、まずLua 5.1の構文を学び、その上でLuaJITの機能を学ぶべきです。前回の記事では、Luaの基本的な構文を紹介しました。今日は、Luaのいくつかのユニークな機能に触れます。

なお、OpenRestyは公式のLuaJITバージョン2.1.0-beta3を直接使用せず、独自のフォークであるopenresty-luajit2を使用しています。

これらのユニークなAPIは、OpenRestyの実際の開発中にパフォーマンス上の理由で追加されました。したがって、後述するLuaJITは、OpenResty自身がメンテナンスするLuaJITブランチを指します。

なぜLuaJITなのか?

LuaJITとLuaの関係についてこれまで話してきましたが、なぜ直接Luaを使わずにLuaJITを使うのか疑問に思うかもしれません。実際、主な理由はLuaJITのパフォーマンス上の優位性です。

Luaコードは直接解釈されるのではなく、LuaコンパイラによってByte Codeにコンパイルされ、その後Lua仮想マシンによって実行されます。

LuaJITの実行環境には、Luaインタプリタのアセンブリ実装に加えて、JITコンパイラが含まれており、直接マシンコードを生成できます。最初、LuaJITは標準Luaと同じように動作し、Luaコードがバイトコードにコンパイルされ、LuaJITのインタプリタによって解釈・実行されます。

違いは、LuaJITインタプリタがバイトコードを実行しながら、各Lua関数の呼び出しエントリが実際に何回実行されたか、各Luaループが実際に何回実行されたかなどのランタイム統計を記録することです。これらのカウントがランダムな閾値を超えると、対応するLua関数エントリまたはLuaループが十分に「ホット」であると見なされ、JITコンパイラが動作を開始します。

JITコンパイラは、ホットな関数のエントリまたはホットなループの位置から、対応するLuaコードパスをコンパイルしようとします。コンパイルプロセスでは、LuaJITバイトコードがLuaJIT独自のIR(Intermediate Representation)に変換され、その後ターゲットアーキテクチャのマシンコードが生成されます。

したがって、LuaJITのパフォーマンス最適化とは、本質的には、できるだけ多くのLuaコードをJITコンパイラがマシンコード生成できるようにし、Luaインタプリタの解釈実行モードにフォールバックしないようにすることです。これを理解すれば、後ほど学ぶOpenRestyのパフォーマンス最適化の本質も理解できるでしょう。

Luaの特殊な機能

前回の記事で説明したように、Lua言語は比較的シンプルです。他の開発言語のバックグラウンドを持つエンジニアにとって、Luaのコードのロジックは、いくつかのユニークな側面に気づけば理解しやすいでしょう。次に、Lua言語のいくつかの特異な側面を見ていきましょう。

1. インデックスは1から始まる

Luaは、私が知る限り、インデックスが1から始まる唯一のプログラミング言語です。これは非プログラマーにとっては理解しやすいかもしれませんが、プログラムのバグを引き起こしやすいです。以下に例を示します。

$ resty -e 't={100}; ngx.say(t[0])'

このプログラムが100を出力するか、インデックス0が存在しないというエラーを報告することを期待するかもしれません。しかし、驚くべきことに、何も出力されず、エラーも報告されません。そこで、typeコマンドを追加して、出力が何であるかを見てみましょう。

$ resty -e 't={100};ngx.say(type(t[0]))'
nil

結果はnil値です。実際、OpenRestyでは、nil値の判定と処理も混乱しやすいポイントですので、後ほどOpenRestyについて話す際に詳しく説明します。

2. 文字列の連結に..を使用

多くの言語が+を使用するのに対し、Luaは2つのドットマークを使用して文字列を連結します。

$ resty -e "ngx.say('hello' .. ', world')"
hello, world

実際のプロジェクト開発では、通常複数の開発言語を使用するため、Luaのこの特異な設計は、文字列の連結が少し混乱を招くことがあります。

3. テーブルが唯一のデータ構造

Pythonのように豊富な組み込みデータ構造を持つ言語とは異なり、Luaには唯一のデータ構造であるtableしかありません。これは配列やハッシュテーブルを含むことができます。

local color = {first = "red", "blue", third = "green", "yellow"}
print(color["first"])                 --> 出力: red
print(color[1])                         --> 出力: blue
print(color["third"])                --> 出力: green
print(color[2])                         --> 出力: yellow
print(color[3])                         --> 出力: nil

明示的にキーと値のペアとして値を割り当てない場合、テーブルはデフォルトで数値をインデックスとして使用し、1から始まります。したがって、color[1]blueです。

また、テーブルの正しい長さを取得するのは難しいので、以下の例を見てみましょう。

local t1 = { 1, 2, 3 }
print("Test1 " .. table.getn(t1))

local t2 = { 1, a = 2, 3 }
print("Test2 " .. table.getn(t2))

local t3 = { 1, nil }
print("Test3 " .. table.getn(t3))

local t4 = { 1, nil, 2 }
print("Test4 " .. table.getn(t4))

結果:

Test1 3
Test2 2
Test3 1
Test4

ご覧の通り、最初のテストケースでは長さ3が返されますが、それ以降のテストはすべて予想外の結果です。実際、Luaでテーブルの長さを取得する際には、テーブルがシーケンスである場合にのみ正しい値が返されることに注意が必要です。

では、シーケンスとは何でしょうか?まず、シーケンスは配列のサブセットです。つまり、テーブルの要素が正の整数インデックスでアクセス可能であり、キーと値のペアが含まれていない場合です。上記のコードでは、t2を除くすべてのテーブルが配列です。

次に、シーケンスには穴(nil)が含まれていません。これら2点を組み合わせると、上記のテーブルt1はシーケンスですが、t3t4は配列ではあるもののシーケンスではありません。

ここまで読んで、なぜt4の長さが1になるのか疑問に思うかもしれません。これは、nilに遭遇した場合、長さを取得するロジックが続行されず、直接返されるためです。

完全に理解できたでしょうか?この部分は本当に複雑です。では、私たちが望むテーブルの長さを取得する方法はあるのでしょうか?もちろんあります。OpenRestyはこれを拡張しており、テーブル専用の章で後ほど説明しますので、ここではサスペンスを残しておきましょう。

4. すべての変数はデフォルトでグローバル

特に確信がない限り、新しい変数は常にlocal変数として宣言することを強くお勧めします。

local s = 'hello'

これは、Luaでは変数がデフォルトでグローバルであり、_Gという名前のテーブルに配置されるためです。ローカルでない変数はグローバルテーブルで検索され、これはコストの高い操作です。変数名のスペルミスは、識別や修正が難しいバグを引き起こす可能性があります。

したがって、OpenRestyでは、モジュールをrequireする場合でも、常にlocalを使用して変数を宣言することを強く推奨します。

-- 推奨
local xxx = require('xxx')

-- 避けるべき
require('xxx')

LuaJIT

Luaのこれら4つの特殊な機能を念頭に置いて、LuaJITに進みましょう。

LuaJITは、Lua 5.1に準拠し、JITをサポートするだけでなく、FFI(Foreign Function Interface)と緊密に統合されており、Luaコード内で外部のC関数を呼び出したり、Cデータ構造を直接使用したりできます。以下は最もシンプルな例です。

local ffi = require("ffi")
ffi.cdef[[
int printf(const char *fmt, ...);
]]
ffi.C.printf("Hello %s!", "world")

たった数行のコードで、Luaから直接Cのprintf関数を呼び出し、Hello world!を出力できます。restyコマンドを使用して実行し、動作を確認できます。

同様に、FFIを使用してNGINXやOpenSSLのC関数を呼び出し、さらに多くのことを行うことができます。FFIアプローチは、従来のLua/C APIアプローチよりもパフォーマンスが優れているため、lua-resty-coreプロジェクトが存在します。次のセクションでは、FFIとlua-resty-coreについて説明します。

さらに、パフォーマンス上の理由から、LuaJITはテーブル関数を拡張しています:table.newtable.clearは、OpenRestyのlua-restyライブラリで頻繁に使用される重要なパフォーマンス最適化関数です。しかし、ドキュメントが難解でサンプルコードがないため、これらを熟知している開発者はほとんどいません。これらはパフォーマンス最適化のセクションで取り上げます。

まとめ

今日の内容を振り返りましょう。

OpenRestyはパフォーマンス上の理由から標準LuaではなくLuaJITを選択し、独自のLuaJITブランチをメンテナンスしています。LuaJITはLua 5.1の構文に基づいており、一部のLua 5.2およびLua 5.3の構文を選択的に互換性を持たせてシステムを形成しています。習得すべきLuaの構文については、インデックス、文字列連結、データ構造、変数などに独特の特徴があり、コードを書く際に特に注意が必要です。

LuaとLuaJITを学ぶ際に遭遇した落とし穴はありますか?ぜひ意見を共有してください。私も遭遇した落とし穴を共有する記事を書きました。この投稿を同僚や友人と共有して、共に学び、進歩しましょう。