TCPからQUICへ — 信頼できる通信の再発明
インターネットの土台は「届かないかもしれない」仕組みです。TCPが順番・再送・輻輳制御でどう信頼性を作り出したか、なぜHTTP/2で行き詰まったか、そしてQUICがあえてUDPの上に全部を作り直した理由を、前提知識ゼロから積み上げます。
「届いたことにする」という発明
インターネットが信頼できるのは、丈夫な線が敷いてあるからではありません。壊れる前提の上に、届いたかどうかを確認し合う手順を重ねているからです。
比喩を使います。あなたは友人に長い手紙を送りたいのに、使えるのは宛先だけ書いて投函するはがきだけ。たいてい届きますが保証はなく、紛失することも、順番が入れ替わることも、同じものが二度届くこともあります。苦情の窓口もありません。
この条件で「確実に、順番通りに、全文を渡す」方法はひとつしかありません。はがきに通し番号を振り、受け取った側が「32番まで受け取った」と返事を書き、返事の来ないぶんを送り直す。これがTCPのやっていることの全部です。難しい理論ではなく、信頼できない土台の上に事務手続きを積むという発想です。
土台のIPは「はがき」しか運べない
最下層のIPは、パケットという小さな塊をルータからルータへバケツリレーするだけで、届いたかどうかを追跡しません。
重要なのは、ルータが混んだときにパケットを捨てるのは故障ではなく正常動作だという点です。ルータの中には送り出し待ちの行列があり、入る速度が出る速度を超えれば行列は伸び、溢れたぶんは床に落ちます。落としたことを送り手に教える仕組みは基本的にありません。
ここからTCPの制約がすべて出てきます。送り手は、返事が来ないという事実からしか、損失も混雑も知ることができないのです。
仕事その1 — 順番付けと確認応答
TCPはアプリに「切れ目のない1本のバイトの川」という顔をします。write() した内容が順番通り、欠けも重複もなく相手の read() から出てくる。この見え方をバイトストリーム抽象と呼びます。
実現方法は素朴で、送るバイトごとに通し番号(シーケンス番号)があり、受け手は「ここまでは連続して受け取った」という位置をACKで返します。これが累積ACKで、「32番まで」の一言で31番以前も無事だと伝わる、通信量の少ない設計です。
代償は後で効きます。累積ACKは連続した先頭しか言えないので、穴の先に何が届いているかを表現できません。後からSACK(選択的確認応答、RFC 2018)が足されたのは、この表現力不足を埋めるためです。
仕事その2 — 再送、つまり「いつ諦めるか」
返事が来ない。何秒待って送り直すか。短すぎれば無駄な再送で自ら混雑を悪化させ、長すぎれば通信が止まって見えます。TCPは往復時間(RTT)を測り続け、その平均とばらつきから待ち時間(RTO)を決めます(RFC 6298)。
が今回実測した往復時間、 がその移動平均(過去を少しずつ忘れる平均)、 がブレ幅です。つまり「いつもの往復時間+ブレ4つぶん」待って返事が来なければ失われたと見なすという意味しかありません。ブレの大きい回線ほど気長に待つ、という常識を式にしただけです。
ただしタイマー待ちは遅いので、高速再送もあります。受け手は穴が空いたまま後続を受け取ると「まだ32番までだ」というACKを繰り返すので、送り手は同じACKが3回重なった時点でタイマーを待たずに送り直します。
ここで後の伏線になる問題が生まれます。再送したパケットへのACKが返ってきたとき、それが最初の送信への返事か再送への返事か区別できません。シーケンス番号はデータの位置なので、再送すると同じ番号が二度飛ぶからです。区別できない以上そのRTT実測値は捨てるしかなく(Karnのアルゴリズム)、一番荒れているときほど測定精度が落ちるという構造的な弱点になります。
仕事その3 — 輻輳制御、見えない渋滞を推測する
一番難しいのはここです。経路がどれくらい混んでいるかは誰も教えてくれません。TCPは損失を「混んでいる」の代理信号として使うという賭けに出ました。
送り手は「返事を待たずに送り出してよい量」を持ちます。これが輻輳ウィンドウ(cwnd)です。接続の最初はスロースタートで、1往復ごとにcwndを倍にして上限を手探りします。失われ始めたら輻輳回避に切り替わり、AIMD(加算増加・乗算減少)で動きます。
が輻輳ウィンドウです。言い換えると、うまくいっている間は1往復かけてやっと1つぶん増やし、損失を見た瞬間に一気に何割か削る。上りは階段、下りは滑り台です。この非対称は意図的で、増減とも掛け算にすると多く取っている流れほど得をして永久に釣り合いません。増加を足し算・減少を掛け算にすると、取りすぎている流れほど損失時に大きく削られ、複数の通信が同じ帯域へ寄っていきます。公平性のための非対称です。
そして速度はこの一行で決まります。
1往復で出せる量を、往復にかかる時間で割ったものが速度という意味です。RTTが分母にいる以上、距離が遠いだけで遅くなる。回線を太くしても地球の裏側との往復時間は変わりません。
損失を合図と見なす方式には副作用があります。ルータのバッファが深いと、TCPは「まだ落ちていないから行ける」と押し込み続け、行列が伸びるだけで損失が起きません。帯域は出ているのに往復時間が数百ミリ秒に膨らむ、これがバッファブロートです。CUBIC(多くのLinuxディストリビューションの既定)は増やし方を工夫したもの、BBRは損失ではなく帯域と往復時間の推定を直接使ってこの罠を避けようとしたものです。
HTTP/2で露呈した限界 — Head-of-Lineブロッキング
HTTP/1.1では1接続で1リクエストずつしか扱えず、ブラウザは同じサーバへ6本ほど並列に接続していました。HTTP/2はこれを「1本の接続に複数ストリームを多重化する」形へ整理します。接続数は減りヘッダも圧縮され、理屈のうえでは明らかな改善でした。
ところがパケットロスのある回線では、HTTP/1.1より遅くなることがあります。原因はTCPの約束そのものです。TCPは接続全体で順番通りのバイトの川を保証するため、途中の1セグメントが失われると、後ろに届いているデータがまったく別のストリームのものであってもカーネルの受信バッファに留め置かれ、アプリに渡りません。穴が埋まるまで川全体がせき止められる。これがHead-of-Lineブロッキングです。
6本に分散していたときは1本詰まっても残り5本が流れていたのに、1本にまとめた結果、1個の損失が全ストリームを止めるようになったわけです。
これはHTTP/2の設計ミスではありません。順序保証の単位が接続全体であるというTCPの性質が原因で、APIがバイトストリームである以上、上のレイヤからは直せません。直すにはトランスポート層そのものを作り直すしかなかったのです。
なぜ、あえてUDPの上に作り直したのか
新しいトランスポートなら、IPの上にTCPと並ぶものを定義するのが筋に見えます。そうせずUDPに載せたのは、技術というより現実の理由が2つあったからです。
理由1: TCPはもう変えられない(硬直化)。 経路上にはNAT・ファイアウォール・最適化装置といった中間装置が大量にあり、TCPヘッダを覗いて動作を判断しています。見慣れないオプションの付いたパケットは黙って捨てられることがある。結果、新しいTCP機能は「規格として作れる」のに「経路を通らない」。TCP Fast Open(RFC 7413)やMultipath TCPが期待ほど普及しなかった背景にはこれがあります。
理由2: TCPはOSカーネルの中にある。 改良を配るには世界中の端末のOS更新を待つしかなく、年単位、機種によっては永久に届きません。一方アプリと一緒に配れるユーザ空間なら、アップデートのたびに新しいトランスポートを配れます。そしてユーザ空間から使えて、どの経路でも通る土管はUDPしかありません。
UDPは「順番も再送も何もせず、ポート番号だけ付けたIP」です。何もしないからこそ中間装置に口出しされません。QUIC(RFC 9000、2021年)は、この空っぽの土管の上にTCPの仕事全部と、TCPにはできなかったことを自前で実装したプロトコルです。もともとGoogleが実験的に始め、IETFで標準化されました。
QUICが作り直した5つの点
1. 暗号化と接続確立の統合。 従来はTCPの3ウェイハンドシェイクで1往復、その上にTLSの鍵交換が積まれ、TLS 1.3を使ってもデータが流れ始めるまで2往復かかります。QUICはトランスポート確立とTLS 1.3の鍵交換を1つの手順に統合し、初回1往復、再訪時は0-RTT(最初のパケットに本文を載せる)まで縮めました。ただし0-RTTのデータは丸ごと再送される余地があるため、副作用のないリクエストにしか使えません。
2. ストリームがトランスポートの一級市民に。 順序保証の単位が接続全体ではなくストリームごとです。ストリームAのパケットが落ちても、届いているストリームBのデータはそのままアプリに渡ります。HTTP/2の問題はここで消えます。ただし完全にゼロにはならず、HTTP/3のヘッダ圧縮(QPACK)が動的テーブルを参照する場合、その部分だけ順序依存が戻ります。
3. パケット番号を二度使わない。 QUICはデータの位置とは別に、送出するパケットへ単調増加する番号を振ります。再送データは新しい番号のパケットに載るので、どのACKがどの送信への返事かが常に一意です。結果、RTT実測値を捨てずに済み、損失検出も明快になります(RFC 9002では、番号が3つ先まで確認されたら損失、あるいは の の時間を過ぎたら損失と見なす)。Karnの問題の根本的な解消です。
4. コネクションID。 TCPの接続は「送信元IP・ポート・宛先IP・ポート」の4つ組で識別されるため、Wi-Fiからモバイル回線へ切り替わればIPが変わり接続は死にます。QUICはIPと独立したコネクションIDを持たせ、IPが変わってもIDが同じなら同じ接続として継続できます(コネクションマイグレーション)。
5. ヘッダまで暗号化。 QUICはペイロードだけでなくヘッダの大半を暗号化します。中間装置に中身を読ませないためで、将来また硬直化しないようにするための意思です。TCPが変えられなくなった歴史から引いた教訓が、そのまま仕様に埋め込まれています。
手を動かすときの入口
ss -ti # cwnd・rtt・再送数を今この場で見る
sysctl net.ipv4.tcp_congestion_control # cubic か bbr か
curl --http3 -sv https://example.com/ # HTTP/3で叩いてみる
sysctl -w net.core.rmem_max=7500000 # QUICはUDP受信バッファが既定だと足りないことがある
# 小さい書き込みが数十ms待たされる古典的な罠を止める:
# s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
現場ではこう使う
誰が、いつ触るか。 バックエンド/SRE/CDN運用/モバイルアプリの担当者が、「なぜか遅い」を切り分けるときです。目的はほぼ常にひとつ、遅さの原因が帯域なのか、往復時間なのか、損失なのかを見分けることです。
見る道具。 TCPなら ss -ti が最短経路で、cwnd・rtt・retrans が一度に出ます。再送が多ければ損失、cwndが小さいまま伸びなければ輻輳制御が抑えている、rttが大きければ距離かバッファ滞留です。QUICは中身が暗号化されているので tcpdump では読めず、代わりにライブラリが吐く qlog を qvis に読ませてストリームごとの流れと損失を見ます。「QUICはWiresharkで見えない」ことを知らずに調査時間を溶かすのは、移行期にいちばん多い事故です。
触るパラメータ名。 net.ipv4.tcp_congestion_control(cubic / bbr)、net.core.rmem_max と wmem_max、TCP_NODELAY、TCPキープアライブ。HTTP/3を実際に使わせるには、サーバの Alt-Svc ヘッダか、DNSのHTTPSレコード(RFC 9460)で h3 を広告する必要があります。サーバで有効にしただけではクライアントはHTTP/3を使いません。
知らないと事故になる落とし穴。
- Nagleアルゴリズムと遅延ACKの噛み合わせ。 小さいデータを2回に分けて
write()すると、送り手は「前のACKが来るまでまとめて待つ」、受け手は「返すデータがないからACKを遅らせる」で双方が待ち合い、数十ミリ秒(Linuxでは40ミリ秒級)止まります。小さいメッセージを往復させるRPCやゲームではTCP_NODELAY、あるいは1回のwrite()にまとめるのが定石です。 - 帯域を買ってもRTTは縮まない。 遠距離の通信は回線を太くしても速くなりません。効くのはCDNやエッジ配置で距離そのものを縮めることです。
- バッファブロート。 速度テストの数字は良いのに操作がもたつく場合、途中のバッファに行列ができています。CUBICのような損失ベースの輻輳制御はこれを検知できません。BBRや
fq_codelの出番です。 - QUICはCPUを食う。 TCPは受信処理の多くをカーネルとNICが引き受けますが、QUICは1パケットずつユーザ空間で復号・処理します。高負荷のサーバではUDP GSO/GROが効いているか確認してください。
- UDPが通らない環境が現実にある。 企業ネットワークや一部の公衆Wi-Fiでは、UDPが塞がれていたりレートを絞られていたりします。HTTP/3は必ずTCPへフォールバックできる構成にしてください。「h3のみ」は作ってはいけません。
- 0-RTTを冪等でないリクエストに使わない。 再送されうる以上、「決済を実行する」ようなリクエストでは二重実行の窓が開きます。
面接や設計レビューで問われる形。 「HTTP/2にしたのに速くならなかった理由は?」「TCPの再送とQUICの再送は何が違う?」「なぜQUICはUDPの上なのか?」— 3つとも答えの軸は同じ2点、順序保証の単位がどこにあるかと、誰がそのコードを世界に配れるかです。ここを押さえていれば、細かい仕様を覚えていなくても筋の通った説明ができます。
まとめ
- IPは「たいてい届くはがき」しか運ばない。信頼性は番号付け・確認応答・再送という事務手続きで後から作られている
- TCPの輻輳制御は、損失を混雑の代理信号として使う賭け。加算増加・乗算減少の非対称は公平性のため
- HTTP/2が詰まったのは順序保証の単位が接続全体だから。上のレイヤからは直せない
- QUICがUDPを選んだのは性能上の都合ではなく、TCPが中間装置とカーネルに縛られて変更不能になったから
- 得たもの: ストリーム単位の順序保証、1往復(再訪は0往復)の接続確立、IP変更に耐えるコネクションID、曖昧さのない損失検出
失われたぶんを送り直すのではなく、あらかじめ冗長を混ぜておいて受け手だけで復元するという反対側の手もあります。往復を待てない場面で効くその考え方は誤り訂正を1から — 壊れる前提で送るという発想で扱っています。ここで見た往復時間とバッファの話が動画配信の遅延としてどう表に出るかは、HLSとDASH — 動画が届くまでのプロトコルを読むと具体的につながります。
コメント
コメントにはログインが必要です