第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 基础(必读):
- 理解三次握手和四次挥手的完整过程
- 序列号与确认号:不是包编号,是字节流坐标
- 用
ss和lsof观察 TCP 的实时状态 - 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) |
| 标志位 | 9bit | NS CWR ECE URG ACK PSH RST SYN FIN |
| 窗口大小 | 16bit | 接收方还能收多少字节(流量控制) |
| 校验和 | 16bit | 覆盖伪首部+TCP首部+数据 |
| 紧急指针 | 16bit | 仅 URG=1 时有效 |
Python 解码练习 — 用
struct解包 TCP 首部:pythonimport 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 都靠自己算偏移。JavaBigInteger到 UDP 还算方便,但 TCP 的原始帧解析会逼你直面byte与bitmask的底层功夫。
关键标志位速记
| 标志 | 英文 | 作用 |
|---|---|---|
| SYN | Synchronize | 连接建立——序列号同步 |
| ACK | Acknowledgment | 确认号有效(除了 SYN/FIN 段,几乎所有段都设 1) |
| FIN | Finish | 发送方结束发送 |
| RST | Reset | 连接异常中断(端口未监听 / 收到不可能的分段) |
| PSH | Push | 接收方尽快交给应用层,别在缓冲区积压 |
| URG | Urgent | 紧急指针有效(实践中几乎不用) |
🔼 以上内容为 TCP 基础(必读)
以下内容为 TCP 进阶(选读)
3.2 三次握手:谁先说话谁先 SYN
你没有记错:TCP 建立连接就是三次交换。
Client Server
| |
|------ SYN, SEQ=1000 -------------->| ① 客户端:我要连你
| |
|<---- SYN, SEQ=5000, ACK=1001 ------| ② 服务器:好,顺便确认收到①
| |
|------ ACK, SEQ=1001, ACK=5001 --->| ③ 客户端:收到你的 SYN,开始传数
| |
|========= 数据流从此开始 ===========|每一步在干什么
| 步 | 客户端状态 | 服务器状态 | 含义 |
|---|---|---|---|
| ① | SYN_SENT | LISTEN | 客户端发送 SYN,初始序列号 seq=ISN_c |
| ② | SYN_SENT | SYN_RCVD | 服务器回复 SYN+ACK,seq=ISN_s, ack=ISN_c+1 |
| ③ | ESTABLISHED | ESTABLISHED | 客户端确认,seq=ISN_c+1, ack=ISN_s+1。可以带数据 |
为什么是三次,不是两次,不是四次?
两次握手的致命问题:如果客户端 A 发了 SYN,网络延迟导致重传,旧的 SYN 到达服务器 B——两次握手会让 B 建立一条"死连接",白白等数据。三次握手的第三步确认了 A 真的活着,不是幽灵包。
模拟——用 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_connect→tcp_send_synack)做了几十 KB 的工作。C++ 的asio::async_connect最终也落到同一个connect(2)syscall。真正看懂三次握手,要用 Scapy 伪造包手工模拟,而不是依赖内核替你干完。比如:
深水区:下面的 Scapy 手工握手需要 root 权限,且涉及 raw socket 操作。普通读者只需理解其存在即可,不必实际运行。
# 用 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 + 13.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 理解序列号坐标:
pythonimport 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 秒)。
两个原因:
确保服务器收到最后的 ACK — 如果④丢失,服务器会重发③(FIN)。TIME_WAIT 让客户端能重新发送 ACK。2MSL 的"2"是因为一次 FIN 重传最多保底一次来回。
让连接的所有"幽灵段"在网络上死透 — 经过 2MSL 后,任何属于该连接的延迟段(网络抖动造成的滞后又出现)都已被网络丢弃或超时,不会污染下一个使用相同四元组的新连接。
Python 验证 TIME_WAIT:
pythonimport 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 模拟滑动窗口:
pythonimport 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¹⁴)。
# 解码窗口缩放因子(出自 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 模拟拥塞控制:
pythonimport 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 Reno | AIMD + 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 状态速查表
| # | 状态 | 谁进入? | 说明 |
|---|---|---|---|
| 1 | CLOSED | 初始/终端 | 无连接 |
| 2 | LISTEN | 服务器 | 等待连接请求 |
| 3 | SYN_SENT | 客户端 | 已发 SYN,等 SYN+ACK |
| 4 | SYN_RCVD | 服务器 | 已收 SYN,已发 SYN+ACK |
| 5 | ESTABLISHED | 双方 | 连接建立完毕 |
| 6 | FIN_WAIT_1 | 主动关闭方 | 已发 FIN,等 ACK 或 FIN |
| 7 | FIN_WAIT_2 | 主动关闭方 | 已收 FIN 的 ACK,等 FIN |
| 8 | CLOSE_WAIT | 被动关闭方 | 收到 FIN,等应用关闭 |
| 9 | CLOSING | 双方同时关闭 | 双方都发了 FIN 但互等 ACK |
| 10 | LAST_ACK | 被动关闭方 | 应用关闭,发了 FIN,等 ACK |
| 11 | TIME_WAIT | 主动关闭方 | 等 2MSL |
Python 模拟状态机:
pythonclass 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 两端各有一个状态机。
小技巧:用 ss 和 lsof 观察状态
# 查看所有 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 就认为端口关闭):
#!/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 分)
- TCP 首部填空:一个 TCP 段的 hex dump 以
0x00 50 0E 8C开头。源端口是什么?目的端口是什么? - 三次握手时序图:画出从 192.168.1.10:40000 到 example.com:443 的三次握手,包括 SEQ/ACK 数字。假设客户端 ISN = 1000,服务器 ISN = 20000。
- 为什么是 2MSL?:用自己的话向一个初学者解释为什么 TIME_WAIT 需要等 2 倍 MSL,而不是 1 倍。
进阶题(40 分)
- 滑动窗口计算:设接收窗口大小为 32KB。发送方在收到 ACK 前已发出 20KB。一秒后收到 ACK 确认了 12KB,窗口通告为 18KB。计算新的可用窗口。
- 拥塞控制曲线:假设 MSS = 1460 字节,cwnd 初始 1 MSS,ssthresh = 16 MSS。画出前 8 个 RTT 的 cwnd 变化(假设无丢包)。
挑战题(30 分)
- 写出正确的四元组去重防幽灵连逻辑:用 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 打交道的必备知识。