9.2 传输控制协议(TCP)
1. 什么是 TCP
1.1 TCP 的本质与核心能力
传输控制协议(Transmission Control Protocol, TCP)是整个现代互联网的基石协议之一。在系统架构的层级上,它被定义为一种面向连接的、可靠的、基于连续字节流的端到端传输层规约。如果说 IP 协议仅仅负责将孤立的报文尽可能送达目标物理节点(Best-Effort 尽力而为),那么 TCP 则是在这片不可靠的异构物理网络之上,为上层应用程序虚拟出一条宛如本地管道般严密、可靠的双向全双工字节流通道。
这一契约主要依托于以下核心工程机制维系:
- 端到端可靠性:TCP 严格依赖序号(Sequence Number)、累积确认(Acknowledgment)与动态自适应重传定时器。接收端在成功校验数据后按序回复确认号;若发送方在根据网络往返时间(RTT)动态估算的超时周期内未能等到特定区间的确认,便会主动触发重传。
- 面向逻辑连接:通信双方在真正收发载荷数据之前,必须经历严格的握手机制就双方的初始序号、最大报文段大小(MSS)与窗口缩放等参数达成一致。该连接是全双工的,收发两个物理方向的数据流各自独立推进,互不干扰。
- 滑动窗口流量控制:网络协议不能脱离接收端的内存处理能力空转。TCP 首部内置 16 位窗口字段(Window Size),接收端通过该字段动态通告自身内核套接字缓冲区的剩余可用容量,发送端据此严格限制在途未确认字节的总量,杜绝将接收端内存撑爆的恶性溢出。
- 严格全局有序:网络层 IP 数据报在穿越公网路由器时完全可能由于路径变动而发生乱序到达。TCP 接收端依据序列号在内核重组缓冲区中将碎片重新拼装排序,确保交付给应用层的数据顺序与发送端的写入顺序分毫不差。
- 传输完整性校验:每一个 TCP 报文段均携带涵盖伪首部、TCP 首部及载荷的 16 位反码和校验字段。任何发生单比特翻转或内存损坏的数据段都会被接收端内核直接丢弃,进而转由超时重传机制自愈。
2. TCP 的生命周期与状态转换
2.1 三次握手:连接建立
TCP 逻辑连接的建立遵循经典的三次握手(Three-way Handshake)流程。其本质并非单纯的仪式,而是通信双方互相协商并确认各自初始序列号(ISN, Initial Sequence Number)的过程:
首先,客户端向服务端发送一个带有 SYN 标志位的报文段,并在序号字段中填入自身随机生成的初始序号
2.2 数据传输与流式抽象
在连接处于 ESTABLISHED 状态的生命周期内,应用程序调用 send 或 write 写入的数据并不会被 TCP 视作具有独立业务边界的“消息”。TCP 会根据路径 MTU 协商出的最大报文段大小(MSS)、网络拥塞窗口以及接收端通告窗口,将连续的字节流在最合适的时机切割为一个或多个数据段送入网络层。接收端内核则按序号将到达的片段按序拼接至套接字接收缓冲区,应用程序通过 recv 或 read 即可像读取连续物理文件一样按需提取字节。
2.3 四次挥手与非正常中断
由于 TCP 是一条全双工通信管道,两个方向上的数据流可以独立终止,因此标准的断开流程需要经历“四次挥手”:
当某一方应用层处理完毕、不再有新数据需要发送时(例如调用了 close 或 shutdown(SHUT_WR)),它会向对端发送一个置位 FIN 的报文段,宣告本方向的写入流已经终结。对端内核接收到 FIN 后自动回复一个 ACK 确认,此时连接进入半关闭(Half-Close)状态——发起方已不可再写,但依然可以持续读取对端尚未发完的数据。随后,当对端也处理完剩余业务并决定关闭写通道时,同样会发送一个 FIN 报文段;发起方在接收到对端 FIN 后回复最后的 ACK。此时,主动发起关闭的一方并不能立即释放资源,而是必须在内核中强制滞留于 TIME_WAIT 状态长达两个报文最大生存时间(2MSL,在多数 Linux 系统上硬编码为 60 秒),以确保最后发送的 ACK 若在网络中丢包,能够有足够的时间响应对端重传的 FIN;同时彻底耗尽线路上可能残存的一切属于该连接四元组的历史迟到报文,防止它们在后续复用相同端口的新连接中造成数据串扰。
除了体面的挥手,TCP 还存在一种强制撕毁连接的异常路径:连接重置(Reset, RST)。当端点遭遇严重不可逆异常时,协议栈会直接发出一个带有 RST 标志位的报文段。收到 RST 报文的一方,其内核套接字缓冲区内的现存数据将立即被全部冲刷丢弃,连接在未经握手挥手的情况下瞬间暴毙,相关的系统调用会立即返回 ECONNRESET。触发 RST 的常见诱因包括:服务端目标端口未被任何进程监听、通信进程遭遇 SIGKILL 异常暴毙导致残留未读数据、网络中间防火墙主动阻断,或是应用程序通过配置 SO_LINGER 强制将丢弃超时设为 0。
3. 深入理解 TCP 的流式特性与抗抖动机制
3.1 连续流与应用层分帧:消除“粘包”误区
初涉网络编程的开发者最常遭遇的心理障碍是所谓的“粘包”与“拆包”。必须明确的是:在 TCP 协议规范与操作系统内核的字典里,压根不存在“包”(Packet)的概念,只有无边无际的连续字节序列。
当应用层先后调用两次 send 分别写入 50 字节时,内核套接字发送缓冲区完全可能将这 100 字节汇聚在一个物理数据段中合并发出,导致接收端单次 read 便直接读出 100 字节;反之,若单次写入 64 KiB 的巨额载荷,由于受限于路径 MTU(如标准以太网下的 MSS 通常为 1460 字节),内核必然会将其拆分成几十个连续的 TCP 报文段发送,导致接收端需要历经多次 read 才能逐渐汲取完整。这种合并与拆解由底层的 Nagle 算法(可通过 TCP_NODELAY 禁用)、套接字聚集选项(TCP_CORK)以及物理链路 MTU 共同主导。因此,试图依赖单次 recv 的返回长度来界定应用层业务消息的边界在原理上是注定失败的,必须由应用层协议自身承担消息分帧(Framing)职责——例如引入诸如 \r\n 的特定分隔符,或是在消息首部开辟固定宽度的字段标明后续载荷长度。
3.2 序号推进与累积确认
TCP 采用以字节为基本刻度的双向序号体系。序列号(Sequence Number)代表当前报文段载荷中首个字节在整个流中的绝对偏移量;确认号(Acknowledgment Number)则具有严格的累积性质:它代表接收方期望接收到的下一个字节编号,即向发送方宣告“该编号之前的所有连续字节均已安然收讫”。如果序号发生空洞(如收到了 1~1000 与 2001~3000,但中间的 1001~2000 发生丢包),接收端的确认号将顽固地停留在 1001,促使发送端聚焦补齐缺失的空洞。
3.3 拥塞控制:守护网络大动脉
如果说流量控制是保护接收端宿主机的内存,那么拥塞控制(Congestion Control)则是保护整个公网交换节点与骨干链路不致因过载而崩溃。TCP 的发送端维护一个动态的拥塞窗口(cwnd, Congestion Window),发送端在途未确认的字节上限受制于
经典 Reno/NewReno 算法通过慢启动(Slow Start)、拥塞避免(Congestion Avoidance)、快重传与快恢复四个阶段动态探测网络承载力。在早期慢启动阶段,窗口指数级扩张以快速试探可用带宽;一旦触及阈值便转为线性的拥塞避免;当遭遇丢包时,算法会惩罚性地将窗口急剧收缩,这也解释了为何在公网偶发丢包时,下载速率往往会呈现出断崖式下跌随后缓慢爬坡的锯齿状震荡。现代 Linux 默认采用基于延迟变化探测瓶颈容量的 BBR(Bottleneck Bandwidth and RTT)拥塞控制算法,显著平滑了跨洋长肥管道(LFN)下的吞吐抖动。
4. C 语言系统编程中的 TCP 接口全景
在基于 POSIX 标准的 C 语言系统编程中,操作系统的网络能力全部通过统一的 BSD 套接字接口抽象暴露:
- 创建套接字端点:调用
socket(AF_INET, SOCK_STREAM, 0)请求内核分配一个流式套接字,获取一个新打开的文件描述符; - 绑定网络地址与监听(服务端):服务端通过
bind将描述符与指定的本地 IP 地址和监听端口进行绑定,随后调用listen激活内核连接队列,开始等待外部连接接入; - 建立逻辑连接:在客户端侧,调用
connect触发三次握手向目标主机发起连接,函数在握手成功后返回;而在服务端侧,通过调用accept从内核的已完成连接队列中移出一个已经完成握手的连接,并为该物理连接派生出一个崭新的已连接套接字描述符; - 数据全双工吞吐:在已连接的套接字上,程序既可以使用网络专用的
send与recv系统调用传入高级控制标志,也可以完全依托 Linux“一切皆文件”的设计哲学,直接调用通用的read、write以及实现零拷贝传输的sendfile; - 体面断开与资源回收:使用
close关闭套接字,促使内核释放文件描述符引用计数并启动四次挥手;若需精细化管控单向半关闭,则调用shutdown单独截断读流或写流。
5. 习题
下面的题用到 setsockopt、getsockopt、shutdown 和 TCP_INFO。TCP_INFO 与 TCP_MAXSEG 在 Linux 的 netinet/tcp.h 里,取完整的 struct tcp_info 需要定义 _DEFAULT_SOURCE(_POSIX_C_SOURCE 不够)。服务器一律绑 127.0.0.1,端口自己挑一个空闲的。
本章反复说 TCP 是字节流,不保留 send 的边界。先写下预测,再写程序验证:
(1) [1.1] 客户端连上后连续 send 三次,内容是 GET、 /index.html、回车换行两遍,共 18 字节;服务器故意晚 300 毫秒才开始第一次 recv,缓冲区 4096 字节。这一次 recv 返回多少字节?如果程序把「一次 recv 得到的数据」当成一条完整请求,会出什么问题?
(2) [2.2] 服务器的缓冲区改成 8 字节,其余不动。读完 18 字节要调用几次 recv,各次返回多少?这时「一次 send 对应一次 recv」是从哪一端先失效的?
(3) [2.2] 改成一次 send 发 3000 字节,服务器缓冲区是 1024 字节。这时又要几次 recv?把这一问和前一问合起来,说明 send 与 recv 的配对关系在两个方向上都不成立。
正文说数据段按 MSS 切分。写一个 segplan MSS TOTAL,按给定的 MSS 把一个 TOTAL 字节的字节流切成分段,假定初始序号是 1000,输出每段的序号区间、长度,最后打印段数:
MSS 536, total 2000, ISN 1000
seg 1: seq 1000..1535 len 536
seg 2: seq 1536..2071 len 536
seg 3: seq 2072..2607 len 536
seg 4: seq 2608..2999 len 392
4 segments2
3
4
5
6
验收:
./segplan 536 2000输出 4 段,前 3 段各 536 字节,第 4 段 392 字节,第 4 段序号是 2608;./segplan 1460 4000输出 3 段,长度 1460、1460、1080;./segplan 536 536只输出 1 段,序号 1000..1535。
再回答两个问题:确认号推进到 2608 时,接收方已经收下多少字节?为什么序号和确认号的单位都是字节,而不是段?
shutdown 与 close 在 TCP 上不是一回事。写一对程序:服务器发完 bye\n 之后调用 shutdown(fd, SHUT_WR),然后继续读客户端随后发来的数据,读到 0 才 close;客户端读到 bye\n 之后继续发一条 thanks\n,再关闭。
要求:
- 服务器与客户端分开跑,用
ss -tan '( sport = :PORT or dport = :PORT )'在两个时刻各记录一次状态:客户端shutdown之后、服务器close之前,以及服务器close之后。写出FIN-WAIT-1、FIN-WAIT-2、CLOSE-WAIT、LAST-ACK、TIME-WAIT分别出现在哪一端、按什么顺序出现; - 把服务器的
shutdown换成close,重新运行。客户端还能不能把thanks\n送出去?服务器还收不收得到?客户端那边send返回什么、errno是什么? - 在客户端
shutdown(SHUT_WR)前后用getsockopt(fd, IPPROTO_TCP, TCP_INFO, ...)各读一次tcpi_state,再隔几百毫秒读一次。三次的数值分别是多少,分别对应哪个状态名?这个字段报的是本端还是对端的连接状态?
验收:第 1 问应当看到客户端先进入 FIN-WAIT-1,服务器收到 FIN 后进入 CLOSE-WAIT(服务器还开着写方向),客户端收到服务器的 ACK 后停在 FIN-WAIT-2;服务器 close 后,服务器侧那条连接离开 CLOSE-WAIT,客户端侧进入 TIME-WAIT。第 2 问里服务器已经关掉整条连接,客户端再 send 会在第二次写时得到 EPIPE(先收到 RST 时是 ECONNRESET)。第 3 问中三次读数应当依次落在 ESTABLISHED、FIN_WAIT1、FIN_WAIT2 对应的取值上,写的时候把数值和状态名对上;这个字段只描述本端套接字的状态,不能拿它判断对端是否已经关闭。
正文说 TCP_NODELAY 和 TCP_CORK 会影响分段策略。写一个程序:客户端连上后连续 send 十次、每次 1 字节,然后等 300 毫秒;服务器只接受连接,等一会儿再关。用 ss -tin "( dport = :PORT )" 读发送端的 data_segs_out,在四种设置下各测一次:
| 设置 | 做法 |
|---|---|
| 默认 | 不做任何设置 |
TCP_NODELAY | setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one) |
TCP_CORK | 十次 send 之前打开,之后关掉 |
MSG_MORE | 前九次 send 的 flags 传 MSG_MORE,最后一次传 0 |
要求:
- 四种设置下
data_segs_out各是多少?为什么默认与TCP_NODELAY在这里给出相同的段数,而TCP_CORK和MSG_MORE都把十次写并成了一段? TCP_CORK是套接字选项,MSG_MORE是每次调用的标志。十次写发完之后,TCP_CORK必须由程序显式关掉才把数据推出去,MSG_MORE则靠什么决定最后一次推出去?各举一个适合用它的场景(提示:前者适合服务器用writev拼一个首部加正文,后者适合循环里连续写)。- 在回环上把服务器改成「收到请求后睡 45 毫秒再回复」,客户端用
clock_gettime(CLOCK_MONOTONIC)量从发第一个字节到读到回复的时间,TCP_NODELAY开与关各测一次,两种设置下都在 45 毫秒上下。既然实测没有差别,为什么教科书还强调「小段写入加延迟 ACK 会产生 40 毫秒延迟」?要让它显现出来,需要哪几个条件同时成立?
验收:第 1 问的实测结果是默认 10、TCP_NODELAY 10、TCP_CORK 1、MSG_MORE 1。第 3 问里两种设置都在 45 毫秒左右,因为这段时间由服务器那 45 毫秒决定,写端的小段策略在其中不起作用;回答要点到 Nagle 只在「已有未确认数据且新数据不满一个 MSS」时才压住小段,而延迟 ACK 让这个等待拖到一个 ACK 定时器,两个条件缺一就不会出现 40 毫秒。