リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC
gRPC ストリーミングの 4 つの形
一言でいうと
gRPCの呼び出しは、HTTP/2ストリーム1つで、メッセージはその上に長さを前に付けた断片として流れ、ステータスコードは、いちばん最後のトレーラーに載ってきます。
なぜ必要なのか
単項の呼び出しだけでリアルタイム機能を作ると、ポーリングになります。音声認識の途中結果、LLMのトークン、相場のように、少しずつできあがる結果をまとめて送ると、最初の断片を受け取るまでの時間が、全体の処理時間と同じになります。gRPCのコアコンセプトのドキュメントは、呼び出しを4つの形に分けています。単項、サーバーストリーミング、クライアントストリーミング、双方向ストリーミングです。形は、protoのrpc宣言で、リクエストとレスポンスのどちらにstreamを付けたかで決まり、生成コードが、その形に合ったスタブを作ります。
どう動くのか
gRPC over HTTP/2の仕様を見ると、呼び出し1つがHTTP/2ストリーム1つです。リクエストは、:path /패키지.서비스/메서드(プレースホルダーはパッケージ・サービス・メソッドです)、content-type: application/grpc、期限があればgrpc-timeoutヘッダーで始まり、メッセージは、1バイトの圧縮フラグ+4バイトの長さ+protobufのバイトの順につながります。応答も、同じ形のメッセージのあとに、トレーラーのgrpc-statusとgrpc-messageで終わります。
この構造から、3つのことが出てきます。
1つ目は、ストリーミングは、作ったそばから送るときにだけストリーミングです。Pythonのサーバーでジェネレーターからyieldすると、gRPCが1つずつ取り出して送ります。結果をlistにすべて集めて返すと、最初のメッセージが最後のメッセージと同じ時刻に届きます。コードの形はストリーミングなのに、ユーザー体験は単項です。
2つ目は、状態は最後に来ます。サーバーがメッセージを3つ送ったあとにエラーで終わると、クライアントは、メッセージ3つを受け取ったあとで、はじめてエラーを見ます。Pythonクライアントでは、反復の途中でgrpc.RpcErrorが飛び出します。list(stub.Method(...))の1行で受け取ると、すでに受け取った3つを失います。途中結果に意味があるストリームなら、ループの中で1つずつ確保する必要があります。ステータスコードは17種類で、リアルタイムサービスでよく見るのは、DEADLINE_EXCEEDED(4)、CANCELLED(1)、UNAVAILABLE(14)、RESOURCE_EXHAUSTED(8)、ABORTED(10)、INVALID_ARGUMENT(3)です。
3つ目は、双方向ストリームの2つの方向は、互いに独立しています。サーバーは、リクエストをすべて受け取る前に答えられ、コアコンセプトのドキュメントの表現のとおり、2つのストリームは、どんな順序でも読み書きできます。その自由のために、お互いに待ち合って止まるデッドロックが、簡単に生じます。クライアントは、答えを受け取ってから次のリクエストを送るのに、サーバーがlist(request_iterator)でリクエストを先にすべて集めると、クライアントは答えを、サーバーはリクエストの終わり(half-close)を待ちます。期限がなければ、永遠に待ちます。
期限は呼び出しにかけます。期限のガイドは、既定値が事実上無限なので、すべての呼び出しに期限を明示するよう勧めています。期限は、grpc-timeoutヘッダーでサーバーに伝わり、期限が過ぎると、クライアントはDEADLINE_EXCEEDEDを受け取ります。ところが、サーバーの処理スレッドは、ひとりでには止まりません。サーバーコードがcontext.is_active()やcontext.time_remaining()で、自分で確認する必要があります。確認しなければ、誰も受け取らない結果のために、CPUとDB接続を最後まで使います。
メッセージのサイズにも上限があります。ほとんどの実装は、受け取るメッセージの既定の上限を4MBにしていて、超えるとRESOURCE_EXHAUSTEDで呼び出しを終えます。大きな結果を1つのメッセージに入れる代わりに、ストリームで切って送ることが、ストリーミングのもう1つの使いどころです。切って送れば、受け取る側は最初の断片から処理を始められ、フロー制御が一度に握るメモリも、断片のサイズに減ります。逆に、断片を細かくしすぎると、メッセージごとに付く5バイトのヘッダーとシリアライズのコストが大きくなるので、音声のように拍のあるデータは、20msのような自然な単位で切ります。
現場での姿
「ストリーミングに変えたのに、相変わらず一度に来る」は、サーバーがまとめて送っているか、途中のプロキシやロードバランサーが応答をバッファリングしている場合です。2つを区別するには、サーバーのそばで1回、ユーザー側で1回、最初のメッセージが届いた時刻を測ってみればよいのです。「クライアントはタイムアウトしたのに、サーバーのCPUが回り続けている」は、期限を確認しないサーバーコードです。期限超過が集中する瞬間、サーバーはすでに捨てられた仕事でいっぱいになり、新しいリクエストまで遅くなる、連鎖障害に広がります。
次のラボですること
meter.protoからコードを生成し、サーバー・クライアント・双方向ストリーミングを実装します。採点ツールは、最初のメッセージの到着時刻でストリーミングかどうかを、1行ずつやり取りする期限3秒の呼び出しで、デッドロックの有無を確認します。ストリームの途中のエラーで、受け取った値を守るクライアントと、期限が過ぎたらひとりでに止まるサーバーまで作ります。