Skip to content

第3章:TCP 深度剖析


元数据卡

字段
难度(硬核)
前置依赖Vol 4 第 2 章(传输层导论)、基本 socket 编程
关键词TCP首部、三次握手、四次挥手、TIME_WAIT、序列号、滑动窗口、拥塞控制、状态机
核心技能读懂 TCP 首部的每一 bit;手写握手/挥手时序图;理解流量与拥塞控制的"两条腿走路"

你在哪

"你面前是这条驿道最核心的环节——TCP(传送确认咒)。它保证你发出的每一道法术信函都能按顺序送到。三次法力握手、滑动卷轴窗口、魔力流量控制——TCP 让你的连接从不可靠变成可靠。"

TCP 是整个驿道的"可靠传送中枢"。前两章你掌握了驿道信标塔七层和三要素(封装/解封装/多路复用),现在要钻进 TCP 的咒语脑袋里——把它每个字段、每段对话、每次重传都搞清楚。

本章结束后,你不会"写"TCP(那是驿道守护法阵的事),但你会在魔力观测镜里读懂每一条传送流,会在排查"慢"问题时不瞎猜。


本章分层

  • 必读(TCP 基础):连接建立(三次握手)、可靠传输(序列号/确认号/重传)、连接关闭(四次挥手/TIME_WAIT)、用 ss 观察 TCP 状态
  • 进阶(TCP 进阶):滑动窗口/流量控制、拥塞控制总览(具体算法在 ch10 展开)、TCP 状态机
  • 深水区:Scapy 手工三次握手、窗口缩放因子、TCP 选项详解、快速恢复细节

本章不会要求你掌握

  • Scapy 伪造 TCP 包做端口扫描
  • 拥塞控制算法的公式推导(ch10 专题展开)
  • SACK/选择性确认的二进制格式

你的任务

本任务按TCP 基础TCP 进阶两步走:

TCP 基础(必读)

  1. 理解三次握手和四次挥手的完整过程
  2. 序列号与确认号:不是包编号,是字节流坐标
  3. sslsof 观察 TCP 的实时状态
  4. TCP 首部:20 字节看懂一段 TCP 对话

TCP 进阶(选读): 5. 滑动窗口与流量控制:发送方和接收方如何协调速度 6. 拥塞控制总览(具体算法详细展开见 ch10) 7. TCP 状态机:11 个状态的手绘迁移图

拥塞控制的具体算法(Cubic/BBR)在 第 10 章(拥塞控制专题) 中系统展开。本章只做总览性介绍。


遭遇战 → 获得技能

3.1 TCP 首部:20 字节读懂一段对话

你在魔力观测镜里看到一条 TCP 传送段,法术十六进制代码是:

00 50 0e 8c 0a 02 23 4c 00 00 00 00 a0 02 ff d7
00 00 00 00 02 04 05 b4

懵了?拆开它。

固定首部(20 字节)

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|         源端口             |          目的端口              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       序列号                                |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       确认号                                |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 偏移 | 保留 |N|C|E|U|A|P|R|S|F|         窗口大小           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|         校验和              |          紧急指针              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       选项(可变)                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                             数据                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

逐字段拆解:

字段长度意义
源端口 / 目的端口各 16bit多路复用/分解的基础。套接字标识 = (src_ip, src_port, dst_ip, dst_port, protocol)
序列号 (SEQ)32bit字节流坐标。这条段的第一个数据字节在整个字节流中的偏移量
确认号 (ACK)32bit期望收到的下一个字节的序列号。意味着之前所有字节已收到
数据偏移4bit首部长度(以 4 字节为单位)。最小值 5(20B),最大值 15(60B)
标志位9bitNS CWR ECE URG ACK PSH RST SYN FIN
窗口大小16bit接收方还能收多少字节(流量控制)
校验和16bit覆盖伪首部+TCP首部+数据
紧急指针16bit仅 URG=1 时有效

Python 解码练习 — 用 struct 解包 TCP 首部:

python
import struct

# 从 raw socket 收到的 tcp_data(IPv4 头部之后)
def parse_tcp_header(data: bytes) -> dict:
    # 前 20 字节是固定首部
    hdr = struct.unpack('!HHIIBBHHH', data[:20])
    src_port, dst_port = hdr[0], hdr[1]
    seq_num, ack_num = hdr[2], hdr[3]
    offset = (hdr[4] >> 4) * 4  # 数据偏移高位4bit ×4
    flags = hdr[5] & 0x3F       # 后6位:URG ACK PSH RST SYN FIN
    window = hdr[6]
    checksum, urg_ptr = hdr[7], hdr[8]
    return {
        'src_port': src_port, 'dst_port': dst_port,
        'seq': seq_num, 'ack': ack_num,
        'header_len': offset,
        'flags': {
            'URG': bool(flags & 0x20),
            'ACK': bool(flags & 0x10),
            'PSH': bool(flags & 0x08),
            'RST': bool(flags & 0x04),
            'SYN': bool(flags & 0x02),
            'FIN': bool(flags & 0x01),
        },
        'window': window,
    }

💬 给 Java 工程师 如果你写过 Netty 的 ByteBuf,TCP 首部就是最原始的那个 ChannelBuffer——没有高级 API,没有自动扩容,每个 bit 都靠自己算偏移。Java BigInteger 到 UDP 还算方便,但 TCP 的原始帧解析会逼你直面 bytebitmask 的底层功夫。

关键标志位速记

标志英文作用
SYNSynchronize连接建立——序列号同步
ACKAcknowledgment确认号有效(除了 SYN/FIN 段,几乎所有段都设 1)
FINFinish发送方结束发送
RSTReset连接异常中断(端口未监听 / 收到不可能的分段)
PSHPush接收方尽快交给应用层,别在缓冲区积压
URGUrgent紧急指针有效(实践中几乎不用)

🔼 以上内容为 TCP 基础(必读)


以下内容为 TCP 进阶(选读)


3.2 三次握手:谁先说话谁先 SYN

你没有记错:TCP 建立连接就是三次交换。

Client                              Server
  |                                    |
  |------ SYN, SEQ=1000 -------------->|   ① 客户端:我要连你
  |                                    |
  |<---- SYN, SEQ=5000, ACK=1001 ------|   ② 服务器:好,顺便确认收到①
  |                                    |
  |------ ACK, SEQ=1001, ACK=5001 --->|   ③ 客户端:收到你的 SYN,开始传数
  |                                    |
  |========= 数据流从此开始 ===========|

每一步在干什么

客户端状态服务器状态含义
SYN_SENTLISTEN客户端发送 SYN,初始序列号 seq=ISN_c
SYN_SENTSYN_RCVD服务器回复 SYN+ACK,seq=ISN_s, ack=ISN_c+1
ESTABLISHEDESTABLISHED客户端确认,seq=ISN_c+1, ack=ISN_s+1可以带数据

为什么是三次,不是两次,不是四次?

两次握手的致命问题:如果客户端 A 发了 SYN,网络延迟导致重传,旧的 SYN 到达服务器 B——两次握手会让 B 建立一条"死连接",白白等数据。三次握手的第三步确认了 A 真的活着,不是幽灵包。

模拟——用 Python 追踪三次握手

python
import socket
import sys

def trace_handshake(dest_host: str, dest_port: int = 80):
    """创建一个 socket 连接并用 strace/tshark 的思路打日志"""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(5)

    print(f"[CLIENT] 状态: CLOSED → SYN_SENT")
    print(f"[CLIENT] 发送 SYN (seq=ISN_c) → {dest_host}:{dest_port}")

    # 这一步触发内核发送 SYN
    sock.connect((dest_host, dest_port))

    # 连接建立后,内核已完成三次握手
    print(f"[CLIENT] 状态: ESTABLISHED")
    print(f"[CLIENT] 收到 SYN+ACK (seq=ISN_s, ack=ISN_c+1)")
    print(f"[CLIENT] 发送 ACK (seq=ISN_c+1, ack=ISN_s+1)")

    # 发送一个 HTTP 请求验证连接正常
    sock.sendall(b"GET / HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n")
    response = sock.recv(4096)
    print(f"[CLIENT] 收到数据: {len(response)} bytes")
    sock.close()

if __name__ == "__main__":
    trace_handshake("httpbin.org", 80)

💬 给 C++ 工程师socket()connect() 调用背后,内核的 TCP 实现(Linux 的 tcp_v4_connecttcp_send_synack)做了几十 KB 的工作。C++ 的 asio::async_connect 最终也落到同一个 connect(2) syscall。真正看懂三次握手,要用 Scapy 伪造包手工模拟,而不是依赖内核替你干完。比如:

深水区:下面的 Scapy 手工握手需要 root 权限,且涉及 raw socket 操作。普通读者只需理解其存在即可,不必实际运行。

python
# 用 Scapy 手工完成三次握手(不经过内核 TCP 栈)
from scapy.all import *
import random

def raw_handshake(src_port: int, dst_host: str, dst_port: int = 80):
    my_isn = random.randint(1000, 1000000)

    # ① 发送 SYN
    syn = IP(dst=dst_host) / TCP(sport=src_port, dport=dst_port,
                                  seq=my_isn, flags='S')
    syn_ack = sr1(syn, timeout=2)
    if not syn_ack or not syn_ack.haslayer(TCP):
        return None

    server_isn = syn_ack.seq
    # ② 回复 ACK
    ack = IP(dst=dst_host) / TCP(sport=src_port, dport=dst_port,
                                  seq=syn_ack.ack, ack=server_isn + 1, flags='A')
    send(ack)
    print(f"[RAW] 三次握手完成!ISN_c={my_isn}, ISN_s={server_isn}")
    return ack.seq, server_isn + 1

3.3 序列号与确认号:字节坐标,不是包编号

这是 TCP 最容易混淆的概念。

TCP 不按"包"计数。它按"字节"计数。

你的应用层写了 3000 字节,TCP 切成三段各 1000 字节发送:

段 ①: seq=1000, Len=1000     → 覆盖字节 1000-1999
段 ②: seq=2000, Len=1000     → 覆盖字节 2000-2999
段 ③: seq=3000, Len=1000     → 覆盖字节 3000-3999

接收方回复:ACK=4000 → 意思是我收到了字节 0-3999(1999 前推),请发 4000

正因如此,累积确认(cumulative ACK)才成立——段 ① 丢了但 ②③ 到了,接收方只能回复 ACK=1000,因为 1000 以前的字节都没齐。发送方据此知道要重传。

Python 验证——用 bytes 理解序列号坐标

python
import hashlib

def tcp_coordinates_demo():
    """把字节流想象成一个坐标轴"""
    stream = b"Hello, TCP World! This is a byte stream."

    # 模拟 TCP 分段
    segments = []
    base_seq = 10000
    pos = 0

    while pos < len(stream):
        chunk = stream[pos:pos + 10]  # 每段 10 字节
        segments.append({
            'seq': base_seq + pos,
            'len': len(chunk),
            'data': chunk,
            'covers': f"bytes {base_seq + pos}{base_seq + pos + len(chunk) - 1}"
        })
        pos += 10

    for s in segments:
        print(f"SEQ={s['seq']}, LEN={s['len']}, 覆盖: {s['covers']}")

    # 确认号含义演示
    received_up_to = segments[0]['seq'] + segments[0]['len']
    print(f"\n接收方回复 ACK={received_up_to}")
    print(f"含义: '我已收齐到字节 {received_up_to - 1},请从 {received_up_to} 开始发'")

tcp_coordinates_demo()

初始序列号(ISN):为什么不是 0?

历史原因 TCP 确实从 0 开始,现在用 随机 ISN(RFC 6298 / 当前基于时钟的算法)。

  • 防止旧连接的"幽灵段"被误认——两个不同的连接偶然用了相同的 IP:端口组合,ISN 随机保证段不混淆
  • 安全性——使伪造 RST 段更难(攻击者需要猜对序列号范围)

3.4 四次挥手:优雅地道别

TCP 是全双工的。任何一方都可以先发起关闭。

Client                              Server
  |                                    |
  |------ FIN, SEQ=4000 ------------->|   ① 客户端:我发完了
  |                                    |
  |<---- ACK, SEQ=8000, ACK=4001 -----|   ② 服务器:知道了(我还在发剩下的数据)
  |                                    |
  |       单向数据传输剩余...          |
  |                                    |
  |<---- FIN, SEQ=9000, ACK=4001 -----|   ③ 服务器:我也发完了
  |                                    |
  |------ ACK, SEQ=4001, ACK=9001 --->|   ④ 客户端:收到(开始 TIME_WAIT)
  |                                    |

TIME_WAIT:为什么等 2MSL?

客户端发送最后一个 ACK 后,进入 TIME_WAIT 状态,保留 2 × 最大分段寿命(Maximum Segment Lifetime,通常 30 秒~2 分钟,Linux 默认 60 秒)。

两个原因:

  1. 确保服务器收到最后的 ACK — 如果④丢失,服务器会重发③(FIN)。TIME_WAIT 让客户端能重新发送 ACK。2MSL 的"2"是因为一次 FIN 重传最多保底一次来回。

  2. 让连接的所有"幽灵段"在网络上死透 — 经过 2MSL 后,任何属于该连接的延迟段(网络抖动造成的滞后又出现)都已被网络丢弃或超时,不会污染下一个使用相同四元组的新连接。

Python 验证 TIME_WAIT

python
import socket
import time

def observe_timewait():
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(('127.0.0.1', 9999))
    srv.listen(1)

    # 客户端连接
    client = socket.socket()
    client.connect(('127.0.0.1', 9999))

    conn, _ = srv.accept()
    conn.close()  # 服务器先关闭 → 主动关闭方是 conn
    srv.close()
    client.close()

    # 现在 127.0.0.1:9999 可能处于 TIME_WAIT
    print("连接已关闭。此时服务端 socket 处于 TIME_WAIT")
    print("执行 `ss -tane | grep 9999` 可以看到 TIME_WAIT 状态")

observe_timewait()

💬 给 Java 工程师 你在 Netty 的 Channel.close() 之后发现端口被占用(Address already in use),就是 TIME_WAIT 在作祟。Java 的 ServerSocket 默认没有 SO_REUSEADDR;框架(Netty)会在 ServerBootstrap 里帮你设。如果你看到 WARN 日志 "bind failed: Address already in use",手动加上 .option(ChannelOption.SO_REUSEADDR, true)


🔼 以上为 TCP 基础(必读):三次握手、序列号/确认号、四次挥手/TIME_WAIT、用 ss/lsof 观察状态。你已经掌握了 TCP 的核心工作逻辑。


以下为 TCP 进阶(选读):Scapy 手工握手、滑动窗口/流量控制、拥塞控制总览、TCP 状态机。这些内容进一步加深理解,但不影响主线进阶。


3.5 滑动窗口与流量控制:速度匹配

接收方有一个接收缓冲区,应用层读得慢、网络层来得多,缓冲区就会满。流量控制就是防止发送方把接收方撑爆。

窗口通告

每个 TCP 段的首部都带一个 Window Size 字段(16bit,最大 65535)。

发送方发完之后还剩窗口 4096 bytes... 还能发
收到 ACK,窗口变为 8192 bytes... 又能发了
收到 ACK 但窗口是 0... 停!等接收方说"有空位了"

实际上接收方的通告窗口 = 接收缓冲区剩余大小。发送方的有用窗口(usable window)= min(接收方通告的窗口, 拥塞窗口)。

Python 模拟滑动窗口

python
import time
from dataclasses import dataclass, field
from typing import List

@dataclass
class SlidingWindow:
    capacity: int = 10000        # 接收方缓冲区
    in_flight: int = 0            # 已发未确认
    last_ack: int = 0
    next_seq: int = 0

    def can_send(self, size: int) -> bool:
        """检查发送窗口是否允许发送 size 字节"""
        return self.in_flight + size <= self.capacity

    def send(self, size: int):
        if not self.can_send(size):
            raise RuntimeError("窗口满了!等待 ACK")
        self.in_flight += size
        self.next_seq += size

    def receive_ack(self, ack: int):
        """收到 ACK,窗口滑动"""
        acked = ack - self.last_ack
        self.in_flight -= acked
        self.last_ack = ack
        print(f"[窗口] ACK={ack}, 飞行中={self.in_flight}, 可用={self.capacity - self.in_flight}")

# 演示
w = SlidingWindow()
for i in range(5):
    w.send(2000)  # 每次发 2000
    print(f"发送 2000, 飞行中={w.in_flight}, 可用={w.capacity - w.in_flight}")
# 第 6 次会失败(满)
try:
    w.send(2000)
except RuntimeError as e:
    print(f"[满] {e}")
    w.receive_ack(w.last_ack + 8000)  # 收到 8000 字节的 ACK
    w.send(2000)  # 又能发了

窗口缩放因子(Window Scaling)

窗口大小字段只有 16 位,最大 65535 字节——对今天动辄几 MB 的缓冲区远远不够。

TCP 在三次握手的 SYN/SYN-ACK 中协商 窗口缩放因子(Window Scale option),最多左移 14 位,将窗口上限推到 1GB(65535 × 2¹⁴)。

python
# 解码窗口缩放因子(出自 TCP 选项的第三、四字节)
def decode_window_scale(opt_bytes: bytes) -> int:
    """解析 WScale 选项:kind=3, len=3, shift=xx"""
    i = 0
    while i < len(opt_bytes):
        kind = opt_bytes[i]
        if kind == 0:       # EOL
            break
        elif kind == 1:     # NOP
            i += 1
            continue
        elif kind == 3:     # Window Scale
            shift = opt_bytes[i + 2]
            return shift
        else:
            length = opt_bytes[i + 1]
            i += length
    return 0  # 未协商,取默认

3.6 拥塞控制总览:路况问题,不是缓冲问题

本节是拥塞控制的 总览性介绍。具体算法详解(CUBIC、BBR、CoDel)和完整 Python 模拟在 第 10 章(拥塞控制专题) 中展开。本节只需要理解 拥塞控制与流量控制的区别 以及核心直觉。

流量控制和拥塞控制经常搞混。区别清晰直白:

流量控制(Flow Control)拥塞控制(Congestion Control)
管什么接收方缓冲区溢出中间路由器/链路满载丢包
谁驱动接收方上报窗口发送方自己评估
手段窗口通告慢启动、拥塞避免
丢包就是接收方读太慢?网络太拥挤! 赶紧减速

四种算法(TCP Reno,工业界最经典的变体):

① 慢启动(Slow Start)

不是真的"慢"。相反——它指数级增长。

cwnd = 1 (MSS)
每收到一个 ACK,cwnd += 1
→ 每个 RTT 翻倍

第 1 RTT: cwnd = 1
第 2 RTT: cwnd = 2
第 3 RTT: cwnd = 4
第 4 RTT: cwnd = 8
...
直到遇到 ssthresh(慢启动阈值)或丢包

直觉:发送方不知道网络的承载能力。它用慢启动快速探测上限——指数增长在几毫秒内就能从 1 跳到几千。

② 拥塞避免(Congestion Avoidance)

cwnd >= ssthresh 后切换为线性增长

每个 RTT 收到全部 ACK 后,cwnd += 1
→ 加性增(AIMD 的 AI 部分)

直觉:过了慢启动阈值,说明快接近网络瓶颈了。此刻改为缓慢试探,小幅增加,避免一下就撑爆。

③ 快速重传(Fast Retransmit)

发送方收到 三个重复 ACK(对同一序列号的确认),判定该段丢失,不等超时,立即重传。

发送: seq=1, 2, 3, 4, 5
情况: 段 1 收到,段 2 丢失,段 3/4/5 到达
接收方 ACK: 1, 1(repeated), 1(repeated), 1(repeated)
发送方: 收到三个重复 ACK → 快速重传段 2

④ 快速恢复(Fast Recovery)

快速重传后不回到慢启动,而是进入快速恢复:

ssthresh = cwnd / 2
cwnd = ssthresh + 3 (因为三个重复 ACK 已经确认了部分数据)
每收到一个重复 ACK, cwnd += 1
当收到新 ACK, cwnd = ssthresh → 进入拥塞避免

为什么比旧 Tahoe 好? 旧版 Tahoe 快速重传后直接 cwnd=1,从头慢启动——太激进。Reno 的快速恢复保留了之前的拥塞窗口经验值。

Python 模拟拥塞控制

python
import time

class TCPCongestionControl:
    def __init__(self, mss: int = 1460, initial_ssthresh: int = 65535):
        self.mss = mss
        self.cwnd = mss           # 拥塞窗口,1 个 MSS 起
        self.ssthresh = initial_ssthresh  # 慢启动阈值
        self.phase = "SLOW_START"
        self.rtt_count = 0
        self.rtt_acked = 0
        self.dupack_count = 0

    def on_ack(self, is_dupack: bool = False):
        """每一个 ACK 到达时调用"""
        if is_dupack:
            self.dupack_count += 1
            if self.dupack_count == 3:
                self._fast_retransmit_recovery()
            else:
                self.cwnd += self.mss  # 快速恢复阶段的窗口膨胀
            return

        # 正常 ACK,重置重复计数
        self.dupack_count = 0

        if self.phase == "SLOW_START":
            self.cwnd += self.mss  # 每个 ACK cwnd + 1 MSS
            if self.cwnd >= self.ssthresh:
                self.phase = "CONGESTION_AVOIDANCE"

        elif self.phase == "CONGESTION_AVOIDANCE":
            # 简化:每个 RTT 加 1 个 MSS
            self.rtt_acked += self.mss
            if self.cwnd <= self.rtt_acked:
                self.cwnd += self.mss
                self.rtt_acked = 0

        elif self.phase == "FAST_RECOVERY":
            # 收到非重复 ACK → 离开快速恢复
            self.phase = "CONGESTION_AVOIDANCE"

    def _fast_retransmit_recovery(self):
        """快速重传 + 快速恢复"""
        self.ssthresh = max(self.cwnd // 2, 2 * self.mss)
        self.cwnd = self.ssthresh + 3 * self.mss
        self.phase = "FAST_RECOVERY"
        print(f"[快速恢复] ssthresh={self.ssthresh}, cwnd={self.cwnd}")

    def on_loss(self):
        """超时丢包 → 回到慢启动"""
        self.ssthresh = max(self.cwnd // 2, 2 * self.mss)
        self.cwnd = self.mss
        self.phase = "SLOW_START"
        print(f"[超时丢包] 回到慢启动, ssthresh={self.ssthresh}")

    def __str__(self):
        return f"cwnd={self.cwnd}, ssthresh={self.ssthresh}, phase={self.phase}"

# 模拟:慢启动 → 拥塞避免 → 丢包
tcp = TCPCongestionControl()
print(f"初始: {tcp}")
for _ in range(10):
    tcp.on_ack()
print(f"慢启动 10 ACK 后: {tcp}")
tcp.on_loss()
for _ in range(5):
    tcp.on_ack()
print(f"丢包后: {tcp}")

现代拥塞控制图谱

算法核心思想目标场景
TCP RenoAIMD + Fast Recovery传统有线网络,丢包即拥塞
TCP BIC/CUBIC二分搜索窗口高带宽时延网络(Linux 默认)
TCP BBR基于带宽和 RTT 建模,不把丢包当拥塞信号长肥网络、无线(有丢包但不拥塞)

对 CUBIC 的一句话:CUBIC 窗口增长是三次函数 cwnd = C·(t-K)³ + Wmax,丢包后快速恢复到丢包前的窗口,然后平稳增长探测。Linux 内核 2.6.19+ 默认就是 CUBIC(写这篇时仍是)。


3.7 TCP 状态机:11 个状态的生死流转

                      ┌──────────────────────┐
                      │       CLOSED         │
                      └──────┬───────────────┘
                             │ 被动打开(Server)
                             │ socket, bind, listen

                      ┌──────────────┐
                      │    LISTEN    │
                      └──────┬───────┘
                SYN 收到     │     SYN 发送

        ┌────────────────────┼────────────────────┐
        ▼                    ▼                    ▼
┌──────────────┐    ┌──────────────────┐     ┌────────────┐
│  SYN_RCVD    │◄───│     SYN_SENT     │     │(既是主动也  │
└──────┬───────┘    └──────────────────┘     │ 是被动方) │
       │ 收到 ACK                            └────────────┘

┌──────────────┐
│ ESTABLISHED  │  ←── 读写数据
└──────┬───────┘

       ├────── 应用关闭(主动关闭方)


┌──────────────┐    FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED
│  FIN_WAIT_1  │
└──────┬───────┘

       │ 被动关闭方

┌──────────────┐
│  CLOSE_WAIT  │  ←── 等待应用关闭
└──────┬───────┘
       │ 应用关闭

┌──────────────┐
│  LAST_ACK    │  → 收到 ACK → CLOSED
└──────────────┘

        其他过渡状态:
        ┌──────────────┐     ┌──────────────┐
        │  CLOSING     │     │  FIN_WAIT_2  │
        └──────────────┘     └──────────────┘
        (双方同时关闭时的中间态)

完整 11 状态速查表

#状态谁进入?说明
1CLOSED初始/终端无连接
2LISTEN服务器等待连接请求
3SYN_SENT客户端已发 SYN,等 SYN+ACK
4SYN_RCVD服务器已收 SYN,已发 SYN+ACK
5ESTABLISHED双方连接建立完毕
6FIN_WAIT_1主动关闭方已发 FIN,等 ACK 或 FIN
7FIN_WAIT_2主动关闭方已收 FIN 的 ACK,等 FIN
8CLOSE_WAIT被动关闭方收到 FIN,等应用关闭
9CLOSING双方同时关闭双方都发了 FIN 但互等 ACK
10LAST_ACK被动关闭方应用关闭,发了 FIN,等 ACK
11TIME_WAIT主动关闭方等 2MSL

Python 模拟状态机

python
class TCPStateMachine:
    """简化的 TCP 状态机——追踪状态迁移"""
    def __init__(self):
        self.state = "CLOSED"
        self.states_log = [self.state]

    def event(self, e: str):
        transitions = {
            # (当前状态, 事件) → 下一状态
            ("CLOSED", "PASSIVE_OPEN"): "LISTEN",
            ("CLOSED", "ACTIVE_OPEN"): "SYN_SENT",
            ("SYN_SENT", "SYN_ACK"): "ESTABLISHED",
            ("LISTEN", "SYN"): "SYN_RCVD",
            ("SYN_RCVD", "ACK"): "ESTABLISHED",
            ("ESTABLISHED", "CLOSE"): "FIN_WAIT_1",
            ("FIN_WAIT_1", "ACK"): "FIN_WAIT_2",
            ("FIN_WAIT_1", "FIN"): "CLOSING",
            ("FIN_WAIT_1", "FIN_ACK"): "TIME_WAIT",
            ("FIN_WAIT_2", "FIN"): "TIME_WAIT",
            ("CLOSING", "ACK"): "TIME_WAIT",
            ("TIME_WAIT", "TIMEOUT_2MSL"): "CLOSED",
            ("ESTABLISHED", "RECV_FIN"): "CLOSE_WAIT",
            ("CLOSE_WAIT", "CLOSE"): "LAST_ACK",
            ("LAST_ACK", "ACK"): "CLOSED",
        }
        next_state = transitions.get((self.state, e))
        if not next_state:
            raise ValueError(f"在 {self.state} 状态遇到事件 '{e}' 是非法迁移")
        self.state = next_state
        self.states_log.append(self.state)
        return self.state

    def trace(self, events: list):
        for e in events:
            old = self.state
            try:
                self.event(e)
                print(f"  {old:>15} --[{e}]--> {self.state}")
            except ValueError as ex:
                print(f"  ❌ {ex}")

# 模拟一次完整连接和关闭
print("=== 正常三次握手 + 四次挥手 ===")
tcp = TCPStateMachine()
tcp.trace([
    "PASSIVE_OPEN",  # 服务器 LISTEN
    "ACTIVE_OPEN",   # 客户端 SYN_SENT → 同时有 LISTEN/SYN_SENT 两股状态流
])
# 注意:这只是简化的单流模拟。实际 TCP 两端各有一个状态机。

小技巧:用 sslsof 观察状态

bash
# 查看所有 TCP 连接的状态
ss -tane

# 只看 ESTABLISHED
ss -tane state established

# 找 TIME_WAIT 连接(最多的就是短连接服务)
ss -tane state time-wait | wc -l

# 查看 socket 的进程归属
lsof -iTCP -sTCP:LISTEN
lsof -iTCP -sTCP:ESTABLISHED

常见陷阱

用 Scapy 实现一个简单的 TCP 端口扫描器(半开扫描,只发 SYN,不收 SYN-ACK 就认为端口关闭):

python
#!/usr/bin/env python3
"""简易 SYN 扫描器——只发 SYN,分析 SYN-ACK/RST 响应"""
from scapy.all import *
import sys
from concurrent.futures import ThreadPoolExecutor

RST = 0x04
SYN_ACK = 0x12

def scan_port(dst_host: str, port: int, timeout: float = 1.0) -> tuple:
    """扫描单个端口,返回 (port, status)"""
    syn = IP(dst=dst_host) / TCP(dport=port, flags='S')
    resp = sr1(syn, timeout=timeout, verbose=False)

    if resp is None:
        return (port, "filtered")  # 没响应 = 防火墙过滤

    if resp.haslayer(TCP):
        flags = resp.getlayer(TCP).flags
        if flags == SYN_ACK:
            # 发送 RST 关闭连接,礼貌点
            rst = IP(dst=dst_host) / TCP(dport=port, flags='R',
                                         seq=resp.ack)
            send(rst, verbose=False)
            return (port, "open")
        elif flags == RST:
            return (port, "closed")

    return (port, "unknown")

def scan_range(dst_host: str, ports: range, workers: int = 20):
    print(f"扫描 {dst_host} 端口范围 {ports.start}-{ports.stop-1}")
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = [pool.submit(scan_port, dst_host, p) for p in ports]
        for f in futures:
            port, status = f.result()
            if status in ("open", "filtered"):
                print(f"  Port {port}: {status}")

if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "localhost"
    scan_range(target, range(1, 1025))

伦理提醒:扫描不是你自己的机器时,必须先获得授权。未经许可的端口扫描在很多司法管辖区属于非法。


通关挑战

基础题(30 分)

  1. TCP 首部填空:一个 TCP 段的 hex dump 以 0x00 50 0E 8C 开头。源端口是什么?目的端口是什么?
  2. 三次握手时序图:画出从 192.168.1.10:40000 到 example.com:443 的三次握手,包括 SEQ/ACK 数字。假设客户端 ISN = 1000,服务器 ISN = 20000。
  3. 为什么是 2MSL?:用自己的话向一个初学者解释为什么 TIME_WAIT 需要等 2 倍 MSL,而不是 1 倍。

进阶题(40 分)

  1. 滑动窗口计算:设接收窗口大小为 32KB。发送方在收到 ACK 前已发出 20KB。一秒后收到 ACK 确认了 12KB,窗口通告为 18KB。计算新的可用窗口。
  2. 拥塞控制曲线:假设 MSS = 1460 字节,cwnd 初始 1 MSS,ssthresh = 16 MSS。画出前 8 个 RTT 的 cwnd 变化(假设无丢包)。

挑战题(30 分)

  1. 写出正确的四元组去重防幽灵连逻辑:用 C 或 Python 伪代码,写出 TCP 状态机在处理 SYN / RST / FIN 时如何根据 (src_ip, src_port, dst_ip, dst_port, seq) 过滤过期连接。

验收标准

  • [ ] 能用 struct.unpack 解析一段已知的 TCP 首部 hex dump
  • [ ] 能在 wireshark/tshark 中定位三次握手的三个包
  • [ ] 能解释为什么四次挥手比三次握手多一次
  • [ ] 能写出一段模拟滑动窗口的 Python 代码(发送/确认/满窗口)
  • [ ] 能区分流量控制和拥塞控制的职责
  • [ ] 能口述慢启动和拥塞避免的核心差异
  • [ ] 能说出 TCP 的 11 个状态名称,并画出核心状态迁移

常见卡点

卡点原因解法
序列号和确认号搞混以为按"包"编号记住:每个字节都是一个坐标,ACK 是"下个坐标"
TIME_WAIT 为什么 2MSL觉得一个 MSL 就够了 ACK 单向传输服务器重发 FIN 需要一次往返(2MSL)
滑动窗口不等于拥塞窗口混淆了两权分离实际窗口 = min(flow_wnd, cong_wnd)
快速恢复时 cwnd 为何 +3觉得不合理三个重复 ACK 意味着三个段已出网络,容量释放了

现在不需要理解

  • TCP 多路复用 vs 单流:MPTCP(Multipath TCP)——一条连接跨多块网卡。等你遇到跨 DC 冗余传输再回来看。
  • SACK(Selective ACK):TCP Reno 的累积确认只能知道谁丢了,不能知道谁没丢。SACK 允许接收方明确告诉发送方"我收到了 1000-1999 和 3000-3999,中间丢了 2000-2999"。等你用 tcpdump 看到 SACK_PERM 选项再说。
  • 零窗口探测(Zero Window Probe):接收方窗口为 0 时,发送方定期发送 1 字节段探测窗口是否恢复。正常网络极少触发。

旅人笔记

TCP 是我认为计算机网络里最漂亮的协议。

一开始我看到首部格式就昏了——20 字节、一堆 flag、校验和、紧急指针……觉得就是个笨重的标准。

但真正让我爱上 TCP 的,是滑动窗口的设计。它用区区 16bit 的窗口大小,协调了传输双方的速率,还通过缩放因子优雅地解决了"16bit 不够用"这个问题——这不是打补丁,这是协议设计的进化能力。

后来写了一个玩具 TCP/IP 栈(只实现 100 行状态机的那种),才真正理解三次握手不是"三步握手"那么简单——它在那三包里协商了 MSS、窗口缩放、时间戳、SACK——一切都藏在 TCP Options 里。

别怕。看两遍状态机图,用 Python 抓几次包,它就变成你的肌肉记忆了。下一个章节你会看到 TCP 的"应用层之子"——HTTP——如何站在这 20 字节的肩膀上统治了互联网。


→ 下一站预告

第 4 章:HTTP 与 Web 服务器

从 TCP 连接上长出的应用层协议——你会写你自己的迷你 Web 服务器(Python 实现),并理解请求/响应格式、缓存策略、Cookie、CORS 这些日常与 HTTP 打交道的必备知识。

Built with VitePress | Software Systems Atlas