「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 6298、SACK = 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 と、観念上の CLOSED。ss や netstat で見えるあの状態名の出典がこれです。
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まで無駄に再送しかねません。SACK(RFC 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 -tinのretransとrttを見て、ロス率が高ければ経路(無線/トンネル/過負荷なミドルボックス)を、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つ挙げます。これを知ると次の技術選定が見通せます。
- ヘッドオブラインブロッキング(HoLB):TCP は「順序通りの単一バイトストリーム」なので、1つのセグメントが失われると、その後ろの(無関係な)データもアプリへ渡せず待たされる。HTTP/2 が1本のTCP上で多重化しても、TCP層のロスで全ストリームが止まるのはこのため。
- 接続確立RTTの固定費:3ウェイ+TLSで毎回複数RTT。高遅延ほど効く。
QUIC(RFC 9000、2021年)は、UDP の上に「ストリーム独立の信頼性+暗号化+輻輳制御」を再実装し、ストリームごとに独立して回復することで HoLB を解消し、接続確立も0〜1RTTに短縮しました。HTTP/3(RFC 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 での観測から原因を特定し、プール化・タイムアウトバジェット・冪等性で根治します。お気軽にご相談ください。