TCP/IP プロトコルスタック

このページの目次

send の境界はそのまま recv の境界にはならず、ACK が相手側によるビジネスロジックのコミットを意味するわけではありません。TCP は順序付きで信頼性の高いバイトストリームを提供しますが、Linux の実装を理解する際には、まずシーケンス番号、ウィンドウ、回復、およびクローズ方向を分けて考える必要があります。

まだヘッダー、オプション、プロトコルレベルでのウィンドウ計算に慣れていない場合は、ネットワークシリーズの TCP を先に読んでください。本稿では Linux の接続キュー、送受信パス、およびソケットの動作に沿って展開します。2つの記事で共通するシーケンス番号とウィンドウのモデルが、プロトコルと実装の橋渡しとなります。

参照目標読書位置
ルーティングとハンドシェイクキュー第1~2節
バイト列、BDP、およびパケット損失第3~5節
制御アルゴリズムとファストパス第6~7節
ハーフクローズ実験第8節

1. IP ルーティングはローカル配信か転送かを決定する

IPv4 では、基本ヘッダーの受信検証後、PREROUTING などの処理を経てルーティング判断が行われます。ローカル宛てのパケットは LOCAL_IN/INPUT を経由して TCP、UDP などに渡され、転送パスは FORWARD と出口処理を経ます。ローカルからの送信には初期ルーティング情報が必要であり、OUTPUT でのアドレス/マークの変更は再ルーティングをトリガーする可能性があり、その後 POSTROUTING に入ります。

IPv6 には独自の入力およびルーティング実装があります。基本ヘッダーには IPv4 のヘッダーチェックサムがなく、拡張ヘッダーの処理や断片化ルールも異なります。IPv6 ルーターは途中のノードでソースエンドポイントに代わって断片化を行わないため、Path MTU Discovery などのメカニズムは正しく処理されなければならず、IPv4 の関数チェーンをそのまま汎用プロトコルスタックとして適用することはできません。

2. 3ウェイハンドシェイクで確立されるのは接続状態

LISTEN 状態のソケットは LISTEN を維持し、ハンドシェイクで接続オブジェクトが確立される クライアント サーバーの LISTEN および接続状態 SYN: seq = x SYN + ACK: seq = y, ack = x + 1 ACK: ack = y + 1 accept は確立済み接続キューから接続を取得します。クライアントの SYN をトリガーする役割はありません。

通常のアクティブオープンでは SYN_SENT を経て、正当な応答によって ESTABLISHED に進みます。サーバー側の LISTEN ソケットは引き続き LISTEN を維持し、半接続(half-open connections)は通常、より軽量な request socket で追跡され、ハンドシェイク完了後にのみ accept 可能な接続キューに入ります。SYN cookie や TCP Fast Open などは、具体的なリソースとタイミングを変更します。

SYN キューと accept キューは異なる段階のものであり、listen(backlog) はすべての半接続に対して単一の総数を直接設定するものではありません。accept の戻りが遅れると、ハンドシェイクが完了したキューが詰まることがあり、SYN 関連のパラメータを拡大するだけでは解決できません。

ハンドシェイクでは初期シーケンス番号とオプションが交換され、SYN 自体が1つのシーケンス番号を消費します。TCP の確立が成功したことは、トランスポート層の接続が利用可能であることを示すだけで、アプリケーションが認証済みであることや、ビジネスサービスが準備完了であることを意味するわけではありません。プロトコルの仕様については RFC 9293 を参照してください。

3. シーケンス番号、ACK、および受信キュー

受信範囲受信状態累積 ACK
[1000,2000) 順序通り受信済み次のバイトは 2000 であるべき2000
[3000,4000) が先に到着中間にギャップがある依然として 2000
ギャップが後に埋められる連続した受信位置を進めることができる連続範囲に応じて進む

TCP のシーケンス番号はバイト単位でカウントされます。SACK を使用すると、離散化した受信済み範囲を別途報告でき、送信者が再送をより正確に選択できます。累積 ACK は、埋められていないギャップをスキップしません。

相手側がバイトを確認して受信プロトコル状態に進んだとしても、それが相手側アプリケーションによって既に読み込まれたことを意味するわけではありません。さらに、データベースへの書き込みがコミットされたことを意味するわけでもありません。send の戻り値も、ACK の受信を意味しません。アプリケーションがメッセージの境界や完了確認を必要とする場合は、長さ、区切り文字、リクエスト ID、およびレスポンスプロトコルを定義する必要があります。

Linux は順序データを受信キューに配置し、順序不同のデータは対応する構造体に保存します。アプリケーションが読み込む際に、意味に従って順序付きのバイトストリームを配信します。PSH は信頼性の高いメッセージ境界を構成するものではなく、recv(4096) は 4096 バイト未満を返す場合もあれば、複数の send のデータを一度にマージする場合もあります。

任意のバイトセグメントをクリックして順序不同および重複到達をシミュレートしてください。累積 ACK は連続したプレフィックス沿ってのみ進行します。アプリケーションの読み取りは別途記録され、受信側の確認をアプリケーションが既に消費したものと見なしてはいけません。

図を準備しています
データが後から届いたのに、ACK はなぜギャップで止まっているのか?

累積 ACK は次に連続して受信されていないバイトを指します。SACK は後方の離散範囲を記述します。プロトコルの確認はアプリケーションの消費を意味せず、既知の範囲が重複して受信されても ACK は再度進みません。

4. フロー制御と輻輳制御は別の尺度

受信ウィンドウ rwnd は、受信側がさらにどの程度のデータを受信できるかを示します。輻輳ウィンドウ cwnd は、送信側がネットワーク上で許容できる在途データ量の制限を示します。両者の単位は実装や表示において異なる場合があります。例えば、cwnd は通常 MSS セグメント数でカウントされるため、比較する前に換算が必要です。

大まかに言えば、送信可能な新しいデータ量は min(cwnd_bytes, rwnd_bytes) - bytes_in_flight によって制限され、さらにパシング、アプリケーションにデータがあるかどうか、再送、および実装の詳細も考慮する必要があります。ウィンドウは送信速度の直接単位ではありません。

ウィンドウは新規送信の制約であり、既に在途のデータを消去するものではありません。以下では、cwnd、rwnd、および在途量を同じバイト尺度で比較し、いずれかの上限を変更してから送信を試みます。

3つの量はすべてバイト単位で計測されます。ここでは現在の送信枠を比較しており、輻輳アルゴリズム、SACK パイプライン推定、または実際のウィンドウ更新プロトコルはシミュレートしていません。

図を準備しています
2つのウィンドウの尺度が、同じ在途量を減らす

新しいデータの枠は max(0, min(cwnd, rwnd) − flight) で計算されます。ウィンドウが縮小すると、flight が新しい上限を超え、新規送信が停止し、確認応答による枠の解放を待ちます。

在途量の計算:帯域幅が十分でも、ウィンドウがスループットを制限する可能性がある

条件または計算結果
リンク 1 Gbit/s、RTT 20 msBDP は約 2.5 MB(2.38 MiB)
有効な在途量が 64 KiB のみこのパスの BDP よりもはるかに小さい
64 KiB / 0.02 s約 3.28 MB/s の簡易スループット上限

ここでは他の制限は無視しています。この計算は、ウィンドウが不足するとスループットが制限されることを示しており、バッファを拡大すればすべてのパフォーマンス問題が解決されるわけではないことを意味しています。

バッファを拡大することで特定の制限を解消できますが、パケット損失、誤った MTU、輻輳、または低速なアプリケーションは修復できません。受信側の自動調整にも上限、メモリ圧力、およびユーザー設定などの制約があります。SO_RCVBUF などのパラメータを設定すると、自動調整の動作にも影響を与えるため、「高 BDP では常にバッファをチェックする必要がない」とは限りません。TCP sysctl を参照してください。

5. パケット損失の検出にはタイムアウトだけではない

重複 ACK、SACK、および時間ベースの RACK などの情報は、RTO の期限前に損失を検出できます。RTO は依然として回復の重要なフォールバックメカニズムであり、平滑化された RTT と変動の推定に基づいて計算され、タイムアウト後にバックオフします。

RFC 6298 は RTO の計算フレームワークを提供しています。具体的な Linux の最小値、パス、および設定可能な項目は対象バージョンに照らして確認する必要があり、RFC の推奨値をすべての Linux タイマーの固定値として見なしてはいけません。再送後の RTT サンプルには曖昧さがあり、推定では任意の ACK を直近の送信に直接割り当てることはできません。

タイマーは目的別に分類される:ウィンドウ、アイドル、および損失

ゼロウィンドウ探査は受信側ウィンドウの閉塞に対処し、keepalive は長期アイドル接続の到達可能性をチェックします。これらは通常の損失再送タイマーの別名ではありません。TCP の再試行タイムアウトでは、相手側アプリケーションが以前のデータを既に処理していないことを保証できません。ビジネスロジックでの再試行には独自の重複排除セマンティクスが必要です。

6. CUBIC と BBR:制御目標を比較し、宣伝文句に惑わされない

CUBIC のウィンドウ増加は、最後の輻輳前のウィンドウ値を中心に設計されており、立方関数は旧ピーク値に近づくと増加率が変化し、遠ざかると増加率が異なる挙動を示します。TCP フレンドリーな動作などの詳細も含まれており、1つの立方関数でアルゴリズム全体を代替することはできません。RFC 9438 は CUBIC の仕様であり、BBR の仕様ではありません。

BBR は配信レートと伝搬 RTT などの推定に基づいてパスモデルを構築し、パシングと在途制限によって送信を制御します。異なる BBR バージョンでは、パケット損失、ECN、公平性、および探査動作が異なります。「完全にパケット損失を考慮しない」や「あらゆる輻輳リンクで CUBIC より優れている」と一概に言うことはできません。実際、カーネルモジュール名が自動的に特定の論文/パッチバージョンの採用を証明するわけではありません。

cat /proc/sys/net/ipv4/tcp_congestion_control
cat /proc/sys/net/ipv4/tcp_available_congestion_control
ss -tin

システムのデフォルトアルゴリズムは新規接続に影響を与えます。既存のソケットには独自の設定があることもあります。比較する際は、パス、RTT、ストリーム数、受信ウィンドウ、およびボトルネックキューを固定し、スループット、レイテンシ、および共存ストリームの動作を観察します。

7. ファストパスはプロトコルの正当性を迂回するものではない

tcp_rcv_established() には、一般的に確立済み接続のパケットに対する最適化処理が含まれています。ヘッダー予測などの条件が満たされると、一般的な状態処理のオーバーヘッドを削減できます。順序不同、異常なフラグ、ウィンドウ変更、オプションなどは、他の分岐へ処理を遷移させます。

関数名から「90% のパケットが固定で数十命令しか実行されない」と推論することはできません。チェックサムオフロード、GRO、ロック競合、ソケットバックログ、メモリ割り当て、およびウェイクアップはコストに影響を与えます。ソケットがユーザーコンテキストによって占有されている場合、パケットはバックログにキューに入れられ、適切なパスによって後処理されます。

MSG_DONTWAIT は、今回の読み書きで待機するかどうかを制御するだけであり、カーネルがパケットを受信して直接ユーザーバッファに書き込む条件として見なしてはいけません。ソースコードは net/ipv4/tcp_input.c、tcp.c、および tcp_output.c と合わせて読むべきです。

8. 片方向をクローズしても、接続全体がクローズされるわけではない

状況典型的な状態/動作説明
自発的に FIN を送信FIN_WAIT1、FIN が確認されると FIN_WAIT2自端の送信方向がクローズされ、受信は継続可能
相手から FIN を受信CLOSE_WAIT相手はデータを送信しないが、ローカルアプリケーションは送信可能
受動的側がその後クローズLAST_ACK自端の FIN が確認されるのを待機
能動側が双方のクローズを完了TIME_WAIT遅延パケットの処理や最終 ACK の再送など、プロトコル要件のため
双方が交差して FIN を送信CLOSING を経る場合あり毎回厳密に直列化されるわけではなく、同じパスを辿るとは限らない

TIME_WAIT と FIN_WAIT2 は異なる状態であり、tcp_fin_timeout が汎用の TIME_WAIT 期間スイッチであると見なしてはいけません。CLOSE_WAIT が長期にわたって蓄積する場合、アプリケーションのクローズ動作を確認する必要があります。大量の TIME_WAIT が発生した場合、その数だけでリークを判断することはできません。

実行可能なハーフクローズの例

以下では、ローカルループバックと一時的なポートのみを使用します。クライアントは書き込み後に送信方向をクローズし、サーバーは EOF を検知した後でも、受信したバイト数を返すことができます。

import socket
import threading

errors = []
with socket.socket() as listener:
    listener.bind(('127.0.0.1', 0))
    listener.listen(1)
    listener.settimeout(3)

    def serve():
        try:
            conn, _ = listener.accept()
            with conn:
                conn.settimeout(3)
                size = 0
                while data := conn.recv(1024):
                    size += len(data)
                conn.sendall(str(size).encode('ascii'))
        except Exception as exc:
            errors.append(exc)

    worker = threading.Thread(target=serve)
    worker.start()
    try:
        with socket.create_connection(listener.getsockname(), timeout=3) as client:
            client.sendall(b'hello')
            client.shutdown(socket.SHUT_WR)
            parts = []
            while data := client.recv(1024):
                parts.append(data)
            assert b''.join(parts) == b'5'
    finally:
        worker.join(timeout=4)
    assert not worker.is_alive()
    if errors:
        raise errors[0]
print('half-close: reply received after SHUT_WR')

このプロトコルは、リクエストの境界として EOF を意図的に使用しています。長期間の接続再利用では、通常、長さまたはフレーム形式を別途設定する必要があります。これは TCP のハーフクローズセマンティクスを示すものであり、ネットワーク断絶、再送、またはアプリケーションの冪等性はシミュレートしていません。