Dev Study
← HTTPとTLSの仕組み
レッスン 9 / 12·約 9 分·第18期 HTTPとTLSの仕組み

HTTP/2 は番号で混ぜて返せるが、下の順序は1本のまま

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力今ここ
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口今ここ
  6. アプリ処理
  7. DB保存

前回は、HTTP/1.1 の応答に番号が無いことを見ました。今回は、番号を共有して省けたものと、共有したまま残ったものを分けて見ます。

まず結論

HTTP/2 はフレームの先頭に「どの要求への応答か」を示すストリーム番号を書きます。両端がこの番号を共有しているので、応答を要求の順に並べる必要がなくなり、遅い応答の後ろで速い応答が待たなくて済みます。

ただし番号を知っているのは HTTP/2 の層だけです。下の TCP は1本のバイト列の順序を全ストリームで共有しています。RFC 9113 自身が、TCP 層の head-of-line blocking はこのプロトコルでは扱わない、と書いています。

? 読む前に予測

1秒後に応答する /slow と、すぐ応答する /fast がある HTTP/2 サーバに、1本の接続で /slow、/fast の順に要求を出します。/fast の応答はいつ届くでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

番号を共有すれば、返す順番を揃えなくてよい

このコースの芯は「通信の仕組みは、両端が何を共有しているかで決まる。共有していれば省略でき、共有していなければ往復が要る」です。今回、HTTP/2 の両端はストリームの番号を共有しました。そのおかげで省けたものがあります。同時に、両端は下の1本の TCP の順序も共有したままです。この回はその2つを分けて見ます。

HTTP/1.1 の応答には、どの要求への応答かを示す番号がありません。RFC 9113 §1 は、HTTP/1.1 のパイプラインを still suffers from application-layer head-of-line blocking と書きます。先頭の応答が遅いと、後ろの応答は用意できていても待たされます。

HTTP/2 はここに番号を入れました。§4.1 によれば、すべてのフレームは a fixed 9-octet header で始まり、その中に 31 ビットの Stream Identifier があります。§5 はこう書きます。

A single HTTP/2 connection can contain multiple concurrently open streams, with either endpoint interleaving frames from multiple streams.

フレームごとに番号が付いているので、受け手は番号で、どの要求への応答かを見分けられます。要求の順に返す必要がなくなりました。§5.1.1 は、クライアントが開始するストリームを奇数とし、Stream identifiers cannot be reused. と定めます。

番号を共有した層と、順序を共有したままの層
ストリーム 1: /slow1秒後に応答
ストリーム 3: /fastすぐ応答
フレームの先頭に番号を書いて混ぜる
HTTP/2 の層番号で見分けるので、3 の応答が先でも取り違えない
TCP はこの番号を知らない
1本の TCP のバイト列順序は全ストリームで1つ。損失や入れ替わりで全部が止まる(RFC 9114 §1.1)
  • ・上2段は本回の実演で確かめた。最下段は RFC の記述で、損失は起こしていない
具体実物

1本の接続で、後の要求が先に済む

node の http2 モジュールで平文の HTTP/2(h2c)サーバを立てました。/slow は1秒待ってから、/fast はすぐ応答し、ストリーム番号と相手のポートをログに出します(Node v24.21.0)。

const http2=require('http2');const t=()=>Date.now()%100000;
const s=http2.createServer();
s.on('session',ss=>console.log(`${t()} new session (TCP connection) from port ${ss.socket.remotePort}`));
s.on('stream',(st,h)=>{const p=h[':path'];console.log(`${t()} stream id=${st.id} path=${p} port=${st.session.socket.remotePort}`);
 const send=()=>{console.log(`${t()} respond id=${st.id} ${p}`);st.respond({':status':200,'content-type':'text/plain'});st.end(`${p} stream=${st.id}\n`);};
 p==='/slow'?setTimeout(send,1000):send();});
s.listen(18082,'127.0.0.1',()=>console.log('h2c listening 18082',process.version));

macOS 標準の curl 8.7.1 で、/slow を先、/fast を後に並列で要求します(2回実行)。

$ curl -s --parallel --http2-prior-knowledge -w "$W" -o /dev/null -o /dev/null http://127.0.0.1:18082/slow http://127.0.0.1:18082/fastコマンド出力
=== run1 curl --parallel --http2-prior-knowledge 2026-09-24 10:57:10
http://127.0.0.1:18082/fast http_version=2 local_port=52054 time_total=0.004207
http://127.0.0.1:18082/slow http_version=2 local_port=52054 time_total=1.004992
=== run2 curl --parallel --http2-prior-knowledge 2026-09-24 10:57:11
http://127.0.0.1:18082/fast http_version=2 local_port=52071 time_total=0.000717
http://127.0.0.1:18082/slow http_version=2 local_port=52071 time_total=1.002037

1回目の2行は、どちらも local_port=52054 です。同じ TCP 接続のまま、後に要求した /fast が先に完了しています。 サーバ側のログ(1回目の接続の分だけ抜粋)には番号が出ています。

サーバのログ(先頭は Date.now() % 100000 のミリ秒)追跡ログ
30280 new session (TCP connection) from port 52054
30281 stream id=1 path=/slow port=52054
30281 stream id=3 path=/fast port=52054
30281 respond id=3 /fast
31282 respond id=1 /slow

/slow が 1、/fast が 3。クライアント開始なので奇数で、後のほうが大きい番号です。応答は 3 が先で、1 は約1秒後(30281 → 31282)でした。見ているのは完了の時刻で、線上のフレームの並びは取得していません。

✓ ここまでの確認

1本の接続で、後に要求した /fast の応答が /slow より先に届いても取り違えが起きないのは、両端が何を共有しているからですか。

抽象原理

番号で分けたのは HTTP/2 の層だけ

RFC 9113 §2 は Streams are largely independent of each other と書きます。「おおむね」です。§1 には、パイプラインを述べた段落より後の段落の末尾に、次の一文があります。

Note, however, that TCP head-of-line blocking is not addressed by this protocol.

第2期『なぜ手を3回握るのか』で見たとおり、TCP が作るのは「順序どおりに、失われずに届く通り道」です。ストリームの番号はその通り道の中身に書かれていて、TCP からは見えません。RFC 9114 §1.1 は、その帰結をこう書きます。

However, because the parallel nature of HTTP/2's multiplexing is not visible to TCP's loss recovery mechanisms, a lost or reordered packet causes all active transactions to experience a stall regardless of whether that transaction was directly impacted by the lost packet.

原因は損失だけでなく、reordered(順序の入れ替わり)も含みます。巻き込まれるのは、失われたパケットと関係のないトランザクションも含む全部です。

両端は番号を共有したので、HTTP の応答を並べる待ちは省けました。けれども、1本のバイト列の順序は全ストリームで共有したままです。共有しているものは省略の根拠にもなり、全員を同じ待ちに巻き込む根拠にもなります。

具体実物

接続を分けると、番号は 1 から数え直す

同じ要求に --parallel-immediate を足します。curl 8.7.1 の man はこのオプションを、多重化できるか待つより opening up more connections in parallel at once を優先させるもの、と説明しています。

サーバのログ(--parallel-immediate を足した1回目。抜粋)追跡ログ
32303 new session (TCP connection) from port 52076
32303 new session (TCP connection) from port 52077
32303 stream id=1 path=/slow port=52076
32303 stream id=1 path=/fast port=52077
32303 respond id=1 /fast
33303 respond id=1 /slow

接続が2本(52076 と 52077)になり、どちらのストリームも番号 1 です。 番号が通用するのは1本の接続の中だけで、その接続の両端だけが共有しています。接続の外に持ち出すと、同じ 1 が別の要求を指します。

--parallel だけのとき、-v の出力には多重化できると分かるまで待ってから同じ接続に載せる様子が出ました(該当行だけ抜粋)。

$ curl -v -s --parallel --http2-prior-knowledge ... 2>&1 | grep -E '...'(抜粋)コマンド出力
* Server doesn't support multiplex yet, wait
* Connected to 127.0.0.1 (127.0.0.1) port 18082
* [HTTP/2] [1] OPENED stream for http://127.0.0.1:18082/slow
* [HTTP/2] [1] [:path: /slow]
* Found bundle for host: 0x1049ce540 [can multiplex]
* Re-using existing connection with host 127.0.0.1
* [HTTP/2] [3] OPENED stream for http://127.0.0.1:18082/fast
* [HTTP/2] [3] [:path: /fast]
* Connection #0 to host 127.0.0.1 left intact

接続を分けた場合に、片方の損失がもう片方を止めるかどうかは、本回では試していません。HTTP/3 がこの順序の問題にどう答えたかは、このコースの後の回で扱います。

✓ ここまでの確認

「HTTP/2 ではストリームが独立しているので、パケットが1つ失われても影響はそのストリームだけに閉じる」という説明は妥当ですか。

⇄ 2つを見比べる
具体例 A

HTTP/2 のストリーム番号

1本の接続の中で /slow が 1、/fast が 3 でした。接続を2本に分けると、どちらも 1 になりました。番号の範囲は1本の接続で、その下の TCP のバイト列の順序は、その接続の全ストリームが共有しています。

実測(サーバのログより)表
接続(相手のポート) /slow /fast
52054(1本に2つ) id=1 id=3
52076 と 52077(2本) id=1 id=1
具体例 B

TCPの接続を識別する組(第2期『ポートという多重化』)

サーバ側の443番という番号だけでは、どの会話かは決まりません。同じ443番に多くの接続が同時に来るからです。番号が会話を指せるのは、両端のIPとポートで区切った範囲の中です。

443という番号だけでは決まらない表
見ている値 一意に決まるか
宛先ポート 443 だけ 決まらない(何千本も該当)
宛先IP + 宛先ポート 決まらない(相手が複数いる)
両端のIPとポート(4値) 決まる

ストリーム番号とポート番号。層は違いますが、番号で会話を見分ける仕組みとして共通する構造は何でしょうか。

「その番号を、誰と誰が、どの範囲で共有しているか」を両方で探してください。

この章のまとめ

  • HTTP/2 はすべてのフレームのヘッダにストリーム番号を書く(RFC 9113 §4.1)。両端が番号を共有するので、応答を要求の順に並べる待ちが省ける。
  • 実測: 1本の接続(local_port 52054)で /slow が id=1、/fast が id=3。/fast が 0.004207 秒、/slow が 1.004992 秒で完了した。
  • 番号が通用するのは1本の接続の中だけ。--parallel-immediate で2本に分かれると、両方とも id=1 だった。
  • 番号を知っているのは HTTP/2 の層だけ。RFC 9113 §1 は TCP head-of-line blocking is not addressed by this protocol. と書く。§2 の独立は largely(おおむね)。
  • RFC 9114 §1.1: 損失または順序の入れ替わりで、全トランザクションが止まる。本回はこれを RFC の記述として読み、損失の実演はしていない。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

curl に `--parallel-immediate` を付けて /slow と /fast を並列に要求したところ、local_port が2つに分かれました。サーバのログに出たストリーム番号はどうなっていましたか。

用語集(4語)
ストリーム
HTTP/2 の1本の接続の中で、1組の要求と応答がやり取りされる、フレームの双方向の並び(RFC 9113 §5)。
ストリーム識別子
ストリームを指す 31 ビットの番号。クライアント開始は奇数、サーバ開始は偶数、0 は接続全体の制御用。新しい番号は前より大きく、再利用しない(RFC 9113 §5.1.1)。通用するのは1本の接続の中だけ。
フレーム
HTTP/2 の送信単位。固定 9 オクテットのヘッダで始まり、その中にストリーム識別子を持つ(RFC 9113 §4.1)。
head-of-line blocking
先頭の1つが進まないために後ろが待たされること。RFC 9113 §1 は、HTTP/1.1 のアプリケーション層のものと TCP 層のものを分けて書く。