Appearance
第2章 TCP 提供怎样的通信能力
IP 协议尽最大努力将数据报(Datagram)送达目标。在漫漫传输途中,可能会出现丢包、重复到达、乱序、比特翻转差错乃至网络拥塞;通信双方的处理速度也往往存在巨大的落差。为了解决这些痛点,TCP 运行在两端主机内部,通过精细的连接状态维护、细致到字节的编号追踪、确认机制、丢包重传以及灵活的窗口控制,将这些不确定因素完美屏蔽,为应用层提供了一条可以顺序读写的双向可靠字节流。
本章我们先俯瞰全貌。至于每项机制具体的首部字段、核心算法以及状态机的微妙转换,都会在后续章节为你逐一展开。
先做一个预测
假设客户端调用一次 sendall 提交了 2400 字节数据,服务端则使用 recv(700) 进行分块读取。请你预测一下:
- 服务端究竟需要调用几次
recv,才能累计得到完整的 2400 字节? - 在 Wireshark 中,你会抓到几个携带实际数据的 TCP 报文段(Segment)?
- 当客户端的
sendall成功返回时,服务端的业务代码是不是就已经把数据处理完了?
前两题的答案都不是定数,它们会受到操作系统调度、缓冲区状态、最大报文段长度(MSS)以及网卡卸载(Offload)机制的影响。第三题则涉及“证据层级”的概念:sendall 成功返回,仅仅说明 Python 已经把所有字节妥善交给了本地 Socket 的发送缓冲区。至于远端业务到底有没有处理完,必须通过应用层的专属响应来确认,TCP 无法越俎代庖。
一个直觉模型:两卷带编号的纸带
为了更好地建立直觉,我们可以把一条 TCP 连接想象成两条方向相反、印有连续编号的纸带:
- 客户端发往服务端的纸带,拥有自己的字节编号。
- 服务端发往客户端的纸带,也拥有属于自己的一套独立编号。
- 发送方会把连续的纸带裁剪成若干小段,再交给底层的 IP 协议投递。
- 接收方收到后,会严格按照编号把它们重新拼接起来。只有连续无缝拼接好的前缀部分,才会交由应用层读取。
- 确认号(ACK Number)就像一张回执单,告诉对方:“我已经收到了这之前的全部内容,下一次请从这个新编号开始发。”
这个模型恰好揭示了 TCP 的两个核心特质:首先,TCP 是全双工(Full-duplex)的,数据可以在两个方向上同时互不干扰地传输;其次,TCP 是面向字节流的,它的序列号(Sequence Number)针对的是每一个字节,而报文段的作用仅仅是打包承载一段连续的字节。
连接与端口
TCP 会在通信的两端(主机)维护各自的连接状态。通常情况下,服务端会在约定的端口上安静监听,而客户端则由操作系统随机分配一个临时端口。连接建立后,源 IP、源端口、目标 IP 和目标端口共同构成了一个经典的“四元组”。操作系统正是依靠这个四元组,在纷繁复杂的网络报文中准确找到对应的 TCP 连接上下文。
端口的核心作用是解决“数据该交给哪个 TCP 端点”的问题。发往同一个服务器 IP 的数据,Web 网页请求会被路由到 443 端口,而数据库连接请求则会去往另一个端口。此外,同一个服务端端口也能同时接纳成千上万个不同的客户端连接,因为哪怕目标 IP 和端口完全一样,每一个客户端的四元组依然是独一无二的。
第4章我们会把监听 Socket、已连接 Socket 和四元组放到操作系统的微观视角下仔细端详。
TCP 怎样整理一条字节流
序列号定位字节
在发送方发出的数据流中,每一个字节都有属于自己的序列号位置。一个 TCP 报文段头部的 Seq 字段,标明的就是该报文段携带的第一个数据字节的序列号。举个例子,假设一个报文段的相对 Seq 为 1000,并且携带了 500 字节的数据,那么它覆盖的序列号范围就是 [1000, 1500)。紧接着它的下一个连续字节的序列号理应是 1500。
需要注意的是,分段的长度并非固定不变,同一个序列范围的数据也可能因为重传而反复出现在网络中。接收端完全依靠序列号来识别数据是否连续、有没有出现缺口(Gap)、是否发生了乱序或者重复。经过这番复杂的“拼图”工作后,应用层最终读取到的,永远是干净、连续的字节流。
校验和发现传输差错
每一个 TCP 报文段都会携带校验和(Checksum)。它的计算范围不仅涵盖 TCP 首部和载荷数据,还包含来自底层 IP 层的伪首部信息。接收端通过校验和来甄别网络传输过程中常见的比特翻转差错。一旦校验失败,该报文段就会被无情丢弃。随后,发送端会通过丢包检测机制发现数据丢失,并触发相应字节的重传。
不过需要澄清的是,TCP 校验和只是一种基础的差错检测机制,防的是“物理层面的不小心”。至于抵御恶意篡改、确保通信双方身份真实可靠,那是 TLS 等高级密码学协议该操心的事。
累计确认推进连续前缀
接收方通过确认号(ACK)向发送方汇报当前的连续接收进度。当发送方收到 Ack = 1500 时,意味着接收方期待的下一个字节序列号是 1500,且 1500 之前的所有数据均已稳妥接收。
累计确认有一个有趣的特性:如果 1000~1499 的数据到了,2000~2499 的数据也到了,但中间 1500~1999 的数据在路上不幸走丢了,那么接收方的 ACK 依然会死死停在 1500,拒绝前进。只有支持 SACK(选择性确认,Selective Acknowledgment)的连接,才能在 ACK 之外额外附带一条消息:“虽说我还在等 1500,但 2000~2499 我确实已经收到了。”这能极大地帮助发送方精准补发丢失的特定数据块。
丢包检测与重传
发送方会在内存中保留尚未被确认的数据,并持续测量网络的往返时间(RTT)。如果某个确认号长时间没有推进,重传超时(RTO)计时器就会被触发,强制重发数据。除了干等超时,连续收到同一个 ACK(重复 ACK)、SACK 提供的块信息,以及现代基于时间的丢包探测算法,都能为发送方提供更敏锐的丢包线索,从而实现更早的快速重传。
重传机制保证了一个核心体验:无论底层网络把数据丢包重发了多少次,应用层依然能按照原有的顺序平滑读取。即使网络中同时漂流着同一份数据的多个副本,接收端的 TCP 栈也会根据序列号明察秋毫,默默去重,绝不打扰应用层的宁静。
第20章我们会深入探讨 RTO、重复 ACK、快速重传与 RACK-TLP,第21章则会详细拆解乱序和 SACK。
两种窗口解决两种速度问题
接收窗口照顾接收端
有时候,接收端的应用程序处理数据非常缓慢。为了防止被数据淹没,接收端 TCP 会通过接收窗口(rwnd, Receive Window)向发送方大声疾呼:我的缓冲区还剩多少空间。随着数据不断涌入缓冲区,如果应用层来不及读取,窗口就会不断缩小;一旦应用把数据读走,腾出了位置,窗口又会重新放大。
这就是 TCP 的流量控制(Flow Control),它的核心宗旨是“让快速的发送方适应缓慢的接收方”。在第22章,我们会深入探究零窗口、窗口更新与窗口探测的诸多细节。
拥塞窗口照顾网络路径
但是,即便接收端有海量的内存,网络路径上的路由器和交换机带宽也是有限的。为了不把网络彻底堵死,发送方会在内部悄悄维护一个拥塞窗口(cwnd, Congestion Window)。它会根据 ACK 的到达情况、丢包事件、ECN(显式拥塞通知)以及所配置的拥塞控制算法,动态调节网络中“在途数据”的整体规模。
在实际运行中,发送方真正能发送的在途数据,是受到 rwnd 和 cwnd 双重制约的,同时还要看应用层产生数据的速度、发送缓冲区剩余空间以及具体的协议栈实现策略。在此之前,你可以先记住下面这个简练的近似公式:
第23章我们会详细拆解慢启动、拥塞避免、CUBIC 与 BBR 等经典拥塞控制思路。
TCP 能力地图
| 应用看到的能力 | 支撑底座(TCP 核心机制) | 抓包观察线索 |
|---|---|---|
| 双向同时传输 | 维护双向独立的序列空间与收发状态 | 双方报文的 Seq、Ack 都在推进 |
| 按序读取字节流 | 序列号、乱序重排队列、累计确认 | 抓包看到的线上乱序,并不影响应用层的顺序读取 |
| 修复可恢复的丢失包 | 未确认队列、超时计时器、重传机制、SACK | ACK 长时间不推进、出现 SACK 选项、重传报文段 |
| 处理重复到达的数据 | 序列空间与接收状态维护 | 相同序列号范围的数据在抓包中重复出现 |
| 适应接收端的处理速度 | 接收窗口(rwnd)与窗口更新机制 | TCP 头部 Window 字段缩小、归零与恢复 |
| 适应网络路径的拥塞容量 | 拥塞窗口(cwnd)、拥塞信号反馈与发送节奏控制 | 在途数据量(Flight Size)和网络吞吐随时间动态起伏 |
| 发现常见的比特翻转差错 | TCP 校验和(Checksum) | Wireshark 提示校验失败并伴随后续重传 |
正是这些精密配合的机制,共同撑起了一条可靠、有序的字节流。当然,TCP 并非万能。如果发生物理链路断开、网络长期瘫痪或者对方主机崩溃重启,Socket 最终还是会向应用抛出错误或超时异常。这个时候,就需要应用层自己出面,决定后续的灾备或重试策略了。
可靠交付停在哪一层
正确理解 TCP ACK 的语义极其重要。当服务端发出一个 TCP ACK 时,它仅仅保证了一件事:对应的连续字节已经安全抵达了服务端的 TCP 接收缓冲区。以下几个关键事实,TCP 是无法保证的,必须依赖应用层的响应、系统日志或者持久化记录来作证:
- 服务端进程已经调用
recv把这些数据从内核读到了用户态。 - 传来的请求数据格式完全合法且通过了业务校验。
- 核心数据已经落盘,持久化到了数据库中。
- 相关的业务操作(如订单创建、扣款等)已经成功执行。
这类业务事实,必须通过高层的应用协议来表达。举个典型的例子:客户端发送一条带有 Request ID 的“创建订单”消息,服务端在数据库事务提交后,回复一条“订单请求 42 已成功提交”。客户端只有收到这条应用层的回复,才能断定业务真正落地。如果这条回复在返回途中丢失,客户端触发重试时,还必须配合 Request ID 来实现操作的幂等性(Idempotency)。第26、27章我们将专门探讨这套系统性问题。
同理,我们在代码里调用 send 成功返回时,也不要沾沾自喜。那只代表本地操作系统内核愿意接纳这些字节。数据此刻很可能还躺在发送缓冲区、堵在网卡队列,或者正漂泊在漫长的网络路径中。想要确认数据最终被对方成功消费,唯有依赖应用层的双向响应协议。
实验:比较写入、读取与报文长度
将下面代码分别保存为 capability_server.py 和 capability_client.py。
服务端:
python
import socket
import time
HOST = "127.0.0.1"
PORT = 9012
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((HOST, PORT))
listener.listen()
print(f"listening on {HOST}:{PORT}")
conn, peer = listener.accept()
with conn:
print(f"accepted {peer}; pause 2 seconds")
time.sleep(2)
total = 0
while True:
chunk = conn.recv(700)
if chunk == b"":
break
total += len(chunk)
print(f"recv returned {len(chunk)} bytes; total={total}")
response = f"RECEIVED {total}".encode("ascii")
conn.sendall(response)
print(f"application response: {response!r}")客户端:
python
import socket
HOST = "127.0.0.1"
PORT = 9012
payload = b"A" * 1200 + b"B" * 1200
with socket.create_connection((HOST, PORT)) as sock:
print(f"local={sock.getsockname()} peer={sock.getpeername()}")
sock.sendall(payload)
print(f"sendall accepted {len(payload)} bytes locally")
sock.shutdown(socket.SHUT_WR)
parts = []
while True:
chunk = sock.recv(4096)
if chunk == b"":
break
parts.append(chunk)
print(f"application response: {b''.join(parts)!r}")实验步骤:
- 打开 Wireshark,在回环接口(Loopback)上开始抓包,并将显示过滤器设为
tcp.port == 9012。 - 在 PowerShell 窗口 A 运行
python .\capability_server.py。 - 在窗口 B 运行
python .\capability_client.py。 - 在服务端暂停的那两秒内,迅速打开窗口 C 运行
Get-NetTCPConnection | Where-Object LocalPort -eq 9012。 - 停止并保存抓包,使用
tcp.port == 9012 && tcp.len > 0过滤出所有携带数据的 TCP 报文段进行观察。
预期现象与解释
在上面的代码中,客户端一口气提交了 2400 字节。由于服务端每次只调用 recv(700),这意味着它每次最多只能读出 700 字节,总共需要多次循环才能读完这完整的 2400 字节。需要注意的是,每次 recv 实际返回的字节数和总调用次数在不同环境下可能会有所波动,但累计总和一定是 2400 字节。最后打印出的 RECEIVED 2400,就是我们在本实验中模拟的“应用层确认”。
而在 Wireshark 这边,tcp.len 代表的是抓包软件在网络层面上观察到的 TCP 数据载荷长度。你会发现,它记录的数据分块方式,与你在代码中看到的 sendall 或 recv 日志往往并不一一对应。特别是在本地回环地址抓包时,受网卡卸载(Offload)机制的影响,单个报文段甚至可能显得异常大。分析时,我们不必拘泥于单次的长度,而应该关注总字节数、数据流方向以及 Seq 序列号的稳步推进。记住,不同工具位于不同的抽象层级,看到的数据形态自然也会有所区别。
精确总结
- TCP 负责维持字节流的绝对顺序,但在哪里切分消息边界,全凭应用层自己定义。
- TCP ACK 仅仅反映了接收端内核 TCP 缓冲区的连续接收进度,真正的业务结果必须由应用层响应来表达。
- TCP 的可靠性是建立在“连接依然存活”的前提之上的。一旦连接异常断开,应用层仍需亲力亲为处理各种错误、超时和重试逻辑。
- 接收窗口(
rwnd)反映的是接收端 TCP 的可用序列空间,与应用层自己的业务队列容量是两码事。 - 至于数据加密、身份认证、消息反序列化以及操作的幂等性,统统不在 TCP 的管辖范围内,它们由 TLS 以及上层应用协议全权负责补充。
检验你的理解
- 既然 TCP 是全双工的,为什么它的两端需要各自维护独立的序列空间?
- 当你抓包看到
Ack = X时,它传达了关于连续字节范围的什么潜台词? - 接收窗口(
rwnd)与拥塞窗口(cwnd)分别是为了应对哪种系统瓶颈而设计的? sendall成功返回、服务端发回 TCP ACK、以及收到RECEIVED 2400,这三者在“证据层级”上有什么本质区别?- 为什么抓包看到的 TCP 数据报文段数量,往往与应用层调用
recv的次数不一致?
本章小结
回望本章,TCP 就像一个极其严谨的管家。它在两端主机上精细地维护着连接状态,用序列号给每个字节打上标记,用累计确认和重传机制修补任何数据的遗漏,用接收窗口迁就慢条斯理的接收方,用拥塞窗口体谅拥挤不堪的网络,甚至还贴心地为双向通信各自隔离了独立的状态空间。
经过这一系列复杂的打磨,底层凌乱的数据包终于蜕变成了应用层手中那条“丝滑、有序、可靠”的全双工字节流。但这并不是万事大吉,因为定义消息边界、确认业务结果、制定兜底重试策略乃至保障通信安全,依然是应用层责无旁贷的重任。
在下一章中,我们将把“字节流”放到代码的显微镜下放大观察,看看如何为这些连绵不绝的字节划定清晰的应用层消息边界。