ゲーマー向けチャットアプリケーション「Discord」では、基盤サービスの一つである「Read States」が十分に高速化できない問題が明らかになった。開発チームは既存のコードをさらに改善することで対応しようとした。だが、Rust言語で再実装したところ、最適化を施す以前からパフォーマンスが向上した。なぜだろうか。開発チームがその理由を語る。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
ゲーマー向けの無料音声テキストチャットアプリケーション「Discord」を開発、提供するDiscordは2020年2月5日(米国時間)、アプリケーションを支える基盤サービスの一つである「Read States」をRust言語で再実装し、その結果サービスのパフォーマンスが大幅に向上したと公式ブログで明らかにした。
Read StatesサービスはこれまでGo言語で実装されていた。それにもかかわらず、なぜRead StatesをRustで再実装しようとしたのか、どのように再実装したのか、再実装によってどのようにパフォーマンスが向上したかを解説した。
Read Statesサービスの目的は、Discordユーザーがどのチャンネルのどのメッセージを読んだのかを追跡することだ。つまり、ユーザーがDiscordに接続したり、メッセージを送信したり、メッセージを読んだりするたびに、Read Statesにアクセスする。
このため、Read Statesはアクセス頻度が非常に高い。同社は、Discordのエクスペリエンスが極めて軽快であることを目指している。
だが、Goで実装していたRead Statesサービスは、高速性が必要だという要件には十分対応できていなかった。平均すれば高速に動作していたものの、数分ごとに平均応答時間が急に大きくなり、ユーザーエクスペリエンスを損なっていた。調査したところ、これはGoの中核機能であるメモリモデルとガベージコレクタ(GC)に起因することが分かった。
Goで記述したサービスがパフォーマンス目標を達成できなかった理由を理解するには、まず、サービスのデータ構造やスケール、アクセスパターン、アーキテクチャについて確認する必要がある。
Discordで読み取り状態情報を保存するのに使うデータ構造を、以下では便宜的に「Read State」と呼ぶ。Discordには数十億のRead Stateがある。ユーザーとチャンネルの組み合わせごとに1つのRead Stateが必要だからだ。各Read Stateは幾つかのカウンターを持つ。例えば、カウンターの一つは、ユーザーがチャンネル内に持つメンション数を表す。
これらのカウンターをバラバラに更新すると整合性が取れなくなる。アトミックに更新しなければならない。さらに頻繁に0にリセットしなければならない。
アトミックカウンターを高速に更新するために、各Read Statesサーバには、Read StateのLRU(Least Recently Used)キャッシュがある。各キャッシュには数百万人のユーザー、数千万のRead Stateが含まれる。そして、キャッシュの更新頻度は毎秒数十万回に及ぶ。
データの永続性を確保するために、Discordは分散データベース管理システム「Apache Cassandra」のクラスタでキャッシュを補完している。キャッシュキーのエビクション(不要なデータを追い出すこと)の際に、Read Stateをデータベースにコミットする。加えて、Read Stateの更新時に必ず、30秒間のデータベースコミットを入れている。こうしてデータベースへ毎秒数万回の書き込みが起こる。
Goで実装したRead Statesサービスのパフォーマンスを次のグラフに示した。最大メンション数(右下)が不規則に変動しているにもかかわらず、CPU使用率(左上)や平均応答時間(右上)にははっきりした挙動が現れており、約2分ごとに、CPU使用率と平均応答時間がスパイク状に急増していることが分かる。
キャッシュキーのエビクションを実行しても、Go版のRead Statesサービスでは、すぐにはメモリ領域を解放しない。その代わりに、ガベージコレクタを頻繁に実行して、もはや参照がなくなったメモリ領域を探し出して解放する。つまり、メモリ領域が使用されなくなった直後に解放するのではなく、本当に使用されていないかどうかをガベージコレクタが判断できるまで、少しの間待つことになる。さらにガベージコレクションの実行中、空きメモリ領域を判断するためにさまざまな処理を進める必要があるため、プログラムの速度が低下する可能性がある。
このような理由から、先ほどのグラフに現れていた平均応答時間の急上昇は明らかにガベージコレクションのパフォーマンスのためだろうと考えた。
だが、DiscordのGoコードは非常に効率的に記述されていた。メモリの割り当て回数も十分に少なくなっている。つまりガベージコレクションに問題があるというのはおかしいとDiscordの開発者は判断した。
そこでGo自体のソースコードを調査したところ、少なくとも2分ごとにガベージコレクションを強制的に実行することが分かった。それなら、ガベージコレクタの挙動を変えたらどうなるだろうか。オンザフライで「GC Percent」の値を変えてみたところ、どのような値を設定しても挙動は変わらなかった。理由はDiscordの側にあった。ガベージコレクションが頻繁に発生するようにメモリを迅速に割り当てることができなかったからだ。
さらに調査を続けた結果、2秒ごとに巨大なスパイクが生じる、別の理由があることが分かった。メモリ領域が本当に参照されなくなったのかどうかをガベージコレクタが判断する際、LRUキャッシュ全体をスキャンする必要があったのだ。
つまり動作を高速にするには、LRUキャッシュを小さくすればよい。LRUキャッシュのサイズを変更するための別の設定をサービスに追加し、サーバごとに多数のパーティション化されたLRUキャッシュを持つようにアーキテクチャを変更した。すると、ガベージコレクションによるスパイクが実際に小さくなった。
だが、この解決策には副作用がある。LCUキャッシュのサイズを小さくすると、ユーザーの読み取り状態がキャッシュ内に含まれる確率が下がり、データベースを参照するために遅延が生じてしまった。
Rustを導入する以前は、LCUキャッシュサイズと遅延のバランスを取って、何とか、2秒ごとのスパイクが小さくなるように運用していた。
Rustではガベージコレクションが不要だ。同社がRead StatesサービスをRustで実装しようと考えたのはこれが理由だ。Rustを使えば、Goで実装した場合に生じた平均応答時間の急増は見られないだろう。
Rustは、「メモリオーナーシップ」という考え方を取り入れた比較的ユニークなメモリ管理アプローチを採用している。メモリに所有権があるため、あるメモリ領域に対する読み書き権限を追跡でき、そのメモリ領域が不要になると、直ちに解放する。Rustはコンパイル時にメモリルールを強制するので、メモリバグはほぼ発生しない。Cのように手動でメモリ領域を追跡する必要はなく、コンパイラが全てを取り仕切る。
これらのことから、Read StatesサービスのRust版では、LRUキャッシュからのRead Stateのエビクションが行われると、Read Stateが使っていたメモリ領域は直ちに解放される。Rustは、Read Stateのメモリが使われなくなったことを認識し、直ちに解放するものの、メモリを解放すべきかどうかを判断するランタイムプロセスも存在しない。
Read Statesサービスを再実装した当時、Rustの安定版では非同期Rustのサポートが不十分だった。だが、ネットワークサービスでは、非同期プログラミングが必須だ。非同期Rustを有効にしたコミュニティー版のライブラリが幾つか存在したものの、利用に際して複雑な手順が必要であり、不具合が生じた場合のエラーメッセージは非常に分かりにくかった。
幸いなことに、Rustチームは、非同期プログラミングが容易になるよう精力的に取り組んでおり、Rustのナイトリーチャネルで、非同期プログラミング機能が強化された不安定版が入手できるようになった。
Discordはナイトリーリリースを導入し、問題が発生した際にはRustチームと協力して対処した。その結果、現時点では、Rustの安定版は非同期Rustをサポートしている。
Copyright © ITmedia, Inc. All Rights Reserved.