OpenResty における `string` の利点と欠点

API7.ai

December 8, 2022

OpenResty (NGINX + Lua)

前回の記事では、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.sayngx.printngx.logcosocket: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つの問題を考えてみてください:文字列helloworld!をエラーログに書き込む場合、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)
'

この記事を友人と共有して学び、交流することも歓迎します。