メインコンテンツへスキップ
友田 陽大
TCP/IP・ネットワーク
TCP/IP
TCP
ネットワーク
パフォーマンス
可観測性
アーキテクチャ設計

TCP の仕組み完全解説:3ウェイハンドシェイク・状態遷移・再送・輻輳制御を RFC 9293 で理解する

TCP がどうやって信頼性を作るのかを、IETF の一次情報(RFC 9293・5681・6298)に忠実に解説する実装ガイド。3ウェイハンドシェイク、11状態の状態機械、シーケンス番号とACK・再送(RTO・高速再送)、フロー制御(ウィンドウ)、輻輳制御(スロースタート・CUBIC・BBR)を、図とコード・観測コマンドで本番のデバッグに使える形にまとめます。

公開日
読了時間
14分
著者
友田 陽大
シェア

「TCP は信頼性がある」——よく聞く説明ですが、「どうやって」信頼性を作っているのかを説明できる人は多くありません。そして本番障害の現場では、まさにその「どうやって」を知っているかどうかが切り分けの速度を決めます。なぜ確立に時間がかかるのか。なぜパケットロスが起きるとスループットが急落するのか。TIME-WAIT は何のためにあるのか。CLOSE-WAIT が溜まるのはなぜ「自分のバグ」なのか。

この記事は、TCP の心臓部——3ウェイハンドシェイク・11状態の状態機械・再送・フロー制御・輻輳制御——を、IETF の一次情報に忠実に、かつ図と観測コマンドで「本番で使える」形に解説します。TCP/IP 全体像の記事で「TCP は信頼性・順序・フロー制御・輻輳制御を提供する」と述べた、その内部です。

この記事のルール:規定は TCP = RFC 9293(2022年8月、RFC 793 ほかを廃止し RFC 1122 を更新)輻輳制御 = RFC 5681再送タイマー(RTO) = RFC 6298SACK = RFC 2018ウィンドウスケール/タイムスタンプ = RFC 7323 に基づきます。CUBIC・BBR など具体的な輻輳制御アルゴリズムは OS 実装依存です。RFC は改訂されるため、最新版を rfc-editor.org で確認してください。


1. TCPセグメントのヘッダ——信頼性の「部品」を先に押さえる

仕組みの前に、TCP が信頼性を作るための「道具」がヘッダに揃っていることを確認します。RFC 9293 が定める主要フィールドはこれです(最小20バイト)。

フィールドサイズ役割
送信元ポート / 宛先ポート各16ビットプロセスの多重化(5タプルの一部)
シーケンス番号32ビットこのセグメントの先頭バイトの通し番号。順序と欠落検知の核
確認応答番号(ACK番号)32ビット「次に欲しいバイト番号」=ここまでは受け取った(累積ACK)
データオフセット4ビットヘッダ長(オプションで可変なため必要)
制御フラグ各1ビットCWR, ECE, URG, ACK, PSH, RST, SYN, FIN
ウィンドウ16ビット受信側が受け取れるバイト数=フロー制御
チェックサム16ビットヘッダ+データ+擬似ヘッダの完全性検査
緊急ポインタ16ビットURG 有効時のみ

制御フラグの意味を押さえると、以降の状態遷移がすべて読めます。

  • SYN:接続を開く。シーケンス番号を同期(Synchronize)する。
  • ACK:確認応答番号が有効。確立後の全セグメントで立つ。
  • FIN:送信完了。「もう送るデータはない」を通知し、正常クローズを始める。
  • RST:接続を即座に異常リセット。ECONNRESET の正体。
  • PSH:バッファリングせず即アプリへ渡せ、の指示。
  • URG / 緊急ポインタ:緊急データ。現代ではほぼ使われない。
  • ECE / CWR:明示的輻輳通知(ECN, RFC 3168)でパケットを落とさずに混雑を伝えるための拡張。

2. 3ウェイハンドシェイク——なぜ「3回」必要なのか

TCP は通信前にコネクションを確立します。RFC 9293 はこれを「初期シーケンス番号(ISN)を双方向で同期し、互いに確認し合う手続き」と定義します。

クライアント                                サーバー
   │                                          │
   │   ① SYN  seq=x                           │   "開きたい。私の開始番号は x"
   │ ───────────────────────────────────────► │
   │                                          │
   │   ② SYN, ACK  seq=y, ack=x+1             │   "了解(x+1まで受領)。私の開始番号は y"
   │ ◄─────────────────────────────────────── │
   │                                          │
   │   ③ ACK  ack=y+1                          │   "了解(y+1まで受領)。確立完了"
   │ ───────────────────────────────────────► │
   │                                          │
  ESTABLISHED                             ESTABLISHED

なぜ2回ではダメか:信頼通信には双方向の合意が要ります。①でクライアント→サーバー方向のISN(x)を、②でサーバー→クライアント方向のISN(y)を伝え、それぞれに相手のACKが必要です。②はサーバーのSYNとクライアントSYNへのACKを兼ねるため、合計3回に圧縮されます。これが「3ウェイ」の理由です。

ISN はなぜランダムか:ISN を予測可能にすると、第三者が偽のセグメントを差し込む「シーケンス番号予測攻撃」が成立します。RFC 6528 は ISN を暗号ハッシュベースで生成し予測困難にすることを規定します。セキュリティが手続きそのものに織り込まれている好例です。

設計への示唆:ハンドシェイクは最低1往復(1 RTT)を必ず消費します。さらに HTTPS なら TLS ハンドシェイクが上乗せされる。遠距離・高RTT環境で新規接続を毎回張るのが致命的に遅いのはこのためで、Keep-Alive とコネクションプールが効く根拠です。TCP Fast Open(RFC 7413)はこの初回RTTを削る拡張ですが、適用範囲は限定的です。


3. 11状態の状態機械——TCP接続のライフサイクル

RFC 9293 は TCP 接続を 11の状態で定義します:LISTEN, SYN-SENT, SYN-RECEIVED, ESTABLISHED, FIN-WAIT-1, FIN-WAIT-2, CLOSE-WAIT, CLOSING, LAST-ACK, TIME-WAIT と、観念上の CLOSEDssnetstat で見えるあの状態名の出典がこれです。

3.1 確立フェーズ

            ┌──────────┐
            │  CLOSED  │
            └────┬─────┘
       passive   │   active open (connect)
       open      │   send SYN
   (listen)      ▼
   ┌────────┐  ┌──────────┐
   │ LISTEN │  │ SYN-SENT │
   └───┬────┘  └────┬─────┘
  recv SYN        recv SYN/ACK
  send SYN/ACK    send ACK
       ▼               │
 ┌───────────────┐     │
 │ SYN-RECEIVED  │     │
 └──────┬────────┘     │
   recv ACK            │
        └──────┬───────┘
               ▼
        ┌─────────────┐
        │ ESTABLISHED │  ← データ送受信はここ
        └─────────────┘

3.2 解放フェーズ(4ウェイ、そして TIME-WAIT)

クローズは送受信が独立に閉じるため、原則4回のやり取り(双方向のFIN/ACK)になります。

能動クローズ側(close を呼んだ方)          受動クローズ側
   ESTABLISHED                              ESTABLISHED
       │  ① FIN ─────────────────────────────►│
   FIN-WAIT-1                                  │ recv FIN
       │◄───────────────────── ② ACK ─────────│ CLOSE-WAIT
   FIN-WAIT-2                                  │ (アプリが close するまで滞在)
       │◄───────────────────── ③ FIN ─────────│ LAST-ACK
       │  ④ ACK ─────────────────────────────►│
   TIME-WAIT                                CLOSED
   (2×MSL 待機)
       ▼
   CLOSED

ここから実務的な3つの教訓が出ます。

  • TIME-WAIT(能動クローズ側が 2×MSL 待つ):遅延して届いた古いセグメントが、同じ5タプルの新接続を汚染しないための安全装置。正しい挙動であって異常ではありません。ただし短命接続を大量に張ると溜まってポート枯渇を招く(→全体像の記事§5.1)。
  • CLOSE-WAIT の滞留=ほぼ自分のバグ:相手のFINを受けてACKは自動で返るが、LAST-ACK へ進むにはアプリが明示的に close() する必要があります。これを呼び忘れると CLOSE-WAIT に居座り続け、FD(ファイルディスクリプタ)をリークします。
  • RST による中断:4ウェイの正常クローズに対し、RST は問答無用で接続を破棄します。受信側は ECONNRESET。LBのアイドルタイムアウト切断、クラッシュ、キューあふれなどが原因。

4. 信頼性の作り方①:シーケンス番号・累積ACK・再送

4.1 累積ACK と再送の基本

TCP は送ったバイトにシーケンス番号を振り、受信側は「次に欲しいバイト番号」をACK番号で返します(累積ACK=それ未満は全部受け取った、の意味)。送信側は ACK が返らないデータを再送します。これが信頼性の根です。

再送の引き金は2系統あります。

4.2 RTO(再送タイムアウト)——時間で諦める

ACK が一定時間返らなければ再送します。その「一定時間」が RTO で、RFC 6298実測 RTT から動的に算出することを規定します。固定値ではありません。

SRTT     = 平滑化した往復時間(RTT の移動平均)
RTTVAR   = RTT のばらつき(分散)
RTO      = SRTT + max(G, 4 × RTTVAR)      (G はクロック粒度)

ポイントは**RTOは保守的(やや長め)で、かつ再送のたびに指数的に伸びる(バックオフ)**こと。だから「パケットが1個落ちただけ」でも、RTO 起因の再送は体感で大きな遅延になりえます。これを避けるのが次の高速再送です。

4.3 高速再送(Fast Retransmit)——重複ACK 3回で待たずに送る

受信側は、順番が飛んだセグメント(例:1,2,_,4,5)を受け取ると、「まだ3が欲しい」という同じACKを繰り返し返します。送信側は重複ACKを3回受け取った時点で、RTO 満了を待たずただちに該当セグメントを再送します。タイマー満了を待たない分、回復が速い。

4.4 SACK(選択的確認応答)——「どこが抜けたか」を正確に伝える

累積ACKだけだと「3が抜けた」ことは分かっても「4,5は届いている」ことを伝えられず、4,5まで無駄に再送しかねません。SACKRFC 2018)は「受信済みの飛び地」をACKに添えて伝え、抜けた穴だけをピンポイント再送できるようにします。現代のスタックでは既定で有効です。

# SACK が有効か(Linux)
sysctl net.ipv4.tcp_sack          # = 1 なら有効
# 接続ごとの再送・RTT を覗く
ss -tin                            # rtt:, retrans:, cwnd: などが見える

5. 信頼性の作り方②:フロー制御と輻輳制御は「別物」

ここを混同すると性能問題の原因を見誤ります。フロー制御は受信側を守り、輻輳制御はネットワークを守る——目的が違います。

5.1 フロー制御(受信ウィンドウ)——「速い送信側 vs 遅い受信側」

受信側はヘッダのウィンドウフィールドで「いま受け取れるバイト数」を毎回通知します。送信側はこれを超えて送れません。受信アプリが読み出さずバッファが満ちるとウィンドウが0になり、送信は停止(ゼロウィンドウ)。受信が回復するとウィンドウ更新で再開します。

16ビットのウィンドウは最大64KBしか表せず、高速・高遅延の回線では足りません。これを拡張するのがウィンドウスケールオプション(RFC 7323)で、ハンドシェイク時に倍率を交換します。

5.2 輻輳制御——「ネットワークの混雑」から全体を守る

送信側は受信ウィンドウとは別に、**輻輳ウィンドウ(cwnd)**という「ネットワークがいま耐えられそうな量」の内部見積もりを持ちます。実際に送れるのは min(受信ウィンドウ, cwnd) です。RFC 5681 が定める土台はこうです。

① スロースタート:cwnd を 1RTT ごとに倍増(指数的)。ssthresh に達するまで。
② 輻輳回避     :ssthresh 超過後は 1RTT ごとに +1MSS(線形)。慎重に増やす。
③ ロス検知時   :
   - 重複ACK(高速再送)→ ssthresh を半減し cwnd を絞る(高速回復)。
   - RTO 満了       → cwnd を 1 に戻しスロースタートからやり直す(重いペナルティ)。

この AIMD(加算増加・乗算減少) が「パケットロスでスループットが急落する」現象の正体です。だからロスは性能の天敵で、わずかなロス率でも遠距離の太い回線では帯域を使い切れません。

5.3 具体アルゴリズムは OS 実装依存——CUBIC と BBR

RFC 5681 は枠組みで、実際の増減カーブはアルゴリズムが決めます。本番で出会う2つ:

  • CUBIC:Linux の既定。cwnd を3次関数で増やし、高帯域・高遅延でも素早く回復。ロスを混雑のシグナルとする従来型の改良版。
  • BBR(Google):ロスではなく実測の帯域とRTTから最適送信レートを推定する。バッファブロート(過大なバッファによる遅延)に強く、ロスの多い無線・長距離で有利なことがある。
# 利用可能/現在の輻輳制御アルゴリズム(Linux)
sysctl net.ipv4.tcp_available_congestion_control
sysctl net.ipv4.tcp_congestion_control     # 既定はたいてい cubic

設計への示唆:「サーバーを増やしたのにスループットが伸びない」とき、原因がアプリではなく輻輳制御 × ロス × RTTということは珍しくありません。ss -tinretransrtt を見て、ロス率が高ければ経路(無線/トンネル/過負荷なミドルボックス)を、RTTが大きければ接続の張り直し(プール化)やリージョン配置を疑います。


6. コードで観る:半開(ハーフオープン)接続と Keep-Alive

状態機械の知識がコードのバグに直結する例を示します。一方の端がクラッシュやNAT/LBのアイドル切断で消えると、もう一方は接続が死んだことを何も送らない限り気づけません(TCPはアイドル時に無通信だから)。これがハーフオープンです。気づかないと、死んだ接続をプールから掴んで ECONNRESET を踏みます。

import net from "node:net";

/** 死活を能動検知し、アイドルも区別して安全に畳む TCP クライアント設定 */
export function createResilientSocket(host: string, port: number): net.Socket {
  const socket = net.createConnection({ host, port });

  // ① TCP Keep-Alive:アイドル接続にプローブを送り、相手が消えていれば検知する
  //    (ハーフオープンの主防御。OS が定期的に空セグメントで生存確認)
  socket.setKeepAlive(true, 30_000); // 30秒アイドルでプローブ開始

  // ② アプリ層のアイドルタイムアウト:一定時間データが無ければ自分から畳む
  socket.setTimeout(60_000, () => {
    socket.destroy(new Error("idle timeout: no data for 60s"));
  });

  // ③ RST/異常を握りつぶさず観測(ECONNRESET の可視化)
  socket.on("error", (err) => {
    console.error(`[tcp] ${host}:${port} ${(err as NodeJS.ErrnoException).code ?? ""} ${err.message}`);
  });

  return socket;
}

Keep-Alive(OSのTCPプローブ)とアプリのタイムアウトは別物です。前者は「接続が物理的に生きているか」、後者は「論理的に応答があるか」を見ます。本番では両方を、上流のLBアイドルタイムアウトより短く設定するのが安全です(LBが先に切ると、こちらが死んだ接続を掴む)。


7. TCP の限界——だから QUIC/HTTP3 が生まれた

最後に、TCP の構造的な弱点を2つ挙げます。これを知ると次の技術選定が見通せます。

  1. ヘッドオブラインブロッキング(HoLB):TCP は「順序通りの単一バイトストリーム」なので、1つのセグメントが失われると、その後ろの(無関係な)データもアプリへ渡せず待たされる。HTTP/2 が1本のTCP上で多重化しても、TCP層のロスで全ストリームが止まるのはこのため。
  2. 接続確立RTTの固定費:3ウェイ+TLSで毎回複数RTT。高遅延ほど効く。

QUICRFC 9000、2021年)は、UDP の上に「ストリーム独立の信頼性+暗号化+輻輳制御」を再実装し、ストリームごとに独立して回復することで HoLB を解消し、接続確立も0〜1RTTに短縮しました。HTTP/3RFC 9114、2022年)はこの QUIC を土台にします。TCP を捨てたのではなく、TCP の制約を回避するために Transport 層ごと作り直した——これが TCP/IP 全体像で触れた「層の差し替え」の最大の実例です。

TCP と UDP(そして QUIC)の使い分けの判断軸は、本クラスタの『TCP と UDP の違いと使い分け』で具体化します。


8. まとめ

  • 確立:SYN→SYN/ACK→ACK の3ウェイで ISN を双方向同期。ISN はセキュリティのためランダム(RFC 6528)。1RTTの固定費が必ずかかる。
  • 状態機械:11状態。ss で読めば障害切り分けは観測になる。CLOSE-WAIT 滞留=自分の close() 漏れ、TIME-WAIT は能動クローズ側の正常な安全装置。
  • 再送:RTO(RTT実測から算出・指数バックオフ)と高速再送(重複ACK3回)。SACK で穴だけ回復。
  • 2つのウィンドウ:フロー制御(受信ウィンドウ=受信側を守る)と輻輳制御(cwnd=ネットワークを守る)は別物。AIMD ゆえロスでスループットが急落する。アルゴリズムは CUBIC(既定)/BBR。
  • 限界:HoLB と確立RTT。これを克服したのが QUIC/HTTP3。

TCP の仕組みは、ss -tin の数字を「読める」ようになって初めて武器になります。次は、では実際の案件で TCP と UDP のどちらを選ぶのか——その判断軸を扱います。


私(友田 陽大)は、決済・リアルタイム系のバックエンドで、TCP の状態遷移・再送・タイムアウト設計に踏み込んだ信頼性設計を手がけています。「スループットが頭打ち」「ECONNRESET/CLOSE-WAIT が増える」「再送が多くレイテンシが不安定」——こうした症状を、ss/tcpdump での観測から原因を特定し、プール化・タイムアウトバジェット・冪等性で根治します。お気軽にご相談ください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

ネットワーク/低レイヤ起因の本番障害の調査・信頼性設計を承ります

「ECONNRESET が止まらない」「TIME_WAIT でポートが枯れる」「再送が多くレイテンシが不安定」「決済・在庫で二重処理が怖い」「TCP/UDP/QUIC のどれを選ぶべきか」——こうしたTCP/IP・低レイヤ起因の課題を、ss/tcpdump での観測から原因を特定し、コネクションプール・タイムアウトバジェット・冪等性で根治します。モバイル回線のタイムアウトと再送を前提に冪等性で本番二重課金0件を達成した決済信頼性レイヤーの知見で、落ちない・追える・正しいバックエンドを設計します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい