リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC
HTTP/2 — 1 本の接続にリクエストを重ねる
一言でいうと
HTTP/2は、1つの接続に複数のリクエストをストリームとして重ねて載せ、ヘッダーを圧縮しますが、その代償として、すべてのストリームが、接続1つのフロー制御ウィンドウとTCPのバイトストリームを分け合って使います。
なぜ必要なのか
HTTP/1.1の接続は、一度に1つのリクエストしか処理しません。仕様には、応答を待たずにリクエストを続けて送るパイプライニングがありますが、応答はリクエストの順序どおりにしか戻ってこられないので、前の遅い応答が後ろをすべて引き留めます。これが、アプリケーションレベルのヘッドオブラインブロッキング(head-of-line blocking)です。そのため、ブラウザーとクライアントライブラリは、パイプライニングの代わりに接続を複数開く道を選び、接続ごとにTCPハンドシェイクとTLSハンドシェイク、輻輳ウィンドウのスロースタートを、別々に払いました。リクエストごとに同じように繰り返されるCookieと認証ヘッダーも、毎回平文で送り直しました。
RFC 9113のHTTP/2は、この2つの無駄を狙います。接続は1つにして、その上に独立したストリームを複数開き、ヘッダーは、接続単位の状態を持つ圧縮(RFC 7541 HPACK)で減らします。gRPCはこの上で動き、ストリーミング応答が突然止まる障害の多くが、ここで説明するフロー制御ウィンドウで起こります。
どう動くのか
HTTP/2のすべてはフレームです。4.1節のフレームヘッダーは、9バイトで固定されています。
| フィールド | サイズ | 意味 |
|---|---|---|
| Length | 24ビット | ヘッダーを除いた本文の長さ。既定の上限は16,384(SETTINGS_MAX_FRAME_SIZE) |
| Type | 8ビット | DATA 0x0、HEADERS 0x1、RST_STREAM 0x3、SETTINGS 0x4、PING 0x6、GOAWAY 0x7、WINDOW_UPDATE 0x8 … |
| Flags | 8ビット | END_STREAM、END_HEADERSのように、種類ごとに意味が違います |
| R + Stream Identifier | 1 + 31ビット | 予約ビットは、送るときは0、受け取るときは無視します |
クライアントが開くストリームは奇数、サーバーが開くストリームは偶数の番号で、ストリーム0は、接続全体の制御(SETTINGS・PING・GOAWAY)に使います。リクエスト1つは、HEADERSフレームで始まり、END_STREAMフラグで終わり、異なるストリームのフレームは、1つの接続の中で、いくらでも混ざって流れます。接続は、常にクライアントのプリフェイス(PRI * HTTP/2.0で始まる24バイト)と、両側のSETTINGSで始まり、HTTP/2を使うと決める方法は、TLSではALPNのh2、平文では3.3節の事前知識(prior knowledge)です。
同時に開けるストリームの数は、受け取る側が、SETTINGS_MAX_CONCURRENT_STREAMSで通知します。6.5.2節は、この値の初期値は無制限で、100より小さくしないことを勧めています。ここでよくある落とし穴が出てきます。相手の最初のSETTINGSが届く前は、上限がわからないので、プリフェイスを送るとすぐにリクエストをまとめて送ると、上限を超えることがあります。
フロー制御(5.2節、6.9節)は、DATAフレームにだけかかります。ウィンドウは、ストリームごとに1つ、接続全体に1つあり、どちらも65,535バイトで始まります。送る側は、2つのウィンドウのうち小さいほうの分だけ送れ、受け取る側がWINDOW_UPDATEでウィンドウを返してはじめて、さらに送れます。SETTINGS_INITIAL_WINDOW_SIZEで変えられるのは、ストリームウィンドウの初期値だけで、接続ウィンドウは、WINDOW_UPDATEでだけ広がります。そのため、ストリームウィンドウを100,000と通知しても、最初の応答は65,535バイトで止まります。ウィンドウを返さないクライアントが1つあると、大きな応答1つが接続ウィンドウを使い切り、同じ接続の3バイトの応答まで待たされます。
HPACKは、61項目の静的テーブルと、接続ごとに1つずつたまる動的テーブル(既定4,096バイト)を使います。同じ接続で、同じヘッダーを2回目に送るときは、テーブル番号1つに縮まります。接続を新しく開くと、テーブルも空になり、最初からまた育ちます。圧縮の利得は、接続を長く使うことから生まれます。
現場での姿
HTTP/2に変えたら、遅いページは速くなったのに、モバイル網ではむしろ悪くなったという報告がよくあります。HTTP/1.1の6つの接続は、互いに独立なので、1つでパケットが失われても、残りの5つは流れます。HTTP/2の1つの接続は、ストリームがいくつあっても、TCPから見ればバイトストリーム1つなので、セグメント1つを再送するあいだ、すべてのストリームが止まります。次のモジュールが、この話を続けます。
もう1つは、gRPCストリームが数秒ずつ止まる障害です。受け取る側のアプリケーションがメッセージを取り出すのが遅いと、ライブラリがウィンドウを返すのも遅れ、送る側は、ウィンドウが閉じて待ちます。ネットワークは空いているのに、スループットが底をついた姿に見えます。
次のラボですること
h2ライブラリでフレームヘッダーを読み、HPACKのサイズを測り、1つの接続にリクエストを重ねて載せるクライアントを作ります。そのあと、同時ストリーム数の上限を守るように直し、ウィンドウが閉じるバイト数を、16,384・任意の値・100,000の3つの場合で測って、接続ウィンドウの存在を数字で確認します。最後に、TCPが1回止まる中継器を置いて、HTTP/2とHTTP/1.1の小さな応答がいつ終わるかを比べます。