Skip to content

第2章 TCP 提供怎样的通信能力

IP 协议尽最大努力将数据报(Datagram)送达目标。在漫漫传输途中,可能会出现丢包、重复到达、乱序、比特翻转差错乃至网络拥塞;通信双方的处理速度也往往存在巨大的落差。为了解决这些痛点,TCP 运行在两端主机内部,通过精细的连接状态维护、细致到字节的编号追踪、确认机制、丢包重传以及灵活的窗口控制,将这些不确定因素完美屏蔽,为应用层提供了一条可以顺序读写的双向可靠字节流。

本章我们先俯瞰全貌。至于每项机制具体的首部字段、核心算法以及状态机的微妙转换,都会在后续章节为你逐一展开。

先做一个预测

假设客户端调用一次 sendall 提交了 2400 字节数据,服务端则使用 recv(700) 进行分块读取。请你预测一下:

  1. 服务端究竟需要调用几次 recv,才能累计得到完整的 2400 字节?
  2. 在 Wireshark 中,你会抓到几个携带实际数据的 TCP 报文段(Segment)?
  3. 当客户端的 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(显式拥塞通知)以及所配置的拥塞控制算法,动态调节网络中“在途数据”的整体规模。

在实际运行中,发送方真正能发送的在途数据,是受到 rwndcwnd 双重制约的,同时还要看应用层产生数据的速度、发送缓冲区剩余空间以及具体的协议栈实现策略。在此之前,你可以先记住下面这个简练的近似公式:

可用发送空间max(0, min(rwnd, cwnd)在途未确认数据)

第23章我们会详细拆解慢启动、拥塞避免、CUBIC 与 BBR 等经典拥塞控制思路。

TCP 能力地图

应用看到的能力支撑底座(TCP 核心机制)抓包观察线索
双向同时传输维护双向独立的序列空间与收发状态双方报文的 Seq、Ack 都在推进
按序读取字节流序列号、乱序重排队列、累计确认抓包看到的线上乱序,并不影响应用层的顺序读取
修复可恢复的丢失包未确认队列、超时计时器、重传机制、SACKACK 长时间不推进、出现 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.pycapability_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}")

实验步骤:

  1. 打开 Wireshark,在回环接口(Loopback)上开始抓包,并将显示过滤器设为 tcp.port == 9012
  2. 在 PowerShell 窗口 A 运行 python .\capability_server.py
  3. 在窗口 B 运行 python .\capability_client.py
  4. 在服务端暂停的那两秒内,迅速打开窗口 C 运行 Get-NetTCPConnection | Where-Object LocalPort -eq 9012
  5. 停止并保存抓包,使用 tcp.port == 9012 && tcp.len > 0 过滤出所有携带数据的 TCP 报文段进行观察。

预期现象与解释

在上面的代码中,客户端一口气提交了 2400 字节。由于服务端每次只调用 recv(700),这意味着它每次最多只能读出 700 字节,总共需要多次循环才能读完这完整的 2400 字节。需要注意的是,每次 recv 实际返回的字节数和总调用次数在不同环境下可能会有所波动,但累计总和一定是 2400 字节。最后打印出的 RECEIVED 2400,就是我们在本实验中模拟的“应用层确认”。

而在 Wireshark 这边,tcp.len 代表的是抓包软件在网络层面上观察到的 TCP 数据载荷长度。你会发现,它记录的数据分块方式,与你在代码中看到的 sendallrecv 日志往往并不一一对应。特别是在本地回环地址抓包时,受网卡卸载(Offload)机制的影响,单个报文段甚至可能显得异常大。分析时,我们不必拘泥于单次的长度,而应该关注总字节数、数据流方向以及 Seq 序列号的稳步推进。记住,不同工具位于不同的抽象层级,看到的数据形态自然也会有所区别。

精确总结

  • TCP 负责维持字节流的绝对顺序,但在哪里切分消息边界,全凭应用层自己定义。
  • TCP ACK 仅仅反映了接收端内核 TCP 缓冲区的连续接收进度,真正的业务结果必须由应用层响应来表达。
  • TCP 的可靠性是建立在“连接依然存活”的前提之上的。一旦连接异常断开,应用层仍需亲力亲为处理各种错误、超时和重试逻辑。
  • 接收窗口(rwnd)反映的是接收端 TCP 的可用序列空间,与应用层自己的业务队列容量是两码事。
  • 至于数据加密、身份认证、消息反序列化以及操作的幂等性,统统不在 TCP 的管辖范围内,它们由 TLS 以及上层应用协议全权负责补充。

检验你的理解

  1. 既然 TCP 是全双工的,为什么它的两端需要各自维护独立的序列空间?
  2. 当你抓包看到 Ack = X 时,它传达了关于连续字节范围的什么潜台词?
  3. 接收窗口(rwnd)与拥塞窗口(cwnd)分别是为了应对哪种系统瓶颈而设计的?
  4. sendall 成功返回、服务端发回 TCP ACK、以及收到 RECEIVED 2400,这三者在“证据层级”上有什么本质区别?
  5. 为什么抓包看到的 TCP 数据报文段数量,往往与应用层调用 recv 的次数不一致?

本章小结

回望本章,TCP 就像一个极其严谨的管家。它在两端主机上精细地维护着连接状态,用序列号给每个字节打上标记,用累计确认和重传机制修补任何数据的遗漏,用接收窗口迁就慢条斯理的接收方,用拥塞窗口体谅拥挤不堪的网络,甚至还贴心地为双向通信各自隔离了独立的状态空间。

经过这一系列复杂的打磨,底层凌乱的数据包终于蜕变成了应用层手中那条“丝滑、有序、可靠”的全双工字节流。但这并不是万事大吉,因为定义消息边界、确认业务结果、制定兜底重试策略乃至保障通信安全,依然是应用层责无旁贷的重任。

在下一章中,我们将把“字节流”放到代码的显微镜下放大观察,看看如何为这些连绵不绝的字节划定清晰的应用层消息边界。


上一章:第1章 一次网络请求是怎样发生的 · 所属篇:第一篇 · 下一章:第3章 TCP 是字节流 · 教程总览

Licensed under CC BY-NC-SA 4.0.