このページの目次

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 flood 攻撃の標的となります。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サーバー再起動直後にポートを bind でき、TIME_WAIT の排空を待たない
tcp_fin_timeout孤児接続孤児ソケットの 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 毎RTT 2倍</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