TT Lab
はじめる
学ぶ 学習パス コース

リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC

1 本の HTTP/2 接続にリクエストを重ね、その代償を測る

TT Labで続きを見る

目標

HTTP/2のフレームとヘッダー圧縮をバイトレベルで確認し、1つの接続にリクエストを重ねて載せるクライアントを作って、HTTP/1.1との違いと、TCPレベルのHOLブロッキングを、自分で測ります。

なぜ重要なのか

HTTP/2は、1つの接続で複数のリクエストを同時に運ぶために作られたプロトコルです。その代わり、1つの接続にすべてを賭けることになり、フロー制御ウィンドウを返さないバグ1つや、セグメントの損失1つが、その接続のすべてのストリームを止めます。gRPCはHTTP/2の上で動き、ストリーミング応答が突然止まる障害の多くが、このウィンドウで起こります。このラボは、利得とコストを同じツールで測り、どちらがいつ勝つのかを、数字で残します。

ステップ

  1. 9バイトのフレームヘッダーを読む: /root/rt/h2/h2lab.pyにframe_header(data)を作ってください。bytesの先頭9バイトをHTTP/2のフレームヘッダーとして読み、(長さ, 種類, フラグ, ストリーム番号)の4つのintのtupleを返します。長さは先頭3バイトの符号なしビッグエンディアン整数で、ストリーム番号は、最後の4バイトから先頭の予約ビットを除いた31ビットです。9バイトより短ければValueErrorで、9バイトのあとに付いたバイトは無視します。
  2. 同じヘッダーを2回送ると小さくなる: /root/rt/h2/h2lab.pyにhpack_sizes(headers, times)を追加してください。(名前, 値)のtupleのlistであるheadersを、hpack.Encoderでtimes回エンコードし、それぞれのエンコード結果のバイト数をlistで返します。1つの接続がリクエストを何回も送る状況を真似るものなので、エンコーダーは1つだけ作って使い続けます。
  3. 1つの接続にリクエストを重ねて載せる: /root/rt/h2/h2lab.pyにfetch_all(host, port, paths, timeout=10)を追加してください。host:portでTCP接続を1つ開き、h2ライブラリのクライアント接続でプリフェイスを送ったあと、pathsのGETリクエストを、応答を待たずにすべて送ります。応答が終わるたびに集め、pathsと同じ順序で、(パス, ステータスint, 本文bytes, 開始から終了までのミリ秒float)のtupleのlistを返します。受け取ったDATAは、acknowledge_received_dataでウィンドウを返し、timeout秒以内に終わらなければ、例外を出します。
  4. HTTP/1.1と同じ物差しで測る: /opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py compareを実行してください。300msかかるリクエスト6つを、HTTP/1.1のkeep-alive接続1つで順番に送った時間と、自分のfetch_allで送った時間が出ます。出力のh1_total_msとh2_total_msの2行を、/root/rt/h2/report.txtにそのまま書いてください。採点ツールがその場でもう一度測って照合します。
  5. サーバーが通知した同時ストリーム数の上限を守る: fetch_allを直して、サーバーのSETTINGSを受け取ったあと、remote_settings.max_concurrent_streamsを超えないようにストリームを開くようにしてください。上限に達していたら、残りのリクエストは待機して、ストリームが終わるたびに開きます。採点ツールは、上限を2と通知するサーバーにリクエスト5つを送り、上限を超えないまま、2つずつ重ねて送るかを確認します。
  6. フロー制御ウィンドウが閉じる位置を測る: /root/rt/h2/h2lab.pyにwindow_probe(host, port, path, window, quiet=0.3)を追加してください。新しい接続のプリフェイスのあとに、INITIAL_WINDOW_SIZEをwindowとして通知するSETTINGSを送り、GET pathを送ります。受け取ったDATAは、ウィンドウを返さずに数え、quiet秒のあいだ何も来なければ、そこまでに受け取ったバイト数を記録し、受け取った分だけウィンドウを返して最後まで読みます。(停止するまでに受け取ったバイト数, 全体のバイト数)を返します。
  7. TCPの1本の流れが塞がると、ストリームがすべて止まる: /opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py holを実行してください。下流方向に20000バイトが通過したあとで、1回800ms止まる中継器を間に置いて、大きな応答と小さな応答を一緒に要求し、小さな応答が終わった時刻を測ります。HTTP/2は自分のfetch_allで1つの接続に、HTTP/1.1は接続2つに分けて送ります。出力のhol_h2_msとhol_h1_msの2行を、/root/rt/h2/report.txtに追加してください。

参考

9バイトのフレームヘッダーを読む

/root/rt/h2/h2lab.pyにframe_header(data)を作ってください。bytesの先頭9バイトをHTTP/2のフレームヘッダーとして読み、(長さ, 種類, フラグ, ストリーム番号)の4つのintのtupleを返します。長さは先頭3バイトの符号なしビッグエンディアン整数で、ストリーム番号は、最後の4バイトから先頭の予約ビットを除いた31ビットです。9バイトより短ければValueErrorで、9バイトのあとに付いたバイトは無視します。

フレームヘッダーの形は、RFC 9113の4.1節の図のとおりです。予約ビットは、送るときは0でなければなりませんが、受け取るときは無視するとされています。オンのまま届いても、ストリーム番号が変わってはいけません。

同じヘッダーを2回送ると小さくなる

/root/rt/h2/h2lab.pyにhpack_sizes(headers, times)を追加してください。(名前, 値)のtupleのlistであるheadersを、hpack.Encoderでtimes回エンコードし、それぞれのエンコード結果のバイト数をlistで返します。1つの接続がリクエストを何回も送る状況を真似るものなので、エンコーダーは1つだけ作って使い続けます。

HPACKの動的テーブルは、接続ごとに1つで、接続が生きているあいだたまっていきます。エンコーダーオブジェクトが、そのままそのテーブルです。2回目の結果が1回目と同じなら、何を新しく作っているのかを見てください。

1つの接続にリクエストを重ねて載せる

/root/rt/h2/h2lab.pyにfetch_all(host, port, paths, timeout=10)を追加してください。host:portでTCP接続を1つ開き、h2ライブラリのクライアント接続でプリフェイスを送ったあと、pathsのGETリクエストを、応答を待たずにすべて送ります。応答が終わるたびに集め、pathsと同じ順序で、(パス, ステータスint, 本文bytes, 開始から終了までのミリ秒float)のtupleのlistを返します。受け取ったDATAは、acknowledge_received_dataでウィンドウを返し、timeout秒以内に終わらなければ、例外を出します。

h2はソケットを知りません。data_to_send()で出ていくバイトを取り出して直接送り、受け取ったバイトはreceive_data()に入れて、イベントとして返してもらいます。ウィンドウを返さないと、大きな応答1つが64KiBで接続全体を止めます。採点のリクエストには、200000バイトの応答が混ざっています。

HTTP/1.1と同じ物差しで測る

/opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py compareを実行してください。300msかかるリクエスト6つを、HTTP/1.1のkeep-alive接続1つで順番に送った時間と、自分のfetch_allで送った時間が出ます。出力のh1_total_msとh2_total_msの2行を、/root/rt/h2/report.txtにそのまま書いてください。採点ツールがその場でもう一度測って照合します。

HTTP/1.1は、1つの接続で応答の順序どおりにしかリクエストを処理しません。パイプライニングは仕様にありますが、ブラウザーとプロキシが事実上使っていません。そのため、6回の300msが、そのまま足し合わされます。

サーバーが通知した同時ストリーム数の上限を守る

fetch_allを直して、サーバーのSETTINGSを受け取ったあと、remote_settings.max_concurrent_streamsを超えないようにストリームを開くようにしてください。上限に達していたら、残りのリクエストは待機して、ストリームが終わるたびに開きます。採点ツールは、上限を2と通知するサーバーにリクエスト5つを送り、上限を超えないまま、2つずつ重ねて送るかを確認します。

SETTINGSを受け取る前は、上限がわかりません。仕様上、そのときの既定値は無制限なので、プリフェイスを送るとすぐにリクエストをまとめて送ると、サーバーが拒否します。最初のSETTINGSは、RemoteSettingsChangedイベントで来ます。

フロー制御ウィンドウが閉じる位置を測る

/root/rt/h2/h2lab.pyにwindow_probe(host, port, path, window, quiet=0.3)を追加してください。新しい接続のプリフェイスのあとに、INITIAL_WINDOW_SIZEをwindowとして通知するSETTINGSを送り、GET pathを送ります。受け取ったDATAは、ウィンドウを返さずに数え、quiet秒のあいだ何も来なければ、そこまでに受け取ったバイト数を記録し、受け取った分だけウィンドウを返して最後まで読みます。(停止するまでに受け取ったバイト数, 全体のバイト数)を返します。

フロー制御ウィンドウは、ストリームごとに1つ、接続全体に1つあります。SETTINGSで変えられるのは、ストリームの初期値だけで、接続ウィンドウは65535から始まります。windowを100000にしたとき、どこで止まるかを、先に計算してみてください。

TCPの1本の流れが塞がると、ストリームがすべて止まる

/opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py holを実行してください。下流方向に20000バイトが通過したあとで、1回800ms止まる中継器を間に置いて、大きな応答と小さな応答を一緒に要求し、小さな応答が終わった時刻を測ります。HTTP/2は自分のfetch_allで1つの接続に、HTTP/1.1は接続2つに分けて送ります。出力のhol_h2_msとhol_h1_msの2行を、/root/rt/h2/report.txtに追加してください。

中継器が止まるのは、失われたセグメント1つが再送を待つあいだ、TCPが後ろのバイトをアプリケーションに上げてくれない様子を真似たものです。ストリームはHTTP/2の概念で、TCPはそれを知りません。