このページの目次

TCP

インターネットでもっとも複雑なプロトコル——3ウェイハンドシェイクによる接続確立、スライディングウィンドウによるフロー制御、CUBIC/BBR による輻輳制御、SACK による再送削減。TCP は信頼性の低い IP 上に信頼性の高いバイトストリームの抽象化を構築し、各メカニズムは数十年にわたるエンジニアリング実践の結晶です。

概要

TCP はインターネットの最も基本的な信頼性のある転送プロトコルであり、1981年に RFC 793 で定義されました。HTTP、SSH、SMTP など、インターネットトラフィックの大部分を担っています。信頼性の低い IP レイヤの上に、順序付きバイトストリーム、パケットロス再送、フロー制御、輻輳制御を提供します。

TCP を理解するための主要な軸は、2つのものを同時に保護することです。スライディングウィンドウは相手側⁠(受信側のバッファがあふれないように)を保護し、輻輳制御はネットワーク上⁠(中継ルータのキューがあふれないように)を保護します。前者には明確なシグナルがあります(相手側が直接ウィンドウサイズを教えてくれます)。後者にはありません——ネットワークは自発的に「あふれそうです」と報告しません。送信側は推測するしかありません。TCP の核心的な革新は「信頼性を保証する」ことではなく、まさにこの「推測の芸術」、すなわち輻輳制御にあります。過去40年で輻輳アルゴリズムは Tahoe/Reno から CUBIC(Linux デフォルト)へ、さらに BBR(Google 2016年)へと進化し、インターネットが銅線からモバイルネットワーク、データセンターへと変遷してきたことを反映しています。

TCP ヘッダ

 0                   1                   2                   3
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Source Port          |       Destination Port        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Sequence Number                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Acknowledgment Number                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data  |       |U|A|P|R|S|F|                               |
|Offset | Rsrvd |R|C|S|S|Y|I|            Window             |
| (4)   | (3)  |G|K|H|T|N|N|           (16 bits)            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           Checksum            |         Urgent Pointer        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Options (if Data Offset > 5)               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

主要なフィールドのセマンティクス:

  • Sequence Number:このセグメントの最初のデータバイトのシーケンス番号。初期シーケンス番号(ISN)はランダム化されています(シーケンス番号予測攻撃を防ぐため)。
  • Acknowledgment Number:受信を期待している次のバイトのシーケンス番号——これは累積 ACK です。ack=1000 は、999 以前のバイトがすべて受信済みであることを意味します。
  • Data Offset:TCP ヘッダの長さ。4バイト単位で表され、最小値は 5(オプションなしのヘッダ長 20 バイト)です。
  • Flags:CWR、ECE(明示的輻輳通知 ECN 用)、URG、ACK、PSH、RST、SYN、FIN。
  • Window:受信側の現在の空きバッファサイズ。元のフィールドは 16 ビットのみ(最大 64KB)でしたが、現代の TCP は Window Scale オプションによって拡張されます(後述)。

主要な Options

Optionkind出現タイミング役割
MSS2SYN のみ自端が受信できる最大のセグメントサイズを宣言。通常 = MTU(1500) − IP(20) − TCP(20) = 1460
Window Scale3SYN のみウィンドウ値を N ビット左シフト(最大 14)。64KB の上限を突破
SACK Permitted4SYN のみ選択的確認応答のサポートを宣言
SACK5データ転送中非連続な受信区間を報告し、再送を穴埋めに限定
Timestamp8全パケットRTT 測定(RTTM)+ シーケンス番号ラップアラウンド保護(PAWS)

詳細な説明が必要なものが2つあります。

Window Scale が必要な理由⁠——これがなければ、ウィンドウの最大値は 64KB です。送信側が「ネットワーク上に置ける」データの上限は min(ウィンドウ, cwnd) ですが、リンクの容量は BDP(帯域幅 × RTT)です。1Gbps、RTT 50ms のリンクでは BDP ≈ 6.25MB となり、64KB のウィンドウでは 64KB / 50ms ≈ 10Mbps しか出せません——帯域がどれだけ大きくてもフルには使い切れません。scale を使用すれば(例:window=65535, scale=7 → 実質 8MB)、ウィンドウが現代のリンクに追いつきます。

SACK が解決する問題⁠——累積 ACK は「連続して何バイト受信したか」しか表現できません。バイト 2001-2999 が欠落した場合、3000-4000 がすでに受信されていても、ACK は 2001 で停止します。これにより、送信側はすでに受信済みの後続データも再送してしまう可能性があります。SACK を使用すると、受信側は「1000-2000 と 3000-4000 を受信した」と明示的に伝え、送信側は 2001-2999 の1つの穴のみを埋めることができます。

接続確立:3ウェイハンドシェイク

sequenceDiagram
    participant C as Client
    participant S as Server
    Note over C: CLOSED → SYN_SENT
    Note over S: LISTEN
    C->>S: SYN, seq=x
    Note over S: SYN_RCVD
    S->>C: SYN+ACK, seq=y, ack=x+1
    C->>S: ACK, seq=x+1, ack=y+1
    Note over C,S: 双方 ESTABLISHED

なぜ3回なのか、2回ではダメなのか? ハンドシェイクの目標は双方が「双方向のチャネルが利用可能である」ことを確認することです。2回の場合:Client が SYN を送信、Server が SYN+ACK で応答——この時点で Client は双方向の到達可能性を確認しましたが、Server は自分の SYN+ACK が到達したかどうかを知りません。3回目の ACK は、Server に対するこの確認応答です。1回減ると、どちらかの端がチャネルの状態について疑念を抱いたままになります。

なぜ SYN はシーケンス番号を消費するのか? SYN と FIN はどちらも「確認可能」でなければなりません——もしシーケンス番号を消費しない場合、パケットが失われても ACK メカニズムでは発見・再送できません。SYN(seq=x) に対して ACK(x+1) が返されることで、「あなたの SYN を受信しました」と証明されます。これがハンドシェイクの図で ack が x+1、y+1 となっている理由です。

余談ですが、SYN_RCVD 状態はサーバーリソース(半接続キュー)を占有するため、SYN フラッド攻撃の標的となります。Linux は syncookies を使用し、状態を ISN にエンコードしてクライアントに返すことで、ステートレスな応答を実現しています。

接続終了:4ウェイハンドシェイク

sequenceDiagram
    participant A as 主動終了側
    participant P as 受動終了側
    A->>P: FIN, seq=u
    Note over A: FIN_WAIT_1
    P->>A: ACK, ack=u+1
    Note over P: CLOSE_WAIT — アプリケーション層でまだ close していない
    Note over A: FIN_WAIT_2
    P->>A: FIN, seq=v
    Note over P: LAST_ACK
    A->>P: ACK, ack=v+1
    Note over A: TIME_WAIT — 2MSL 待機後に CLOSED
    Note over P: CLOSED

ハンドシェイクが4回になるのは、終了が2つの独立した半終了だからです。FIN の受信は「相手側はこれ以上送信しない」という意味しか示しません。自端にはまだ送信すべきデータが残っている可能性があります(そのため、ACK と自らの FIN の間に CLOSE_WAIT が挟まれます。その長さはアプリケーションが close を呼び出すタイミングに依存します)。

TIME_WAIT でなぜ 2MSL(約120秒)待機するのか⁠:

  1. 最後の ACK の到達を保証するため⁠——もし ACK が失われると、相手側は FIN を再送します。主動終了側は「生きていて」再応答できなければならず、そうでなければ相手側は永遠に LAST_ACK で停止してしまいます。
  2. 古い接続の迷子パケットをネットワーク内で期限切れにするため⁠——前の接続の遅延パケットが、直後に同じ5タプルで再利用される新しい接続の一部と誤認されるのを防ぎます。

TIME_WAIT が大量に蓄積する(高並列の短命接続を行うクライアント側で一般的)場合の緩和策:

手段適用範囲説明
tcp_tw_reuse=1クライアントTIME_WAIT ソケットを新しい接続で再利用可能。タイムスタンプの単調増加に基づいて安全性を判定
SO_REUSEADDRサーバーTIME_WAIT が排空されるのを待たずに、再起動直後にポートを bind 可能
tcp_fin_timeout孤児接続orphan ソケットの FIN_WAIT_2 タイムアウトのみに影響。MSL を調整するものとして誤用されることが多い
接続プールアーキテクチャ層根本的な解決策——長寿命接続を再利用し、TIME_WAIT の発生そのものを減らす

スライディングウィンドウとフロー制御

フロー制御が答える質問は:⁠送信側は相手側がどれくらい受け取れるのかをどうやって知るのか? 答えはシンプルです——受信側は各 ACK で空きバッファ(Window フィールド)を通知し、送信側は「送信済みだが未確認」のデータ量がそれを超えないように保証します。

送信側視点のシーケンス空間:

        確認済み          送信済み未確認        送信可能        ウィンドウ外
  ──────────────┬──────────────────┬────────────────┬─────────────
                SND.UNA            SND.NXT          SND.UNA+SND.WND

送信条件は SND.NXT < SND.UNA + SND.WND です。

「ウィンドウが縮小する」ことで発生する2つの古典的な落とし穴:

  • ゼロウィンドウデッドロック⁠:受信側バッファが満杯で window=0 を通知し、送信側が送信を停止します。その後、受信側がスペースを空けて window update を送信——もしこの update が失われると、双方が互いを待ってデッドロックします。そのため TCP には persist timer があります。送信側は定期的に 1 バイトの window probe を送信し、受信側に最新ウィンドウを含む ACK を強制的に返させます。
  • Silly Window Syndrome:受信側が 1 バイト空きを作るたびに window=1 を通知し、送信側が 1 バイトを送信——無限の小パケットとなり、ヘッダオーバーヘッドがペイロードを大幅に上回ります。両端で緩和策があります。送信側は Nagle アルゴリズム(MSS 分溜めるか、ACK を受信するまで送信しない)を使用し、受信側は Clark の方案(ウィンドウが十分なスペースを空けるまで小さなウィンドウを通知しない)を使用します。

輻輳制御:CUBIC → BBR

フロー制御には相手側からの明確な数値通知がありますが、輻輳制御にはありません——⁠ネットワークは「あふれそう」を教えてくれません⁠。送信側は間接的な証拠から推測する必要があります:パケットが失われた?遅延が増加した?「どのシグナルを使って輻輳を推測するか」という選択こそが、各世代のアルゴリズムの分岐点です。CUBIC はパケットロスをシグナルとし、BBR は測定された帯域幅と RTT をシグナルとします。

送信レートは輻輳ウィンドウ cwnd によって制御され、実際に送信できる量は min(cwnd, 相手側ウィンドウ) です。

CUBIC(Linux デフォルト)

CUBIC の成長曲線は3次関数であり、最後のパケットロス時のウィンドウ W_max を「記憶」として使用します。

cwnd = C × (t − K)³ + W_max        K = ³√(W_max × β / C),  β = 0.7

直感的には:パケットロス後、cwnd は 0.7 倍に削減され、その後W_max に急速に近づき、近づくと速度を落とし(曲線の平坦部)、問題ないことを確認してからさらに高い水位を探索して加速⁠——3次曲線は自然にこの「速-遅-速」の形状を与え、成長は最後のロスからの時間 t のみに依存し、RTT には依存しません(長距離・高帯域リンクにおいて、Reno の RTT ごとに線形に1加算するよりもはるかに高速です)。

flowchart LR
    SS["🚀 <b>Slow Start</b><br><small>cwnd 毎RTT2倍</small>"] -->|"ssthresh 到達"| CA["📈 <b>Congestion Avoidance</b><br><small>CUBIC曲線に沿って成長</small>"]
    SS -.->|"パケットロス"| MD["💥 <b>Decrease</b><br><small>cwnd ×0.7, W_max を記録</small>"]
    CA -.->|"パケットロス"| MD
    MD ==> CA
    classDef grow fill:#3fb9501f,stroke:#3fb950,stroke-width:2px
    classDef steady fill:#4493f81f,stroke:#4493f8,stroke-width:2px
    classDef loss fill:#f851491f,stroke:#f85149,stroke-width:2px
    class SS grow
    class CA steady
    class MD loss

CUBIC の問題は、そのシグナル自体にあります:⁠パケットロスは遅すぎるし、常に正しいとは限らないシグナルです⁠。

  • bufferbloat リンク上(ルータのバッファが巨大)では、キューが満たされてからパケットロスが発生——この時点では遅延がすでに数百ミリ秒に達しており、CUBIC はまだアクセルを踏んでいます。
  • 高帯域 × 長 RTT(大 BDP)リンク上では、1回のロスで 30% 削減された後、回復に時間がかかります。
  • ランダムなパケットロスがあるリンク(WiFi、越洋通信)上では、ノイズを輻輳と誤判定し、無駄に速度を低下させます。

BBR(Bottleneck Bandwidth and RTT、Google 2016)

BBR はシグナルを変えました:パケットロスを待たず、⁠リンクそのものをモデル化します。パスを1本のパイプラインと見なし、2つのパラメータを測定します——ボトルネック帯域幅 BW(直近の配信レートの最大値)と伝播遅延 min RTT(直近の RTT の最小値)。パイプラインの容量は BDP = max(BW) × min(RTT) です。in-flight データ量を BDP 付近に固定し、帯域を最大限に活用しつつ、ルータ内でキューイングしないようにします。

flowchart LR
    S["🚀 <b>Startup</b><br><small>毎ラウンド2倍<br>パイプラインを素早く満たす</small>"] -->|"配信レート<br>これ以上増加しない"| D["🫗 <b>Drain</b><br><small>BDP を超える<br>キューイングデータを排空</small>"]
    D -->|"inflight が BDP に戻"| P["⚖️ <b>ProbeBW</b><br><small>定常状態: 利得ループ<br>新しい帯域を軽く探る</small>"]
    P -->|"10秒ごと"| R["📏 <b>ProbeRTT</b><br><small>4パケットに圧縮<br>min RTT を再測定</small>"]
    R ==>|"クリーンな min RTT を取得"| P
    classDef grow fill:#3fb9501f,stroke:#3fb950,stroke-width:2px
    classDef warn fill:#d299221f,stroke:#d29922,stroke-width:2px
    classDef steady fill:#4493f826,stroke:#4493f8,stroke-width:2.5px
    classDef neutral fill:#64748b1f,stroke:#64748b,stroke-width:2px
    class S grow
    class D warn
    class P steady
    class R neutral
  • Startup:スロースタートに似ており、毎ラウンド2倍にしてパイプラインを素早く満たします。
  • Drain:満たされると inflight は BDP を超えます(余分な分がキューイングされる)。まずこれを排空します。
  • ProbeBW:定常状態。利得ループ [5/4, 3/4, 1, 1, 1, 1, 1, 1] を使用して周期的にアクセルを軽く踏み、新しい帯域を探索してから解放します。
  • ProbeRTT:10秒ごとに inflight を極限まで圧縮し、キューを空にして実際の伝播遅延を測定します——そうしないと、自身が生成したキューが min RTT の測定を汚染してしまいます。

実際の効果(Google YouTube の展開データ):平均遅延が約33%減少、再送が約20%減少。ただし限界も確かに存在します。BBR v1 は CUBIC 流に対して不公平でした(プローブが攻撃的すぎて、共存するパケットロス型トラフィックを圧迫)。v2(RFC 9438 時代の改善と併用)では、パケットロスと ECN への対応が追加され、公平性が改善されました。

比較表

CUBICBBR
輻輳シグナルパケットロス測定された BW + RTT モデル
bufferbloat リンクキューが満たされ、遅延が急増inflight ≈ BDP、低遅延
ランダムロス リンク輻輳を誤判定し、スループットが崩壊パケットロスで自動減速せず、スループット安定
公平性同種間で収束が良好v1 はロス型流に対して不公平、v2 で改善
展開Linux デフォルトsysctl net.ipv4.tcp_congestion_control=bbr

この輻輳制御のアプローチは TCP に固有のものではありません——QUIC はユーザーランドで全套を再実装しています(UDP と QUIC 転送層 参照)。アルゴリズムを変更してもカーネルを変更する必要はありません。チューニングの実践についてはネットワークパフォーマンスとチューニングをご覧ください。

参考文献

  • RFC: 793, 7323 (timestamp/window scale), 2018 (SACK), 7413 (TFO), 5681, 8312 (CUBIC), 9438 (BBRv2)
  • ソースコード⁠: net/ipv4/tcp_input.c (tcp_rcv_established), net/ipv4/tcp_cubic.c, net/ipv4/tcp_bbr.c
  • ツール⁠: ss -tianp, nstat -a | grep Tcp, bpftrace -e 'k:tcp_retransmit_skb { @[comm] = count(); }'

Keywords: TCP, 3ウェイハンドシェイク, TIME_WAIT, スライディングウィンドウ, CUBIC, BBR, SACK, TFO, Window Scale, 輻輳制御, BDP, bufferbloat