TCP ディープダイブ 基礎
クライアントが curl https://api.example.com/user/42 でちょうど Enter を打った。 前の primer は NIC を離れるパケットを見た;こちらは L4 内に留まり、 接続自体が何をしているかに答える —— api.example.com:443 への 3-way ハンドシェイク、 スライディングバイトウィンドウ、最後の FIN ダンス。 5 セクションで全体像を組み立てる:3-way ハンドシェイク(対話的なパケットレベルウォークスルー付き);信頼性 —— シーケンス番号、ACK、再送、SACK、RTO;フローと輻輳制御 —— rwnd vs cwnd、スロースタート、CUBIC vs BBR、バッファブロート;ライフサイクル —— TIME_WAIT、ハーフクローズ、Nagle、キープアライブ; 最後にクイックリファレンス。
3-way ハンドシェイク —— なぜ 3 で 2 ではないか
curl がちょうど Enter を打った。カーネルがソケットを作成し、ランダムな初期シーケンス番号を選び、api.example.com:443 に SYN を送って、1 RTT 待つ。3 パケット後、 両側は次の 100 万バイトの接続を支配する seq 番号、ウィンドウサイズ、オプションについて合意する。
あらゆる TCP 接続は 3 つのパケットで始まる —— SYN、SYN-ACK、ACK。 なぜ 3 で 2 でないかというと、両側が同期した初期シーケンス番号(ISN)を必要とし、 各側の ISN は信頼される前に相手によって確認される必要があるから。 2 つのパケットは 1 方向しか同期できない。
クライアント サーバ |---- SYN seq=X ----------------------->| (1) クライアントが ISN=X を選ぶ |<--- SYN-ACK seq=Y, ack=X+1 -----------| (2) サーバが ISN=Y を選び、 | | X を見たことを確認 |---- ACK ack=Y+1 --------------------->| (3) クライアントが Y を確認 | | | ESTABLISHED ←-- 両側 --→ ESTABLISHED
コストはアプリケーションデータが流れる前にちょうど 1 RTT。LAN では 200 µs; 海を越えると 100–200 ms。この 1 RTT こそ、本番 HTTP クライアントで コネクションプーリングが非常に重要な理由 —— 新規 TCP ハンドシェイクごとに 最初のペイロードバイトの前にラウンドトリップ 1 つを払い、TLS がさらに 1 つ (TLS 1.2 では 2 つ)を加える。
rwnd(空きバッファ)を通告し、 送信側は観測した損失に基づいて成長または崩壊する別の cwnd を脳内に持つ。 接続を切るとき、片方だけが TIME_WAIT に入る —— 通常は先にclose() した側。SYN フラッドと SYN クッキー
ステップ 1 の後、サーバは「この 4-tuple からのハーフオープン接続がある、 私の選んだ ISN=Y、クライアント ACK を待っている」と覚えておく必要がある。 その状態は SYN キューに住む(カーネル sysctlnet.ipv4.tcp_max_syn_backlog、デフォルト 1024–4096)。 偽装した送信元 IP から SYN を撃ちまくり、ハンドシェイクを完了しない攻撃者は、 そのキューをあふれさせ、正規クライアントへのサービスを拒否する。古典的な SYN フラッド DoS。
防御は SYN クッキー(net.ipv4.tcp_syncookies = 1):SYN キューが満杯のとき、 カーネルは状態の割り当てを停止する。代わりに、ISN=Y を (4-tuple、MSS、タイムスタンプ、シークレット)の暗号学的ハッシュとして選ぶ。 クライアントの ACK が ack = Y + 1 で到着すると、カーネルは 4-tuple から Y を再導出し、ハッシュを確認し、その場で接続状態を再構築する。 ハーフ接続ごとのメモリは不要。
SYN キューと accept キュー
3 番目の ACK が到着すると接続は完全に確立される;カーネルはそれを SYN キューからaccept キューに移す。ユーザコードの accept() は そのキューから取り出す。サイズは min(backlog, net.core.somaxconn)、backlog は listen() への引数で、somaxconn は 最近のカーネルでデフォルト 4096(長年 128 だった —— 数え切れない本番ストールの原因)。 オーバーフローは 3 番目の ACK を黙って落とし、クライアントは RTO 後に再試行する。 症状:1、3、7 秒のランダムな接続レイテンシ。
TCP Fast Open
TFO(RFC 7413)は同じサーバへの後続接続で 1 RTT コストをスキップする。 サーバは最初の接続中にクライアントに不透明な TFO クッキーを渡す; 次の接続で、クライアントは SYN を同じパケット内のデータとクッキーと共に送る。 サーバはクッキーを検証し、アプリケーションはハンドシェイク完了前にリクエストを受け取る —— TCP 接続上の 0-RTT データ。Linux で広くサポート (net.ipv4.tcp_fastopen)されているが、公衆インターネットでの TFO 利用は稀; ミドルボックスが非標準の SYN+data を落とすし、HTTP/2 接続再利用 + HTTP/3 0-RTT が ほとんどのユースケースをカバーする。
要点。「TCP は両側が互いの初期シーケンス番号を同期して確認する必要があるため 3 パケットを要する —— 2 つでは 1 方向しかカバーできない。コストはデータ前に 1 RTT。 SYN キューはハーフオープンを保持する(SYN クッキーで防御);accept キューは accept() を 待っている完全な ESTABLISHED 接続を保持する。後者のオーバーフローは 3 番目の ACK が 黙って落とされるため、神秘的なクライアント 1/3/7 秒レイテンシを引き起こす。」
信頼性 —— シーケンス番号、ACK、再送、SACK
IP は best-effort データグラムサービスを提供する:パケットは失われ、 並べ替えられ、複製され、または破損し得る。TCP は各バイトに番号を付け、 受信側が確認しないものを再送することで、それを信頼性のある順序付きバイトストリームに変える。
TCP 接続のすべてのバイトにシーケンス番号がある。 TCP ヘッダの 32 ビットフィールドはバイト(パケットではない)を数え、 4 GB 後にラップアラウンドする —— だからこそ高帯域長寿命接続はPAWS(Protection Against Wrapped Sequence)タイムスタンプオプションで 曖昧さを解消する。
受信側は ACK 番号 =「私が期待する次のバイト」を返す。 ACK は累積:ack=1001 は「バイト 1000 までのすべてを受信したが、 その後は何も受信していない」を意味する。ストリーム中央の失われたセグメントは、 後続のすべての ACK が依然として「私はバイト 1001 を待っている」と言うことを意味し、 送信側からは重複 ACKとして見える。
2 つの損失検出方法
TCP は次の 2 つのメカニズムのいずれかで再送を発火する:
- タイムアウト(RTO)。セグメントの ACK が再送タイムアウト内に到着しなければ、 再送する。RTO は動的:
RTO = SRTT + max(G, 4 · RTTVAR)、SRTTは平滑化 RTT、RTTVARは平滑化 RTT 分散(RFC 6298)。 Linux は 200 ms を下限とし 120 s を上限とする;ほとんどのディストロのデフォルト天井は 60 s。 各タイムアウト後、RTO は倍増する(指数バックオフ)。連続 6 回のタイムアウトで接続は中止される (net.ipv4.tcp_retries2 = 15がデータフェーズ用、約 15 分に相当)。 - 高速再送。連続する 3 つの重複 ACK は送信側に「受信側は後のセグメントを 受け取っているが、まだこれが欠けている」と伝えるので、RTO を待たずに即座に再送する。 元々は Reno;NewReno で拡張され、今は SACK ベースの損失回復。
SACK —— 選択的確認応答
純粋な累積 ACK は、バーストで 1 つの損失があるときに帯域幅を浪費する —— 送信側は欠けているセグメントだけを再送すべきか、それともその後のすべてを再送すべきか分からない。SACK(RFC 2018、SYN オプションでネゴシエート)は受信側に 「1001–2000 と 3001–8000 を持っている;2001–3000 が欠けている」と報告させる。 送信側は正確に欠けている範囲を再送し、そこで停止する。SACK なしでは、 損失の多い接続は複数 RTT の回復ウィンドウにカスケードする。
DSACK(RFC 2883、duplicate SACK)は反対方向に進む: 受信側に「1001–2000 を2 度目に受け取ったばかり」と報告させる。 送信側は自分の再送が偽だったことを知る —— 通常は元のものが失われたのではなく ただ遅かっただけ —— そして将来の偽陽性で cwnd をカットすることについて、 それほど積極的でなくなることができる。
TLP —— Tail Loss Probe
現代の Linux(~3.10+)は末尾損失ケースのために TLP を出荷している: アプリケーションが小さなバーストを送信し、最後のセグメントが失われると、 重複 ACK をトリガする後続セグメントがない —— RTO タイムアウトだけが救済可能で、 数百 ms のコスト。TLP は約 2 · SRTT(RTO よりはるかに高速)後に 単一のプローブを発火し、高速 ACK を得るか、重複 ACK 経由でトリガする。 ほとんどのセグメントが末尾にあるリクエスト/レスポンスワークロードで劇的に改善。
損失回復トレース
send seq=1、1001、2001、3001、4001、5001 (5 セグメント、各 1 KB) recv ack=1001 (最初を取得) recv ack=2001 (2 番目を取得) recv ack=2001 [SACK 3001-5001] (DUP-ACK 1;3 番目が失われた) recv ack=2001 [SACK 3001-6001] (DUP-ACK 2) recv ack=2001 [SACK 3001-6001] (DUP-ACK 3 → 高速再送!) fast-retransmit seq=2001 (欠けているものだけ) recv ack=6001 (すべて追いついた) cwnd 半減(Reno) —— ここから congestion-avoidance フェーズ
要点。「TCP は各バイトに番号を付ける(32 ビット、PAWS でラップ保護)、 ACK は累積(次期待バイト)。損失は RTO タイムアウト(遅い、数十から数百 ms) または 3 つの重複 ACK(高速再送)で検出される。SACK は穴を報告するので欠けている範囲だけが 再送される;DSACK は偽の再送をフラグする;TLP は末尾損失をプローブするので、 落とされた最後のセグメントは完全な RTO のコストを払わない。」
フロー + 輻輳制御 —— rwnd、cwnd、Reno → CUBIC → BBR
2 つの異なる問題が同じメカニズムを共有する:スライディングウィンドウ。フロー制御は受信側が溺れるのを防ぐ。輻輳制御はネットワークが溺れるのを防ぐ。 送信側は両方に従い、より厳しい方によってペースされる。
rwnd —— 受信側フロー制御
すべての ACK ヘッダは受信ウィンドウ(rwnd)を通告する:「今これだけの空きバッファ空間がある」。 送信側は受信側が通告した以上の未確認バイトをワイヤに乗せないと約束する。 生の 16 ビットフィールドは 64 KB で頭打ち —— 現代の帯域には笑える数字 —— だからウィンドウスケーリングオプション(RFC 7323、SYN でネゴシエート)が 受信側に通告値を最大 214 倍させ、有効ウィンドウを 1 GB まで拡張する。
アプリケーションがしばらく read() を呼ばないと、カーネルバッファが満杯になり、 通告 rwnd がゼロに縮み、送信側が停止する。これは良い(無損失バックプレッシャー)、 アプリケーションが一時的にストールし接続がアイドルで座っている間まで —— 送信側は rwnd が再開するまでゼロウィンドウプローブで探る。 年間病理:遅いコンシューマがエラーを 1 つも出さずにパイプライン全体をストールできる。
cwnd —— 輻輳制御
送信側はまた輻輳ウィンドウ(cwnd)も維持する。 これはネットワーク(受信側ではなく)がどれだけ吸収できるかの内部推定値。 進行中のバイトは min(rwnd, cwnd) でキャップされる。 異なる輻輳制御アルゴリズムは cwnd がどう進化するかだけが異なる。
古典的な Reno / NewReno / CUBIC ファミリは 3 つのフェーズに従う:
- スロースタート。cwnd は
tcp_init_cwnd(Linux デフォルト 10 セグメント × 1460 B MSS ≈ 14 KB —— RFC 6928)で始まり、 RTT ごとに倍増する(ACK ごとに 1 MSS)。指数成長、cwnd がssthreshに 達するか損失が観測されるまで。 - 輻輳回避。cwnd は線形に成長、RTT ごとに +1 MSS。AIMD:加法増加、乗法減少。
- 損失反応。Reno は cwnd を半分にする。CUBIC は損失発生時の cwnd (
W_max)を記憶し、3 次曲線に沿ってそれに向かって戻る —— ピーク付近で平ら、ピークから離れて加速。結果:Reno の純粋な AIMD より高 BDP リンクで回復が速い。
Linux は 2.6.19(2006)以来 CUBIC がデフォルト。 確認:sysctl net.ipv4.tcp_congestion_control;同じもので選ぶ。
BBR —— モデルベース、損失非依存
Google は 2016 年に BBR(Bottleneck Bandwidth and Round-trip propagation time)を根本的に異なるアプローチとして導入した。 CUBIC と Reno は損失ベース:パケットが落ちるまでボトルネックキューを満たし、 その落ちを信号として扱う。10 ms LAN ではうまく機能する; 200 ms の海を越えるリンクで深いミドルボックスバッファがあると、 キューが満杯になってパケットが落ちる頃には、レイテンシは爆発しており (「バッファブロート」)、CUBIC は大幅に後退しなければならない。
BBR は入ってくる ACK のレートからボトルネック帯域幅(BtlBw)を、 観測された最小 RTT から往復伝播遅延(RTprop)を継続的に推定する。 送信レートはペースされる —— パケットはバーストではなく時間的に滑らかに広がる —— そして BDP = BtlBw × RTprop 付近に保たれる。 損失のある long-fat ネットワーク(大西洋横断、モバイル)では、BBR は日常的に CUBIC の 2–10× のスループットを提供する。きれいな LAN では同点。
BDP —— 封筒の裏の計算
帯域幅遅延積はリンクを飽和させるために「進行中」にする必要があるデータ量を教えてくれる:
BDP = 帯域幅 × RTT 1 Gbps × 100 ms = 12.5 MB (米国大陸横断) 10 Gbps × 200 ms = 250 MB (大西洋横断 10G) 100 Mbps × 30 ms = 375 KB (住宅ブロードバンド)
送信側が BDP より少ないバイトを進行中に保つと、帯域幅をテーブルに残す —— これが「RTT 制限」。理由:rwnd が小さすぎる(ウィンドウスケーリングを忘れた、 デフォルト net.ipv4.tcp_rmem が低すぎる)、cwnd が小さすぎる (スロースタート中、または損失からの回復中)、またはアプリケーションが書き込みの間にストールする。net.ipv4.tcp_rmem と tcp_wmem のチューニングは long-fat 接続が配信不足の場合の最初の寄港地。
バッファブロートと qdisc
スイッチ/ルータと消費者向け Wi-Fi AP の長い FIFO キューは、パケットを落とさずに吸収し、 TCP から輻輳を隠し、RTT を秒単位に膨張させる。修正:BBR(損失ではなくペースして RTT を見る)と Linux 上の fq_codel /cake qdisc(長いキューから積極的に落として TCP に正直な損失信号を与える)。sysctl net.core.default_qdisc が qdisc を選ぶ; 現代のディストロは fq_codel をデフォルトとする。
要点。「2 つのウィンドウが送信側を制御する:rwnd(受信側が空きバッファを教える; フロー制御)と cwnd(私がネットワーク容量を推定する;輻輳制御)。進行中は min(rwnd, cwnd) で キャップされる。CUBIC は損失ベースで主流;BBR はモデルベースで、BtlBw × RTprop でペースし、 ミドルボックスバッファを満たさないことで損失のある long-fat リンクで勝つ。 BDP を使って帯域制限か RTT 制限かを知る。」
ライフサイクル —— TIME_WAIT、FIN、Nagle、キープアライブ
接続を切断することは、開くことよりも興味深いと判明する。FIN ダンス、有名な TIME_WAIT、 遅延 ACK と戦う Nagle のアルゴリズム、そしてキープアライブ —— これらすべてが本番コードが最も頻繁に誤動作する場所。
FIN ダンスと TIME_WAIT
TCP は双方向なので、閉じることも双方向。各側は書き込みが終わったらFIN を送信する;各側は相手の FIN を ACK する。 最も一般的なケースで合計 4 パケット(FIN、ACK、FIN、ACK)、 サーバがクライアントの FIN の ACK に自分の FIN をピギーバックする際は 3 つに圧縮されることが多い。
クライアント サーバ |---- FIN ---------------------------->| |<--- ACK -----------------------------| クライアント → FIN_WAIT_2 |<--- FIN -----------------------------| |---- ACK ---------------------------->| サーバ → CLOSED | | | TIME_WAIT (4-tuple を 2·MSL 保持) | | ↓ 60 秒後 | | CLOSED |
先に close() を呼んだ側が TIME_WAIT に入り、 4-tuple (local_ip, local_port, remote_ip, remote_port) を2 · MSL —— Maximum Segment Lifetime の 2 倍、Linux ではデフォルト 60 秒 (net.ipv4.tcp_fin_timeout)保持する。2 つの理由:
- 最後の ACK が失われた場合の重複 FIN を吸収する。TIME_WAIT なしでは、 同じ 4-tuple での次の接続が死んだ接続からの迷子 FIN を見て、自分自身を切断する。
- 古い接続からの遅延セグメントが、同じ 4-tuple を再利用する新しい接続と衝突しないようにする (「古い重複セグメント」問題)。
本番では、同じクライアントが多くの短い HTTP/1.0 風接続を行うときに重要: TIME_WAIT エントリが蓄積し、4-tuple 空間が枯渇し、connect() がEADDRNOTAVAIL で失敗し始める。確認:ss -tan state time-wait | wc -l; 正当な緩和策:
- コネクションプーリング。症状ではなく治療。1 つの接続を多くのリクエストに再利用; HTTP/1.1 キープアライブ、HTTP/2 多重化、gRPC プールチャネル。
- SO_REUSEADDR。TIME_WAIT エントリがあるポートにリスニングソケットをバインドできる。
- SO_REUSEPORT。同じポートに複数のソケットを許可 —— カーネルがリスナープロセス間で入ってくる接続をロードバランスする。 シャード化されたサーバの一般的なパターン(Nginx、Envoy ワーカープロセス)。
- net.ipv4.tcp_tw_reuse = 1。安全な場合(PAWS タイムスタンプが 新しい SYN が本当に新しいことを証明する)、新しい送信接続が TIME_WAIT 状態の ローカル 4-tuple を再利用できるようにする。送信のみ、タイムスタンプ付きのみ;本番で安全。
使ってはいけない:net.ipv4.tcp_tw_recycle。 Linux 4.12 で削除された、NAT の背後のあらゆるクライアントを壊したから —— 複数のクライアントがパブリック IP を共有し、彼らのタイムスタンプは NAT 全体で単調ではなく、 サーバは黙って SYN を落とす。サーバで有効化されているのを見たらバグとして扱う。
ハーフクローズと shutdown(SHUT_WR)
close(fd) は両方向を閉じる。shutdown(fd, SHUT_WR)は FIN を送るが読み側は開いたままにする、これで「送信し終えた」とシグナルした後でも レスポンスを受け取ることができる。HTTP/1.1 は歴史的に、レスポンスを読む前にリクエストボディの 終わりをマークするためにこれを使った;gRPC はクライアントストリーミング RPC でこれを行う。
Nagle vs 遅延 ACK
両方有効のときひどく相互作用する 2 つのレイテンシ対スループット最適化:
- Nagle のアルゴリズム(デフォルト有効): 送信側は MSS バイトがキューイングされるか前のセグメントが ACK されるまで小さな書き込みを保持する。 「tinygrams」問題を削減(1 バイトのペイロードごとに 40 バイトのヘッダ)。
setsockopt(TCP_NODELAY, 1)で無効化。 - 遅延 ACK(デフォルト有効): 受信側はレスポンスデータにピギーバックすることを期待して、純粋な ACK の送信を ~40 ms 遅延させる。 典型的なリクエスト/レスポンスストリームで ack トラフィックを半減させる。
組み合わせると、教科書的な病理を引き起こす:クライアントが write(small_header)次に write(small_body) を行う。1 つ目が出る;Nagle が ACK を待って 2 つ目を保持する。 受信側はピギーバックするためのより多くのデータを待って ACK を 40 ms 遅延する。 結果:すべてのリクエストに 40 ms のレイテンシが加わる。修正は TCP_NODELAY か —— よりよく —— ユーザ空間で書き込みを結合する(1 回の writev() 呼び出し、 または send() の前にバッファに完全なリクエストを構築する)。
TCP キープアライブ
SO_KEEPALIVE はカーネルにアイドル接続でキープアライブプローブを送信するよう伝え、 死んだピアを検出できるようにする。デフォルトは役に立たない:net.ipv4.tcp_keepalive_time = 7200(最初のプローブまで 2 時間のアイドル!)、tcp_keepalive_intvl = 75、tcp_keepalive_probes = 9 —— 死んだピアを検出するのに合計 ~2 時間 11 分。ネットワーク向けの何かについては、 ソケットごとに TCP_KEEPIDLE / TCP_KEEPINTVL / TCP_KEEPCNT を まともな値(60 秒アイドル、10 秒間隔、3 プローブ → 90 秒で失敗)に設定する。 gRPC は同じ理由で独自のアプリケーションレベルキープアライブを出荷する。
接続ドレイン
ロードバランサがバックエンドをローテーションから外すとき、ドレインすべき: 新しい接続の送信を停止し、既存のものを完了させる(または期限後にクリーンな FIN で閉じる)。 間違った方法はバックエンドを強引に落とすこと —— 開いている接続は RST を取得し、 進行中のリクエストは失敗する。正しい方法:ヘルスチェックで不健康とシグナルし、 キープアライブの期限切れを待ち、FIN を送る。Kubernetes preStop フックは まさにこのために存在する。
要点。「先に閉じた方が TIME_WAIT に 2·MSL(デフォルト 60 秒)入って 落伍者を吸収する。TIME_WAIT 枯渇の治療法はコネクションプーリングで、tcp_tw_recycle ではない (削除済みで NAT を壊した)。Nagle + 遅延 ACK は教科書的な 40 ms レイテンシ病理; Nagle を無効にせず書き込みを結合して修正する。デフォルト TCP キープアライブは 2 時間のアイドル —— 重要なものにはソケットごとに TCP_KEEPIDLE を設定する。」
クイックリファレンス
冷たく説明できる価値のある 6 つの核心問題と、コードレビューで一目で見抜くべき 5 つの赤旗。
なぜ TCP は 3-way ハンドシェイクが必要(2 ではなく)?
両側が同期した初期シーケンス番号(ISN)を必要とし、各側の ISN は信頼される前に 相手によって確認される必要がある。2 つのパケットは 1 方向しか同期しない (クライアントの ISN がサーバに到達するが、サーバの ISN は決して確認されない)。 3 番目のパケットがループを閉じる。コスト:アプリケーションデータの前に 1 RTT、 その上に TLS がさらに 1–2 を加える。
rwnd と cwnd の違いは?
rwnd は受信側の空きバッファ空間の通告で、 すべての ACK ヘッダに含まれる —— フロー制御。cwnd は送信側のネットワーク容量の内部推定で、ワイヤ上には決して現れない —— 輻輳制御。進行中の未確認バイトは min(rwnd, cwnd) で制限される。 どちらかが小さすぎるとスループットのボトルネックになる。
CUBIC vs BBR —— それぞれどこで勝つ?
CUBIC は損失ベース:パケットが落ちるまでボトルネックキューを満たし、3 次曲線で後退する。 きれいな低レイテンシ LAN/データセンターリンクで勝つ。BBR はモデルベース: BtlBw と RTprop を推定し、BtlBw × RTprop でペースし、損失を輻輳信号として無視する。 損失のある long-fat リンク(海洋横断、モバイル、バッファブロートのある住宅ブロードバンド)で勝つ。 そこでは CUBIC は巨大なミドルボックスバッファを満たすか、ランダムなパケット損失で崩壊する。 Google は YouTube に BBR をデプロイして 2–14× のスループット向上を報告した; きれいな LAN では同点。
TIME_WAIT とは何で、本番でどう緩和する?
先に close() を呼んだ側が 4-tuple を 2·MSL(Linux デフォルト 60 秒、net.ipv4.tcp_fin_timeout)保持し、遅延再送を吸収し、古いセグメントが 再利用された 4-tuple と衝突するのを防ぐ。枯渇の症状:ss -tan state time-wait | wc -l が巨大、connect() が EADDRNOTAVAIL で失敗。修正:コネクションプーリング (HTTP/1.1 キープアライブ、HTTP/2、gRPC)、送信用にnet.ipv4.tcp_tw_reuse = 1。tcp_tw_recycle は絶対に使わない —— カーネル 4.12 で削除された、NAT を壊したから。
Nagle + 遅延 ACK の病理とは?
Nagle は MSS バイトまたは前のセグメントの ACK を待って小さな書き込みを保持する。 遅延 ACK はデータにピギーバックすることを期待して、純粋な ACK を ~40 ms 遅延させる。 アプリケーションが連続した小さな書き込み(ヘッダ次にボディ)を行うとき、Nagle は 2 つ目を保持し、 受信側は ACK を遅延し、すべてのリクエストに ~40 ms のレイテンシが加わる。修正: ユーザ空間で書き込みを結合する(writev() またはバッファされたsend())、単に TCP_NODELAY を設定するのではなく —— それは症状を修正するが tinygrams 問題をテーブルに残す。
接続が帯域制限か RTT 制限かをどう測る?
BDP = 帯域幅 × RTT を計算する。送信側が BDP より少ない進行中バイトを保つなら、 RTT 制限(小さな rwnd、小さな cwnd、または書き込みの間にアプリケーションがストールする)。 BDP バイトを進行中に保ち損失 / 後退した cwnd を観測するなら、帯域制限。 確認:ss -ti(cwnd、rwnd、rtt、loss を表示); プロファイル:tcpdump + tcptrace または iperf3。
コードレビューの赤旗
- 反射的に Nagle を無効化。
setsockopt(TCP_NODELAY, 1)の後に リクエストごとに 10 個の小さな書き込み。1 つの 40 ms ストールをワイヤ上の 10 個の 40 バイトヘッダ tinygrams と交換した。代わりに書き込みを結合する (writev()、または最初にバッファを構築)。 - 本番 sysctl で tcp_tw_recycle = 1。カーネル 4.12 で削除された;NAT の背後のあらゆるクライアントを壊した。バグとして扱う。 正しいノブは送信接続用の
tcp_tw_reuse。 - timeout=0 の SO_LINGER の誤用。クローズ時にクリーンな FIN ではなく RST を強制する。ピアは接続リセットを見て、進行中のデータは失われる。 テストシャットダウンパスでだけこれを行い、実トラフィックには絶対に行わない。
- 1 バイトメッセージをピンポンするアプリケーションプロトコル。各リクエストは 1 RTT のコスト;
スループット ≤ ペイロード / RTT、 帯域幅に関係なく。50 ms RTT はパイプがいくら太くても接続あたり 20 req/s を意味する。 パイプライン / 多重化するかレイテンシ予算を受け入れる。 - コネクションプーリングなし —— リクエストごとに新規 TCP+TLS。TCP に 1 RTT + TLS に 1–2 RTT = 最初のペイロードバイトまで 2–3 RTT。 100 ms 大陸横断リンク上ではリクエストあたり 300 ms のオーバーヘッド。 HTTP/1.1 キープアライブ、HTTP/2、または HTTP/3 がこれを修正する; これなしで独自のクライアントを書くとパフォーマンスを床に残すことになる。