跳到内容

第 4 卷 / 第 3 章 / 第 2 课

3.2 TCP 序号、ACK 与流量控制 ​

TCP 的工作,是让接收程序最终仍读到连续的 hello world。它给字节编号,用 ACK 报告连续收到的位置,暂存乱序数据,并在判断丢失后重传。关键边界是:TCP 编号的是字节,不是应用消息,也不是抓包工具显示的第几个 packet。

本课沿着一条已建立连接的数据面前进。连接建立与关闭见3.1 TCP 连接生命周期;网络拥塞时发送方怎样调整 cwnd,留到第 10 章。

1. TCP 是可靠字节流 ​

TCP 向应用提供双向、按序的 byte stream。对一次成功维持的连接,它会:

  • 用 checksum 检测许多传输 bit error,但不能发现所有错误;
  • 用 sequence number 标识数据位置;
  • 确认已经连续收到的范围;
  • 对丢失的数据重传;
  • 在交给应用前恢复顺序;
  • 依据接收方可用空间限制发送量。

它不会保留应用 send 的消息边界,也不会证明对端业务已经处理数据。进程崩溃、连接被 reset 或网络长期中断时,未确认数据仍可能失败。需要“订单已经入库”这类保证时,应用协议必须返回自己的 acknowledgment,并处理重试与幂等。

2. 一段 TCP header 告诉你什么 ​

不含 option 时,TCP header 最短为 20 bytes:

text
  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
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |          source port          |       destination port        |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |                        sequence number                        |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |                    acknowledgment number                     |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 | Data  |       |C|E|U|A|P|R|S|F|                               |
 | Offset| Rsrvd |W|C|R|C|S|S|Y|I|            Window             |
 |       |       |R|E|G|K|H|T|N|N|                               |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |           checksum            |         urgent pointer        |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |                  options, then application data ...           |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

排查数据传输时,先看这些字段:

字段作用
source/destination port与 IP 地址一起标识 endpoint
sequence number通常是本 segment 首字节的序号;带 SYN 时是 SYN 的序号,首个数据字节为它加 1
acknowledgment numberACK 有效时,期望收到对方的下一个 sequence number
data offsetTCP header 长度,以 32-bit word 为单位
flagsSYN、ACK、FIN、RST、PSH 以及 ECN/urgent 相关状态
window本端通告给对方的接收窗口,可能应用 window scale
checksum覆盖 TCP header、payload 和 IP pseudo-header 的错误检测值
optionsMSS、window scale、SACK、timestamp 等协商或反馈信息

header 图是阅读方向,不是 wire capture 的全部。IPv4/IPv6 header、TCP option、offload 与隧道都会改变你实际看到的布局。

上图按 RFC 9293 基础头展示 4 位 data offset、4 位 reserved 和 8 个 control bit,未把历史实验的 NS 位当成固定字段。Data offset 从 5 到 15,对应 20 至 60 字节头部;扩展字段的解释应再查相应协商规范。

3. Sequence number 是字节坐标 ​

假设抓包工具把某方向的第一个数据 byte 显示为相对序号 0。应用写入 11 bytes,TCP 可能分成:

text
segment A: seq=0, len=5   -> bytes [0, 5)   "hello"
segment B: seq=5, len=1   -> bytes [5, 6)   " "
segment C: seq=6, len=5   -> bytes [6, 11)  "world"

切成三个 segment 只是这次发送的结果。重传、MSS、offload 和拥塞状态变化后,边界都可能不同;接收应用只看到同一条 11-byte stream。

wire 上的 sequence number 是 32-bit 无符号值,按模 2^32 运算。抓包工具常显示 relative sequence number,方便人阅读。SYN 和 FIN 各占用一个 sequence number,即使它们不携带普通应用 byte。

python
MOD = 1 << 32
start, size = MOD - 4, 8
next_wire_sequence = (start + size) % MOD
assert next_wire_sequence == 4
print(start, next_wire_sequence)  # 4294967292 4

回绕后数值 4 在这里反而比 4294967292 更新。实际 TCP 的序号比较要按模空间和窗口约束处理,不能直接用普通整数 <;下面的小模型因此明确排除回绕。抓包默认相对序号常把 SYN 设为 0、首个数据字节设为 1,本课为了算例方便另以数据起点为 0。

4. ACK 报告“下一个缺口” ​

TCP ACK 通常是 cumulative acknowledgment。若接收方发送 ack=6,含义是序号 6 之前的连续字节已经收到,下一步期待 6。

现在让中间的空格先到:

text
收到 seq=5, len=1  -> ack=0   # [0,5) 仍缺失
收到 seq=6, len=5  -> ack=0   # 后续可暂存,但缺口仍在 0
收到 seq=0, len=5  -> ack=11  # 缺口补齐,连续范围推进

诊断工具里多个相同 ACK 不等于“接收方什么都没收到”。它可能已经缓存后续 bytes,只是 cumulative ACK 越不过最早缺口。

ACK 也不等于对端应用读取或提交了数据。它确认的是 TCP 层接收状态。

5. 用一个小模型看乱序重组 ​

下面是可直接运行的 Python 3 示例。它只模拟不重叠、不会回绕的 segment,用来观察 cumulative ACK;它不是 TCP 实现。

python
class Receiver:
    def __init__(self):
        self.next_sequence = 0
        self.pending = {}
        self.delivered = bytearray()

    def receive(self, sequence, payload):
        if sequence < 0 or not payload:
            raise ValueError("need nonnegative sequence and nonempty payload")
        end = sequence + len(payload)
        if end <= self.next_sequence:
            return self.next_sequence
        if sequence < self.next_sequence:
            raise ValueError("partial overlap is outside this model")
        if sequence in self.pending and self.pending[sequence] == payload:
            return self.next_sequence
        for start, chunk in self.pending.items():
            if sequence < start + len(chunk) and start < end:
                raise ValueError("overlapping ranges are outside this model")

        self.pending[sequence] = payload
        while self.next_sequence in self.pending:
            chunk = self.pending.pop(self.next_sequence)
            self.delivered.extend(chunk)
            self.next_sequence += len(chunk)
        return self.next_sequence

receiver = Receiver()

assert receiver.receive(5, b" ") == 0
assert receiver.receive(6, b"world") == 0
assert receiver.receive(0, b"hello") == 11
assert receiver.delivered == b"hello world"

print(receiver.delivered.decode())
print("cumulative ACK:", receiver.next_sequence)

运行:

bash
python3 tcp-reassembly.py

预期输出:

text
hello world
cumulative ACK: 11

模型忽略完全落在已交付范围内的重复数据,拒绝部分重叠或 pending 区间冲突,不比较旧重复数据的内容。真实 TCP 还要处理重叠策略、序号回绕、receive window、SACK、内存上限与异常输入。这里交付到 delivered 也只是模型行为,不能推断实际应用已经读取。

6. SACK 告诉发送方后面收到了什么 ​

只有 cumulative ACK 时,发送方知道最早缺口,却不清楚缺口之后哪些范围已经到达。Selective Acknowledgment(SACK)允许接收方在 TCP option 中报告若干已收到的非连续 block。

例如:

text
cumulative ACK: 0
SACK block: [5, 11)

发送方据此可以优先补 [0,5)。协商有方向性:数据接收方只有在对方的 SYN 中收到 SACK-permitted 后,才能向对方发送 SACK block;这个 option 表示愿意处理 SACK 反馈,不能笼统说必须双方先通告才允许任一方向使用。

SACK 是反馈,不改变应用看到的按序字节流;序号 0 的缺口没补上前,后面的字节通常仍不能交给应用读取。

SACK 也不是提前释放重传副本的凭证:接收方可能丢弃此前报告过的乱序数据。发送方仍须保留数据,直到累计 ACK 确认它。

7. 什么时候重传 ​

发送方不能等无限久,也不能每个 ACK 稍慢就立即重发。TCP 主要从两类证据推断丢失:

重传超时(RTO)。 实现依据测得的 round-trip time(RTT)及其变化估算 RTO。计时到期仍未得到所需确认,就重传并调整计时。固定写死一个“网络平均 RTT”无法适应局域网与跨洲链路。

ACK/SACK 反馈。 多个 ACK 持续指向同一缺口,或 SACK 显示后续范围已经到达,发送方可以在 RTO 之前判断某段很可能丢失。经典教材称之为 fast retransmit;现代实现的 loss detection 还会结合更丰富的 SACK 与时间信息。

重传意味着“发送方再次发送一段序号范围”,不保证 packet 边界与第一次完全相同。抓包判断 retransmission 时也要考虑 capture loss、offload 和 observation point。

例如首个 RTT 样本为 100 ms,下一次为 140 ms。按 RFC 6298 的平滑计算,先用旧 SRTT 更新变化量,再更新 SRTT;这里取时钟粒度 1 ms,并应用该 RFC 建议的 1 秒 RTO 下限:

python
srtt = 0.100
rttvar = srtt / 2
sample = 0.140
rttvar = 0.75 * rttvar + 0.25 * abs(srtt - sample)
srtt = 0.875 * srtt + 0.125 * sample
raw_rto = srtt + max(0.001, 4 * rttvar)
rto = max(1.0, raw_rto)
print(f"SRTT={srtt:.4f}, RTTVAR={rttvar:.4f}")
print(f"raw RTO={raw_rto:.3f}, floored RTO={rto:.3f}")
# SRTT=0.1050, RTTVAR=0.0475
# raw RTO=0.295, floored RTO=1.000

这不是任意系统 ss 必须打印的数值:实现可能采用不同的最小计时策略。重传造成 RTT 样本歧义时,Karn 规则要求排除这类样本,除非 timestamp 等机制消除歧义;RTO 到期后的退避又是另一步。

8. 流量控制保护接收方 ​

服务端的接收缓冲区不是无限大。若应用迟迟不取走数据,TCP receive buffer 会逐渐填满;接收方通过 advertised receive window(rwnd)告诉发送方还有多少序号空间可接收。

发送方在途数据量的上界可先用这个简化关系理解:

text
allowed flight ≈ min(rwnd, cwnd)

rwnd 保护接收 endpoint,cwnd 保护网络路径。前者属于 flow control,后者属于 congestion control。两者取较小值,但它们回答的是不同问题。

若简化地忽略 SACK 恢复等细节,rwnd=64 KiB、cwnd=32 KiB,已有 24 KiB 在途,则还能提交到传输中的新数据约为 8 KiB。这不是“每次 send 可写 32 KiB”的接口承诺:本地 send buffer 还可暂存数据,已有在途数据也不会因窗口变小而瞬间撤回。

接收窗口降为 0 时,发送方不能继续灌入普通新数据。实现会保留 persist 状态并探测窗口是否重新打开,以免窗口更新丢失后双方永久等待。应用若长期不读,协议最终仍需要资源上限和连接策略。

9. Window scale 为什么在 SYN 里协商 ​

TCP header 的 window 字段只有 16 bits。高带宽、高 RTT 路径需要更多在途数据,65,535 bytes 可能不足。Window Scale option 让每一方向声明一个 shift count,后续把 window 字段左移相应位数解释。

几个边界容易写错:

  • option 只能在 SYN segment 中协商;
  • 两个方向各自声明自己的 scale,数值可以不同;
  • SYN 中的 window 字段本身不应用 scale;
  • scale 扩大可表达范围,不会凭空增加 socket buffer;
  • 抓包工具可能已经显示换算后的 window,也可能同时显示 raw value 和 scale。

RFC 7323 将最大 shift count 限为 14。调大系统 buffer 也不代表每条连接都会立即占满该内存,实际分配和 autotuning 取决于实现。

Window Scale 与上面的 SACK-permitted 不能套同一条协商规则:双方必须都在 SYN 中携带 Window Scale,任一方向才能启用窗口缩放。缩放系数在连接期间固定。

10. 带宽时延积解释窗口为什么会卡吞吐 ​

一条路径可同时容纳的数据量近似为 bandwidth-delay product(BDP):

text
BDP = bottleneck bandwidth × RTT

若瓶颈为 100 Mbit/s、RTT 为 80 ms,路径 BDP 约为 1 MB。单连接若只能维持远小于这个数的有效在途数据,即使链路没有丢包,也难以填满路径。

python
bandwidth_bits_s = 100_000_000
rtt_s = 0.080
bdp_bytes = bandwidth_bits_s * rtt_s / 8
window_bytes = 65_535
window_limited_bits_s = window_bytes * 8 / rtt_s
print(f"BDP: {bdp_bytes:.0f} bytes")
print(f"Window ceiling: {window_limited_bits_s / 1_000_000:.4f} Mbit/s")
print("Scaled window:", 8192 << 7, "bytes")
# BDP: 1000000 bytes
# Window ceiling: 6.5535 Mbit/s
# Scaled window: 1048576 bytes

这里 MB 按十进制,即 1,000,000 字节;1 MiB 则是 1,048,576 字节。6.5535 Mbit/s 只是以该窗口和 RTT 估算的理想上限,协议开销等会进一步降低应用吞吐。

这个计算只给数量级,不是吞吐承诺。协议开销、应用供给速度、cwnd、rwnd、排队、loss 和 receiver processing 都会改变结果。正确做法是先测量 window 与 RTT,再决定是否存在 buffer 限制。

11. MSS、MTU 与 segment 边界 ​

Maximum Segment Size(MSS)option 表示本端愿意接收的最大 TCP payload segment 尺寸。它通常依据接口或路径 MTU 推导,但不包含 IP/TCP header。

根据 MTU 通告 MSS 时减固定 IP/TCP 头长度;实际发段时还要扣掉采用的 IP/TCP option,遵守对端 MSS 和路径 MTU。因而 IPv4 MTU 1500 可通告 MSS 1460,但若 TCP 带 12 字节 option,实际数据最多 1448 字节。IPv6、隧道和更小的路径 MTU 也会改变预算,不能把通告 MSS 与每一段的实际 payload 混成一个数。

TCP segmentation offload(TSO/GSO)可能让发送端抓包看到大于 path MSS 的逻辑 segment;接收端 GRO 又可能把多个 packet 合并后再交给协议栈。分析 wire packet size 时,应在合适的 capture point 抓取,或暂时考虑 offload 的影响。

12. Nagle、Delayed ACK 与小写入 ​

Nagle algorithm 试图减少大量 tiny segment:连接中已有未确认小数据时,新的小写入可能暂存。Delayed ACK 则允许接收方短暂等待,以便合并 ACK 或随反向数据一起发送。

两种策略在某些 request/response 模式下会相互影响延迟,但不能看到小包就统一开启 TCP_NODELAY:

  • 低延迟交互协议可能愿意用更多 packet 换更快发送;
  • bulk transfer 通常更关心吞吐与 packet efficiency;
  • 应用自行 buffer、TLS record 和 runtime writer 也会改变写入边界;
  • 具体 delayed ACK 与 Nagle 行为属于实现策略。

先抓包确认等待发生在哪一层,再调整 socket option。一次 send 很小,不证明 wire 上一定有同样大小的 packet。

13. Checksum 能发现什么 ​

TCP checksum 覆盖 TCP header、payload,以及由 IP 地址和协议等组成的 pseudo-header。它用于检测偶发传输错误,也能捕捉部分送错 endpoint 的情况。

它不是密码学完整性保护。攻击者若能修改 packet,也能重算 checksum;防篡改与身份认证由 TLS、IPsec 等安全协议处理。

在发送主机抓包时看到“checksum incorrect”不一定是坏包。硬件 checksum offload 可能在抓包点之后才填写最终值,需要结合 capture 位置与 offload 状态判断。

14. TCP 的队头阻塞 ​

接收方已经拿到后续 segment,却仍要等最早缺口补齐后才能向应用交付连续 stream。这叫 transport-layer head-of-line blocking。

HTTP/2 可以在一个 TCP connection 内多路复用许多 stream,但底层丢失一个 TCP 序号范围时,所有 stream 的后续 byte 都会一起等待。QUIC 把可靠性放到独立 stream 上,正是为了缩小这个阻塞范围;这会在第 11 章展开。

TCP 不知道哪些 byte 属于图片、RPC 或心跳。应用 framing 再精巧,也无法越过底层字节流缺口。

15. 用 ss 和 tcpdump 找证据 ​

Linux 上可以先看 socket 的内部统计:

bash
ss -tin

ss 的字段随 kernel 与 iproute2 版本变化,常见信息包括 RTT、RTO、拥塞控制算法、MSS、window scale、retransmission 和 delivery rate。不要把某个版本的一行输出当成跨平台稳定 API。

本机实验可在 loopback 抓包:

下面的 8000 是示例端口。第 2 章代码默认绑定端口 0,运行前把它改为本机空闲的 8000,并先启动抓包;若另选端口,过滤器同步修改。短实验可能在你打开诊断工具之前就结束。

bash
sudo tcpdump -i lo -nn -tttt 'tcp port 8000'

观察时把三个时间线对齐:

  1. 应用何时调用 send、recv、shutdown;
  2. 内核 socket state 与 queue 怎样变化;
  3. capture point 实际看见哪些 SYN、ACK、data、retransmission、FIN 或 RST。

抓包只观察路径上的一个位置,不能显示服务端内核收包之后的业务处理;代理两侧的 TCP flow 也要分别解释。

16. 常见误解 ​

“ACK 表示这个 packet 被业务处理了。” ACK 确认 TCP 序号范围,不确认业务事务。

“TCP 一次 send 对应一次 recv。” TCP 不保存应用 write boundary。

“窗口越大越快。” 有效吞吐还受 cwnd、RTT、loss、应用速度和瓶颈带宽限制。

“重复 ACK 一定是网络丢包。” reordering、duplicate segment、capture artifact 等也会产生类似观测,需要结合 SACK 与两端抓包。

“TCP checksum 能阻止篡改。” 它是错误检测,不是认证。

“PSH 标志定义消息结束。” PSH 影响接收处理提示,不给应用建立通用 message boundary。

17. 练习与验收 ​

练习一:改变抵达顺序 ​

修改前面的 Receiver 示例,让 world、空格、hello 依次抵达。验收条件:

  • 前两次返回的 ACK 都是 0;
  • 最后一次返回 11;
  • delivered 仍为 hello world。

练习二:对照应用与抓包 ​

在本机运行第 2 章的 socket 示例,同时用 tcpdump 抓 loopback。验收条件:

  • 能指出握手的三个 segment;
  • 能区分应用两次 sendall 与实际 segment 边界;
  • 能找到 FIN 或 RST,并用程序关闭路径解释它。

需要管理员权限的抓包若无法运行,可以保存应用日志并注明验证边界,不要伪造 packet 输出。

18. 从字节流到应用协议 ​

现在可以解释乱序到达时的 ACK:后续字节可先缓存,累计确认仍停在缺口;缺口补齐后,连续范围才推进。TCP 的可靠性到接收协议栈为止,业务完成仍由应用协议回答。

继续阅读:HTTP 与 Web 服务器。在那里,连续字节终于重新拥有请求行、header 和 body 的语义。若想追踪网络路径拥堵时窗口怎样收缩,直接前往第 10 章:TCP 拥塞控制。

参考 ​

Built with VitePress | Software Systems Atlas