gRPC を使って1万接続を達成するまでの話

めるぽん
wandbox.org
Published in
5 min readMar 22, 2019

先日、Wata で1万接続を達成した という記事を書きましたが、gRPC で1万接続するにあたって引っかかったところを纏めました。

Wata サーバは簡単に言えば PubSub サーバです。今回達成したのは Publisher が 1 で Subscriber が 10,000 の配信です。Wata の詳細については Wata プロジェクトについて を参照してください。

gRPC C++ の非同期 API をストリーミングで扱う方法が分からない

本番での Wata クライアントは Unity のアプリケーションになるわけですが、テスト時に1万の Unity アプリケーションを立ち上げるのはかなり辛いものがあります。

なので gRPC で通信するテスト用の Wata クライアントを書くわけですが、少ない台数でやるためには、1台で大量の接続を捌く必要があります。

1スレッド1クライアントだと死ぬため非同期 API にする必要があり、また、Wata の gRPC はサーバストリーミングとクライアントストリーミングを両方使っているので、テスト用の Wata クライアントは非同期 API でストリーミングを行う必要があります。

しかし非同期 API かつストリーミングをどうやってやるのかが分からなくて大変でした。

非同期 API を使った公式のサンプルは grpc/examples/cpp/helloworld がありますが、これはストリーミングではありません。

ストリーミングを使った公式のサンプルは grpc/examples/cpp/route_guide がありますが、こうれは非同期 API ではありません。

ただリポジトリの中に grpc/test/cpp/qps というベンチマークがあり、これは非同期 API のストリーミングを使ってるのでこれが参考になります。

あとは適当にググったりスレッドと戦ったりしつつ、何とか2台(結果的には1台でも良かったかも)で1万接続を行うテスト用クライアントが作れました。

マルチスレッド化しても高速にならないことがある

C++ Performance Notes によれば、CPU の数だけスレッドを作り、1スレッドにつき 1 CompletionQueue を作るのが一番良いパフォーマンスが出ると書いています。

しかし、この通りにやってもパフォーマンスが出ないことがあります。

というのも、1リクエスト中は常に 1 つの CompletionQueue に紐付けられるため、今回みたいに1クライアントから送信されたデータを1万クライアントに配信するみたいな処理だと、1スレッドだけが重くなってしまうからです。

// すべてのクライアントへメッセージを送信する
for (grpc::ServerAsyncWriter<StreamingResponse>* writer : writers_) {
writer->Write(response, tag_);
}

grpc の Write 関数は、基本的に1回の呼び出しで1回のシステムコールを発行します。そのためこれは1万回のシステムコールが発生します。

この書き方だと、送信の処理が常に特定のスレッドでしか実行されないため、CPU を使い切ることが出来ません。4コアを積んでても200%行くか行かないかぐらいになってしまうということになってしまいます。

なので、送信用のワーカースレッドを複数作り、そこを経由して送信するようにしました。これで無事 CPU を使い切れるようになりました。

上記の C++ Performance Guide には「複数スレッドからのシステムコールは纏める」みたいなことを書いてるから、システムコールの発行数も減ってるかもしれません(未確認)。

gRPC のメッセージ数が多いと遅い

きちんと試した訳ではないですが、gRPC のメッセージは、送信の回数に関わらず、純粋にメッセージ数が多いと CPU を食うことになるようです。

最初のテストでは、限界を見るためというのもあり、秒間60フレームで、毎フレームデータと音声の2種類を送信していたので、秒間120回メッセージを送信していました。こうするととても重くなり、クライアントもサーバも一瞬で負荷に耐えられなくなりました。

そこで上記の C++ Performance Guide にある、バッファーヒントを利用してデータの送信回数を減らす、というのを試してみたのですが、これで送信する回数は減っているにも関わらず、CPU 負荷は高いままで、ほぼ変わりませんでした。

なのでメッセージのシリアライズ、デシリアライズは結構負荷が掛かると考えていいでしょう。秒間120回もやってはダメです。

ということで proto ファイルを設計し直しました。データか音声のどちらか1つだけ送信する、という形式だったのを、データの配列と音声の配列の両方を送れるようにしました。

これで 100 ミリ秒に 1 回、全てのデータを纏めて送信するようにしたところ、めちゃめちゃ軽くなりました。当社比 1/5 ぐらいです(以前は Wata サーバが 700% ぐらい CPU 使ってたのが 130% ぐらいになったので)。

実際に処理するデータ量が変わった訳でもないのにここまで改善されるのは驚きでした。メッセージの設計は重要だと言えます。

感想

Wata サーバは元々シングルスレッドで書いていて、うまくいけばこのままでも動くだろうし、最悪 CompletionQueue をマルチスレッド化してバッファーヒントを利用すれば楽に達成できるだろうと思っていたのですが、最悪の想定を軽々と超えてくるのが大変でした。

gRPC で性能を出す時にはこの辺に気をつけましょう。

--

--