OpenResty における `string` の利点と欠点
API7.ai
December 8, 2022
前回の記事では、OpenRestyにおける一般的なブロッキング関数について学び、それらが初心者によく誤用されることを確認しました。今回の記事からは、パフォーマンス最適化の核心に触れていきます。これには多くの最適化技術が含まれており、OpenRestyコードのパフォーマンスを迅速に向上させるのに役立ちますので、軽視しないでください。
この過程では、これらの最適化技術の使用方法を体験し、その効果を検証するために、より多くのテストコードを書く必要があります。これにより、それらをうまく活用できるようになります。
パフォーマンス最適化の裏側
最適化技術はすべて「実践」の一部ですので、それを行う前に、最適化の「理論」について話しましょう。
パフォーマンス最適化の方法は、LuaJITとOpenRestyの更新に伴って変化します。一部の方法は、基盤技術によって直接最適化され、もはや習得する必要がなくなるかもしれません。同時に、新しい最適化技術も登場します。したがって、これらの最適化技術の背後にある普遍的な概念を習得することが最も重要です。
OpenRestyプログラミングにおけるパフォーマンスに関するいくつかの重要な考え方を見てみましょう。
理論1: リクエストの処理は短く、シンプルで、高速であるべき
OpenRestyはウェブサーバーであるため、1,000以上、10,000以上、さらには100,000以上のクライアントリクエストを同時に処理することがよくあります。したがって、全体のパフォーマンスを最大化するためには、個々のリクエストが迅速に処理され、メモリなどのリソースが回収されることを保証する必要があります。
- ここで言う「短い」とは、リクエストのライフサイクルが短く、リソースを長時間占有しないことを意味します。長い接続の場合でも、時間やリクエスト数の閾値を設定して、定期的にリソースを解放する必要があります。
- 2つ目の「シンプル」とは、1つのAPIで1つのことだけを行うことを指します。複雑なビジネスロジックを複数のAPIに分割し、コードをシンプルに保ちます。
- 最後の「高速」とは、メインスレッドをブロックしないこと、そして多くのCPU操作を実行しないことを意味します。もしそうする必要がある場合でも、前回の記事で紹介した他の方法と組み合わせることを忘れないでください。
このアーキテクチャの考慮事項は、OpenRestyだけでなく、さらなる開発言語やプラットフォームにも適していますので、よく理解し、考えてみてください。
理論2: 中間データの生成を避ける
中間プロセスでの無駄なデータを避けることは、OpenRestyプログラミングにおいて最も支配的な最適化理論と言えます。中間プロセスでの無駄なデータを説明するために、小さな例を見てみましょう。
$ resty -e 'local s= "hello"
s = s .. " world"
s = s .. "!"
print(s)
'
このコードスニペットでは、s
変数に対して複数の結合操作を行い、結果としてhello world!
を得ています。しかし、s
の最終的な状態であるhello world!
だけが有用です。s
の初期値や中間の代入はすべて中間データであり、できるだけ生成しないようにするべきです。
その理由は、これらの一時データが初期化とGCのパフォーマンス損失をもたらすからです。これらの損失を軽視しないでください。もしこれがループなどのホットコードに現れると、パフォーマンスが明らかに低下します。後で文字列の例を使ってこれを説明します。
string
は不変
さて、本記事の主題であるstring
に戻りましょう。ここで強調したいのは、Luaにおいてstring
は不変であるということです。
もちろん、これはstring
が結合や変更などができないという意味ではありませんが、string
を変更する際には、元のstring
を変更するのではなく、新しいstring
オブジェクトを作成し、string
への参照を変更します。したがって、元のstring
が他の参照を持っていない場合、LuaのGC(ガベージコレクション)によって回収されます。
不変なstring
の明らかな利点は、メモリを節約できることです。これにより、同じstring
のコピーがメモリ内に1つだけ存在し、異なる変数が同じメモリアドレスを指すようになります。
この設計の欠点は、string
の追加や回収に関して、string
を追加するたびにLuaJITがlj_str_new
を呼び出してstring
が既に存在するかどうかを確認し、存在しない場合は新しいstring
を作成する必要があることです。これを頻繁に行うと、パフォーマンスに大きな影響を与えます。
この例のようなstring
結合操作の具体的な例を見てみましょう。これは多くのOpenRestyオープンソースプロジェクトで見られます。
$ resty -e 'local begin = ngx.now()
local s = ""
-- `for`ループで`..`を使用して文字列を結合
for i = 1, 100000 do
s = s .. "a"
end
ngx.update_time()
print(ngx.now() - begin)
'
このサンプルコードは、s
変数に対して100,000回のstring
結合を行い、実行時間を出力します。この例は少し極端ですが、最適化前後のパフォーマンスの違いをよく示しています。最適化なしでは、このコードは私のラップトップで0.4秒かかりますが、これはまだ比較的遅いです。では、どのように最適化すべきでしょうか?
前回の記事で答えは出ていますが、table
を使用して一時的な中間string
をすべて取り除き、元のデータと最終結果だけを保持する方法です。具体的なコード実装を見てみましょう。
$ resty -e 'local begin = ngx.now()
local t = {}
-- 配列を使用して文字列を保持する`for`ループで、毎回配列の長さをカウント
for i = 1, 100000 do
t[#t + 1] = "a"
end
-- 配列の`concat`メソッドを使用して文字列を結合
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
このコードでは、各文字列を順番にtable
に保存し、インデックスは#t + 1
、つまりtable
の現在の長さに1
を加えた値で決定します。最後に、table.concat
関数を使用して各配列要素を結合します。これにより、すべての一時的な文字列をスキップし、100,000回のlj_str_new
とGCを回避します。
これが私たちのコード分析ですが、最適化の効果はどうでしょうか?最適化されたコードはわずか0.007秒で、50倍以上のパフォーマンス向上を実現しています。実際のプロジェクトでは、この例では1回の追加で1文字a
を追加しているだけなので、さらに顕著なパフォーマンス向上が見られるかもしれません。
新しいstring
が10倍の長さのa
の場合、パフォーマンスの違いはどうなるでしょうか?
0.007秒のコードは私たちの最適化作業に十分でしょうか?いいえ、まだ最適化できます。もう1行のコードを変更して結果を見てみましょう。
$ resty -e 'local begin = ngx.now()
local t = {}
-- `for`ループで、配列を使用して文字列を保持し、配列の長さ自体を維持
for i = 1, 100000 do
t[i] = "a"
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
今回は、t[#t + 1] = "a"
をt[i] = "a"
に変更し、たった1行のコードで配列の長さを取得する100,000回の関数呼び出しを回避しました。以前のtable
セクションで説明した配列の長さを取得する操作を覚えていますか?これはO(n)
の時間計算量を持つ比較的高価な操作です。したがって、ここでは単に配列のインデックスを維持することで、配列の長さを取得する操作を回避しています。言い換えれば、もしそれが面倒なら、避けることができます。
もちろん、これはよりシンプルな書き方です。以下のコードは、自分で配列のインデックスを維持する方法をより明確に示しています。
$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = "a"
index = index + 1
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
他の一時的なstring
を減らす
先ほど話したstring
結合による一時的なstring
のミスは明らかです。上記のサンプルコードをいくつか見れば、同様のミスを繰り返さないと信じています。しかし、OpenRestyではもっと隠れた一時的なstring
が生成されることがあり、それらははるかに検出しにくいです。例えば、以下で説明するstring
処理関数はよく使用されますが、それが一時的なstring
を生成することに気づいていましたか?
ご存知の通り、string.sub
関数はstring
の指定された部分を切り取ります。前述の通り、Luaのstring
は不変であるため、新しい文字列を切り取るにはlj_str_new
とそれに続くGC操作が発生します。
resty -e 'print(string.sub("abcd", 1, 1))'
上記のコードの機能は、string
の最初の文字を取得して出力することです。当然、一時的なstring
が生成されます。同じ効果を達成するためのより良い方法はあるでしょうか?
resty -e 'print(string.char(string.byte("abcd")))'
もちろんあります。このコードでは、まずstring.byte
を使用して最初の文字の数値コードを取得し、次にstring.char
を使用してその数値を対応する文字に変換します。このプロセスでは一時的なstring
は生成されません。したがって、string
関連のスキャンや分析を行う際には、string.byte
を使用することが最も効率的です。
table
型のSDKサポートを活用
一時的なstring
を減らす方法を学んだ後、試してみたくなりましたか?それでは、上記のサンプルコードの結果をクライアントにレスポンスボディの内容として出力してみましょう。この時点で、一時停止して、まず自分でこのコードを書いてみてください。
$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = "a"
index = index + 1
end
local response = table.concat(t, "")
ngx.say(response)
'
もしこのコードを書けるなら、あなたはすでにほとんどのOpenResty開発者よりも進んでいます。OpenRestyのLua APIは、string
結合にtable
を使用することを考慮しているため、ngx.say
、ngx.print
、ngx.log
、cosocket:send
などの多くのstring
を取る可能性のあるAPIでは、string
だけでなくtable
もパラメータとして受け入れます。
resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = "a"
index = index + 1
end
ngx.say(t)
'
この最後のコードスニペットでは、local response = table.concat(t, "")
というstring
結合のステップを省略し、table
を直接ngx.say
に渡しています。これにより、string
結合のタスクをLuaレベルからCレベルにシフトし、別のstring
の検索、生成、GCを回避します。長いstring
の場合、これはさらなる大きなパフォーマンス向上をもたらします。
まとめ
この記事を読んだ後、OpenRestyのパフォーマンス最適化が多くの細かい部分に触れていることがわかります。したがって、最適なパフォーマンスを達成するためには、LuaJITとOpenRestyのLua APIをよく知る必要があります。これはまた、以前の内容を忘れてしまった場合、すぐに復習して定着させる必要があることを思い出させます。
最後に、1つの問題を考えてみてください:文字列hello
、world
、!
をエラーログに書き込む場合、string
結合なしでサンプルコードを書くことはできるでしょうか?
また、本文中の他の質問も忘れないでください。新しいstring
が10倍の長さのa
の場合、以下のコードのパフォーマンスの違いはどうなるでしょうか?
$ resty -e 'local begin = ngx.now()
local t = {}
for i = 1, 100000 do
t[#t + 1] = "a"
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
この記事を友人と共有して学び、交流することも歓迎します。