你和我聊网络,从 IP 层往上走,到了传输层,就要跟两个老熟人打交道:UDP 和 TCP。UDP 像个粗心大意的快递员,把包裹丢过去就不管了;而今天的主角 TCP,却像一位极其讲究、近乎偏执的管家——它要确认每一份"货"确实到对方手上了,才放心送下一份。
实际上,TCP 的全名叫做传输控制协议(Transmission Control Protocol)。人如其名,它的核心工作就是对"数据传输"这件事进行一份详细到近乎繁琐的控制:数据有没有丢、有没有重复、顺序对不对、对方有没有收到、发太快会不会把对方或网络挤爆……每一件它都要管。这带来的直接结果,就是 TCP 复杂,但可靠。
这篇文章我们就把 TCP 掰开揉碎讲清楚。它为什么复杂?可靠性从哪来?三次握手、四次挥手到底是怎样一步步完成的?TIME_WAIT、CLOSE_WAIT 又是什么坑?滑动窗口、拥塞控制这些"性能机关"是如何工作的?一句话:网络是不靠谱的,但 TCP 要用一套精密的机制,在"尽量少出错"和"尽量传输得快"之间,找到一个聪明的平衡点。这不只是背概念,接下来我们沿着报文段格式慢慢往深处走。
你应该先有的知识准备
往下读之前,有几件以往学过的东西我们反复会用到,先在这里一句话回顾。不太熟的话,可以对应的网络章节里回头补:
- 分层模型:网络从下到上分物理层、链路层、网络层(IP)、传输层、应用层。TCP 工作在传输层,承上(服务应用层进程)启下(借助 IP 层做不可靠的数据报投递)。
- 端口号:一台主机上有许多进程,数据来了到底该交给哪一个进程,靠端口号来区分。源端口指明数据从哪个进程来,目的端口指明要到哪个进程去。
- IP 只保证"尽力而为":IP 层不保证数据报不丢失、不重复、不乱序、不损坏。TCP 的一切"可靠",正是建立在 IP 层这种不靠谱之上的——这正是它最厉害的地方。
- socket 编程基础:
socket、bind、listen、connect、accept、close这些接口,是观察三次握手、四次挥手行为"看得见的手"。本文的代码示例和状态讲解会频繁引用它们。
TCP 的准确身份:面向连接、可靠、面向字节流的传输协议
在进入细节之前,先把 TCP 的"名片"递给你。官方定义里,TCP 是面向连接的、可靠的、面向字节流的传输协议。这三个词,每一个都是后续所有内容的地基:
- 面向连接(Connection-oriented):甲和乙通信之前,必须先通过一系列动作"建立连接"(三次握手),通信结束后再"拆除连接"(四次挥手)。这个连接不是一个真实存在的物理线路,而是通信双方在内核里为这次通信维护的一组状态和数据结构。因为有了连接,双方才清楚"这段对话属于谁和谁"。
- 可靠(Reliable):TCP 通过一系列机制(校验和、序号、确认应答、超时重传、流量控制、拥塞控制,后面逐个展开),尽力把发送方的字节流原封不动、按序、无重复地送到对方。请注意用词是"尽力"——这个世界没有绝对,但 TCP 给了我们一个极高概率下的可靠。
- 面向字节流(Byte-oriented):这是和 UDP 最大的差异之一,也是造成很多新手困惑的地方。TCP 不认为自己在传输"一个个独立的包",而是把应用层交下来的所有数据看成一根连续的字节流(就像一条没有边界的长河)。这份特性直接导致了后面的"粘包问题",我们专门讲。
在学习 TCP 的过程中,你会反复看到同一个主题:它既要可靠,又要高效,可就这俩目标经常打架。 可靠性往往需要等待和重传(慢),高效又希望一股脑往外发(快)。TCP 的几乎所有复杂设计,都是在调和这对矛盾。理解了这条主线,后面每个机制的"为什么这样设计"就有了答案。
TCP 报文段格式:一个段里到底装了什么
TCP 在传输层处理的数据单元,学名叫报文段(segment),课件里也常简称为"段"。别小看名字,第一处术语厘清就在这:UDP 的数据单元叫"数据报"(datagram),而 TCP 叫"报文段"。为什么?因为在 TCP 眼里,根本没有"一个请求对应一个包"的概念,它只是把连续字节流切割成一段一段发出去,所以叫"段"最贴切。
一个 TCP 报文段 = TCP 首部 + 应用数据。这个首部非常关键,TCP 的全部可靠性、流量控制逻辑,都靠它承载。最基础的标准 TCP 首部是 20 字节,如果加上了可选的"选项"字段,最长可以到 60 字节。下面是首部格式:
0 16 31
├──────────────────────────┬──────────────────────────┤
│ 源端口号 │ 目的端口号 │ 0-31位
├──────────────────────────┴──────────────────────────┤
│ 32位序号 seq │ 32-63位
├─────────────────────────────────────────────────────┤
│ 32位确认号 ack_seq │ 64-95位
├──────────────────┬─────┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┤
│ 4位首部长度 doff │6位保留│U│A│P│R│S│F│ 16位窗口大小 │ 96-111位
├──────────────────┴─────┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┤
│ 16位校验和 check │ 112-127位
├───────────────────────────┬────────────────────────┤
│ 16位紧急指针 │ (选项,变长,0-40字节) │ 128-159位
└───────────────────────────┴────────────────────────┘
我们把每个字段逐个拆开讲:
(1)源端口号 / 目的端口号,各 16 位
源端口号 表示这个数据是从哪个进程来的,目的端口号 表示它要到哪个进程去。端口号取值范围是 0 到 65535(16 位满值)。源端口 + 源 IP + 目的端口 + 目的 IP + 协议类型,这五个元素拼在一起构成一个通信五元组,刚好唯一标识一条 TCP 连接。后面讲 TIME_WAIT 占端口时会再次用到"五元组"这个概念。
(2)32 位序号 seq 和 32 位确认号 ack_seq
这是 TCP 可靠传输的两把钥匙,也是最容易混淆的一对,这里先把概念立起来,后面"确认应答"节再讲透:
- 序号 seq(sequence number):TCP 给每个字节都编了号,序号就是"本报文段第一个数据字节的编号"。注意,不是给"包"编号,是给字节编号。
- 确认号 ack_seq(acknowledgment number):只在 ACK 标志有效时才有意义,它表示"下一个期望接收到对方的字节序号"。换句话说,它的含义是"你从这里开始往下发吧",暗示"在这之前的所有字节我都收到了"。
数据是双向的,所以一条连接里,双方的每个方向都有自己的序号和确认号,各管各的。
(3)4 位 TCP 首部(报头)长度 doff
这个字段表示 TCP 首部有多少个 32 位字(即多少个 4 字节)。为什么既要有长度又要以 4 字节为单位?因为 TCP 首部带了可选的"选项"字段后长度不固定,接收方必须知道首部到哪结束、数据从哪开始。基准的 20 字节首部,doff = 5(5 × 4 = 20)。由于只有 4 位、最大是 15,所以首部最长是 15 × 4 = 60 字节,其中 20 字节是固定部分,剩下最多 40 字节是选项。这一点与源课件里"TCP 头部最大长度是 15 × 4 = 60"完全一致。
(4)6 位标志位(Flags)
这一组位开关是 TCP 控制信息的"消息灯",每个标志只有 0 或 1。六个标志分别是:
| 标志 | 名称 | 含义 |
|---|---|---|
| URG | 紧急指针有效 | 紧急指针字段是否有意义,几乎没有应用用到 |
| ACK | 确认号有效 | 确认号字段是否有意义。注意:连接建立后几乎所有报文都会置 1 |
| PSH | 推送 | 提示接收端应用层"立刻把缓冲区数据读走",别攒着 |
| RST | 复位 | 请求重新建立连接 / 异常终止当前连接,携带 RST 的段叫复位报文段 |
| SYN | 同步 | 请求建立连接,携带 SYN 的段叫同步报文段,握手第一次就是用它的 |
| FIN | 结束 | 通知对方"本端要关闭连接了",携带 FIN 的段叫结束报文段 |
这里有个读音/命名的小窝点:SYN 读作"辛",FIN 读作"芬";SYN 表示请求建立连接(是主动方发出的"我要开始"),FIN 表示我要结束(是主动关闭方发出的"我要结束")。后面三次握手、四次挥手的所有关键动作,都是这几个灯的开关。
(5)16 位窗口大小 window
表示接收方自己还能接收的数据量(接收缓冲区剩余空间),单位是字节。这是流量控制的核心字段:发送方看这个数就知道"你现在还能收多少,我就发多少"。16 位满值是 65535,但后面我们会看到,TCP 还有办法通过"窗口扩大因子"把窗口撑得更大。
(6)16 位校验和 check
发送方在发送前对所有字段(包括首部和数据)做校验计算并填入,接收方收到后同样计算校验。两边的结果对不上,就认为数据"在传输中损坏了",接收方直接丢弃,等待发送方超时重传。这里专门给你纠一个课件里容易留下偏差的说法:源课件原话写"发送端填充,CRC 校验",但其实 TCP 首部和数据的校验用的是 Internet 校验和,即对 16 位整数的二进制反码求和(one's complement sum),并不是链路层用于帧校验的 CRC。这个细节较真起来很重要,面试被问"TCP 校验和怎么算"时,回答"二进制反码求和"是加分项。
(7)16 位紧急指针 urg_ptr
配合 URG 标志使用,用来指出紧急数据在报文段内的结束位置。实际工程中很少用,理解概念即可。
(8)0-40 字节选项
选项字段承载各种扩展能力,最典型的两个我们后面还会碰到:窗口扩大因子(让窗口能超过 65535 字节)和 MSS(Max Segment Size,最大报文段长度,双方协商单次能发多少字节,避免跨过链路层大小的限制)。
思考题 1: 同样是 16 位(满值 65535),为什么 UDP 首部里的"长度"字段能直接表示报文的边界,而 TCP 里却要用到"序号"这种机制来定位?这和"面向字节流"有什么关系?
答案: UDP 是一个个完整数据报交付给应用层的,数据报长度字段告诉接收方"这一个包到此为止",边界天然清晰。而 TCP 是面向字节流的,它没有"报文中长度"这个字段去标记"这是第几个应用层包",它只有连续递增的序号,接收方靠序号把乱序到达的段重新排序,拼成一根字节流。这正好对应了两者交付模型上的本质差异:UDP 分割成一个一个独立数据报,TCP 最终只是往缓冲区里倒连续字节。
面向字节流:为什么读和写不需要一一对应
创建 TCP socket 时,内核会为这个连接同时创建发送缓冲区和接收缓冲区。这一对缓冲,就是"面向字节流"具体的栖身之所。理解它,你就理解了 TCP 的名场面。
处理流程是这样的:
- 应用程序调用
write(或send)时,数据会先写入内核的发送缓冲区,而不是立刻变成网络数据报发出去; - 如果应用一次写入的字节数太长,TCP 会把这份数据拆分成多个报文段发出去;
- 如果写入的字节数太短,TCP 会把数据先放在发送缓冲区里攒着,等缓冲区攒到差不多长度,或到了别的合适时机(例如缓冲快满、超时、或收到对方窗口通告),再一起发出去;
- 接收方向上,数据从网卡到达内核的接收缓冲区,应用程序调用
read(或recv)从接收缓冲区取走。
这一套设计带来的最重要的结果,可以浓缩成一句话:TCP 的读和写不需要一一匹配。 例如:
- 写 100 个字节,可以调一次
write(buf, 100),也可以调 100 次write(buf, 1),每次只写一个字节; - 读 100 个字节,完全不需要关心写的时候是怎么写的——你可以一次
read100 字节,也可以一次 read 1 字节、重复 100 次。
因为中间隔了一层内核缓冲区,数据是"流"过去的,不是"包"递过去的。发送方怎么拆、怎么攒,接收方读到的是同一个顺序的字节流。这也是为什么 TCP 天然是全双工的:一条连接既有发送缓冲区也有接收缓冲区,两个方向可以同时收发,互不干扰。
思考题 2: 如果说 TCP 的读写不一一对应,那是不是意味着我用 send 发送 200 字节,对方就一定会收到完整的 200 字节字节流?我能保证一次 send 对应对方的一次 recv 吗?
答案: 对方一定会收到完整的 200 字节字节流(前提是网络正常、连接未断),但不保证一次
send对应一次recv。由于面向字节流和缓冲区拆分/合并的存在,这次send(200B)可能被拆成两个段发给对方,对方的recv可能一次只取走 100 字节,需要再调recv才能拿完剩余。这就是为什么专业 TCP 应用都要"读满期望长度"或通过应用层协议确定读完数据才返回,绝不能假设"一次读写恰好对应一个逻辑请求"。
确认应答:用序号给每个字节盖章
现在我们进入可靠传输的第一块基石——确认应答(ACK)机制。为了让发送方知道"我发的东西你到底有没有到、到了多少",TCP 干了这么一件事:给每一个字节都编上号(这就是序号),接收方每收到数据,就回一个带确认号的应答。唐僧讲经也好,快递签收也好,道理是一样的:收货方回个"已经签收"的回执。
具体规则长这样:
- 发送方发出一个报文段,里面带首字节的序号;
- 接收方收到后,回一个 ACK,里面的确认号 ack_num = 对方发来的序号 + 收到数据的长度,意思就是"你下一个字节从这里发,这之前我都收到了";
- 发送方看到这个 ACK,就知道该起身发下一段了。
给个具体数字例子(假设初始 ISN 为 100):主机 A 发送一个 seq = 100、携带 100 字节数据的段。主机 B 收到后,回 ACK 报文段,ack_seq = 200。这样 A 就知道 100~199 字节 B 都收到了,下一次应从字节 200 开始发。
有一点必须强调:发送方每次发出的序号是"该段首字节在整个字节流里的绝对编号",而不是说这个连接里第几个包。 序号是全 32 位的,理论上可以从 0 数到 4294967295,循环使用。因为 IP 层可能会把数据报打乱顺序,所以接收方靠这些单调递增的序号,就能把乱序到达的段重新排好,这就是"序号保证按序到达"的含义。"按序到达"不代表网络天然有序,而是 TCP 靠序号主动把它整理成有序的。
关于序号还有一个冷门但容易考的点:SYN 和 FIN 这两个控制段虽然不带业务数据,但它们各占一个序号。也就是说,三次握手时,SYN 段虽然不包含数据字节,但它会"消耗"掉一个序号(这也是为什么握手后数据从 seq=ISN+1 开始)。这也成了解释握手 ACK 时 "+1" 的关键,下面讲握手时会看到。
至此可靠性的前两味药(序号 + 确认应答)齐了。序号解决去重和排序,确认应答解决"知道对方收没收到"。
超时重传:网络不可靠,发送方要会"兜底"
有了确认应答还不够。万一发出去的报文段,因为网络拥堵在途中丢了、或迟迟到不了对端,那对方自然也不会回 ACK。发送方不能在原地干等——它得有个"兜底"办法:如果在一段时间内收不到对方的 ACK,就认为这个段丢了,重新发一遍。这就是超时重传机制。首次亮相的术语重传,指的是发送方对没拿到确认的数据段再发一次。
但这里有一个复杂的权衡。主机 A 没收到 ACK,有两种可能:
- 发给 B 的数据段真的丢了,那么 B 确实没收到,A 该重发;
- 数据段到了,但 B 回给 A 的 ACK 在回程上丢了。这时 B 其实已经收到了数据,而 A 因为没收到 ACK 也去重发,B 就会收到重复的数据。
对第 2 种情况,TCP 必须能识别出"哪些是重复包,并把重复的丢掉"——做到这一点,靠的正是前面说的序号:接收方只要看序号,就知道这个段我之前是不是收过了(收过就扔)。这就是序号"去重"的功劳。你看,序号这一味药,同时管着"排序"和"去重"两件大事。
那么问题来了:超时时间到底定多长才合适?
- 最理想的情况,是找到一个最小的时间,保证"确认应答一定能在这个时间范围内返回",在这个基础上再发,既不冤枉也不浪费;
- 可问题在于:网络的状况随时在变,往返时间(RTT)不同网络差异很大,同一个网络不同时段也不一样;
- 如果超时设得太长,丢一个包要等很久才重传,整体重传效率低下;
- 如果超时设得太短,可能数据其实没问题、只是 ACK 稍微慢了点,你就翘首重发一堆重复包,徒增网络负担。
所以 TCP 的办法是动态计算这个超时时间,而不是写死。它要保证"无论什么网络环境都能较高性能地通信"。具体的工程做法(以 Linux 及 BSD/Windows 传统实现为例)是这样的:
- 超时是以 500ms 为一个基本单位 来控制的;
- 第一次发送后若超时未收到 ACK,等待 500ms 后重发;
- 若重发后仍没应答,再等 2 × 500ms(1000ms)重发;
- 若仍不行,再等 4 × 500ms 重发,依此类推,以指数形式递增退避;
- 当重传次数累计到一定上限,TCP 认为"网络或对端主机出问题了",强制关闭连接。
这个"指数退避"很符合生活直觉:越重发越说明网络可能越堵,越堵越不该高频轰炸,所以间隔越拉越长。另外补充一个专业细节:现代 Linux 用重传定时器配合对 RTT 的实时采样来估算 RTO(Retransmission Timeout,重传超时上限),并遵循 Karn 算法——对重传的报文段,不把它的往返时间计入 RTT 测量,以免把超时估计搅乱。理论上它比"纯 500ms 翻倍"更精细,但"500ms 起步、指数退避"这个历史模型作为理解框架依然准确,面试时答这两点都算对。
思考题 3: 如果网络环境很好,往返只要 10ms,TCP 仍然按 500ms 起算超时,会不会太慢了?
答案: 这只是传统实现"以 500ms 为最小粒度的退避模型"下的一个简化起点,实际现代实现会基于测量的 RTT 动态调整 RTO,使其贴近真实往返,短则几十毫秒、长则按指数退避拉大。即便在最朴素的 500ms 模型下,它也只是"起步值",对于绝大多数场景仍然是可接受的:因为正常传输时 ACK 都及时返回,根本不会触发超时,超时机制只是"兜底",它的存在保证了最坏情况不崩,而不是限制最好情况变慢。
三次握手:连接是怎么建立的
可靠的"逐字节确认"框架立起来了,现在要看 TCP 真正"组织关系"的地方——连接管理。TCP 建立连接走的是著名的三次握手,断开连接走的是四次挥手。术语首次出现的这两组词,就是下面两段的重头戏。
为什么连接之前非要握手?因为我得先确认两件事:你的地址能通、你对这次通信准备好了。TCP 用三次握手完成同步。先看客户端主动建立连接(socket 编程里就是客户端调 connect)时的时序图。方括号里标的是双方此刻所处的内核状态:
名义上的三次握手时序
Client(主动方) Server(被动方)
CLOSED LISTEN (调用listen后)
│ ── ① SYN (seq = x) ──────────────► SYN_RCVD (放入半连接队列)
│ │
│ ◄── ② SYN+ACK (seq = y, ack = x+1) ── │ 服务端同时确认并请求同步
│ 同时确认了① │
│ │
│ ── ③ ACK (ack = y+1) ─────────────► ESTABLISHED (移入全连接队列)
│ ESTABLISHED │
走一遍这个流程,注意力放在两个角色的状态上:
第一次握手:客户端调用 connect,向服务端发送一个携带 SYN 标志的同步报文段(简称 SYN 段),并带上一个初始序号 seq = x。客户端从 CLOSED 进入 SYN_SENT 状态,表示"我已经发出连接请求,正等着回音"。
第二次握手:服务端 listen 之后处于 LISTEN 状态,收到这个 SYN 段,就知道有客户端想连我。它向内核的连接队列里放入这条连接(此时还没真正建立,见"半连接队列"),然后同时做两件事:既确认客户端的 SYN(把 ack 设为 x+1),又发起自己的同步(发一个自己的初始序号 seq = y),于是回给客户端一个 SYN + ACK 报文段。服务端从 LISTEN 进入 SYN_RCVD(收到同步)状态,意思是"我收到了你的请求,正等着你最后确认"。
第三次握手:客户端收到服务端的 SYN+ACK,确认了"服务端已经知道我来了",于是发一个 ACK 段(ack = y+1),确认服务端的序号。客户端进入 ESTABLISHED 状态,可以读写数据了。服务端收到这个最终 ACK,也进入 ESTABLISHED,这条连接才算真正建好。
这里有个细节值得单独划重点:为什么第三次握手的 ack 是 x+1 而不是 x? 因为 SYN 段虽然不带数据,但它"消耗"了一个序号(前面讲过)。服务端期望的是下一个字节序号 x+1,所以确认号是 x+1;同理第三次握手确认服务端时写 ack = y+1。这个 "+1" 正是"SYN 占一个序号"规则的体现。
客户端视角的状态变化可记为:CLOSED -> SYN_SENT -> ESTABLISHED。服务端视角为:CLOSED -> LISTEN -> SYN_RCVD -> ESTABLISHED。
为什么必须是三次握手:不能两次,也不该四次
课件上几乎必然会有这个灵魂拷问,面试也爱问:为什么建立连接是三次而不是两次?
刨根问底,答案落在一个经典问题上——网络中可能会"迟到"的旧连接请求。考虑这个场景:
- 客户端 A 第一次想连服务端 B,发出一个 SYN 段;
- 但这个 SYN 因为网络拥堵,卡在网络的某个角落,迟迟没到 B;
- A 等超时了,以为丢了,于是撤销了这次尝试(放弃这次连接,可能重新发起新的连接);
- 结果过了一段时间,那个"挂队"的旧 SYN 突然被网络放行,送到了 B;
- B 一看来了个 SYN,就以为 A 想连自己,如果采用两次握手,B 会认为"连接已建立",傻乎乎地为一个"过期请求"分配资源、进入 ESTABLISHED;
- 但 A 那边根本不知道自己还"欠着"这条连接,收到 B 的任何数据都会当垃圾扔掉,而 B 却一直眼巴巴等着,浪费资源又造成混乱。
加一次确认(第三次握手)的妙处正在于:A 能通过"自己有没有真的发起过这次连接"来判断。A 收到这个迟来的第二次握手回应时,发现"我并没有在等这个连接",就可以不理它、甚至发 RST 复位,B 收到后放弃,从而避免"为过期请求白白建立连接"。三次握手保证了双方在"对方确实活着、确实想连我"这一点上达成同步,杜绝了历史遗留 SYN 造成的"半死连接"。
那为什么不是四次、五次,越多越保险呢?因为三次已经能可靠地确认双向的能力:
- 第一次握手:B 得知 A 能发;
- 第二次握手:A 得知 B 能收也能发(A 收到了 B 的 SYN+ACK);
- 第三次握手:B 得知 A 能收(B 收到了 A 的 ACK)。
双向的"收"和"发"都验证过了,双方对于"对方准备好了"都有了确凿证据。再多几次,只是重复确认,没有任何增量信息,徒增往返时延。所以在"可靠"与"高效"的权衡下,三次是最优解。
思考题 4: 有人问"三次握手是否可以简化为两次,因为反正第二次握手你已经带上自己的 SYN 了?"请指出这样做忽略的致命隐患。
答案: 忽略的正是"迟到的旧 SYN"问题。如果用两次握手,服务端一旦发出自己的 SYN+ACK 就把连接当成 ESTABLISHED 并开始分配资源、往缓冲区和队列里灌东西。可当这个第二次握手是回应一个"早已过期的旧 SYN"时,客户端根本没在发起连接,于是服务端白白建立了一条没人要的连接,占着宝贵的资源,直到超时才被清理。这是对资源(尤其半连接/全连接队列与内存)的浪费,也存在被攻击者利用的空间。第三次握手让服务端"收到对方的确认才算数",从而把这类误建连接消灭在源头。
SYN 洪水:半连接队列被攻击者塞满
讲过三次握手,顺带揭开一个著名的网络攻击面——SYN 洪水(SYN flood)。它利用的正是第三次握手完成前"连接并未真正建立"这个窗口。
关键点在于握手过程的两个连接状态都发生在一一对应的两个队列里:
- 半连接队列(SYN 队列):服务端第二次握手(发完 SYN+ACK)后、还没收到第三次握手 ACK 之前,这条连接待的地方。
- 全连接队列(Accept 队列):第三次握手完成、进入 ESTABLISHED 之后待的地方。socket 程序里
accept取走的正是这个队列里的连接。
攻击者不断地伪造源 IP,发送大量只有第一次握手(SYN)的报文段,让服务端一次次进入 SYN_RCVD、把半连接塞进 SYN 队列后期待"永远等不到的第三次握手"。由于每个半连接都要占内存,而队列容量有限,当 SYN 队列被打满,正常的客户端连 SYN 段都排不进去了,服务端就"拒绝服务"了——这就是 SYN 洪水的杀伤力所在。它面向的名字也由此而来:SYN 洪水。
防御手段有若干:
- 缩短 SYN 半连接的保留时间,加速清理;
- 限制半连接数阈值,超过就丢弃;
- 现代 Linux 内核最常用的是 tcp_syncookies(SYN Cookie)机制:不真的在半连接队列里存大量状态,而是把该连接的关键信息"烘焙"进
SYN+ACK的序号里作为"饼干"返回。等对方真的回第三次握手时,把回执里的信息解出来验证,只有验证通过才真正分配连接状态。这样就无需预先占用队列,天然免疫了这种"只发 SYN 不回 ACK"的轰炸。
如果你在服务器上看到 /proc/sys/net/ipv4/tcp_syncookies 的值为 1,说明 SYN Cookie 已开启。这一节作为三次握手的延伸,帮你在生产环境"被攻击"时不至于一脸懵;它不属于课件必须讲死的范围,但属于面试与运维里的高频词。
四次挥手:连接是怎么断开的
见面容易送别难。TCP 断开连接要走四次挥手。先看一个典型场景:客户端主动调用 close 断开连接。
四次挥手时序(客户端主动关闭)
Client(主动方) Server(被动方)
ESTABLISHED ESTABLISHED
│ ── ① FIN (seq = u) ───────────────► │ 进入 CLOSE_WAIT
│ 进入 FIN_WAIT_1 │ (等待自己应用处理完收尾数据)
│ │
│ ◄── ② ACK (ack = u+1) ─────────────── │ 同时回 ACK 确认
│ 进入 FIN_WAIT_2 │
│ (等待对方的 FIN) │
│ │ 服务端应用处理完,调用 close:
│ │ 进入 LAST_ACK
│ ◄── ③ FIN (seq = v) ───────────────── │
│ 进入 TIME_WAIT │ 进入 LAST_ACK
│ (等待 2MSL 才彻底关闭) │
│ ── ④ ACK (ack = v+1) ───────────────► │ 收到最终 ACK
│ │ 进入 CLOSED
│ 2MSL 之后进入 CLOSED │
下面逐挥解释,并同步标出两端的状态迁移——这是本节最容易考、也最重要的一张地图:
-
主机主动关闭的一侧(假设是客户端):
- CLOSED -> SYN_SENT(建连时,非挥手阶段,列在此处方便对照)
- ESTABLISHED -> FIN_WAIT_1:客户端调用
close,向服务端发送结束报文段(FIN),进入 FIN_WAIT_1,等待服务端对它的确认; - FIN_WAIT_1 -> FIN_WAIT_2:收到服务端对 FIN 的确认后进入 FIN_WAIT_2,此时客户端自己的"发送方向"已经关了,但还能收数据,它开始等服务端的结束报文段;
- FIN_WAIT_2 -> TIME_WAIT:收到服务端发来的 FIN 后,进入 TIME_WAIT,并发出对服务端 FIN 的确认(这个最终 ACK 也有人称之为 LAST_ACK,注意别和服务端那个叫 LAST_ACK 的状态搞混,这里指的是报文);
- TIME_WAIT -> CLOSED:等待一个 2MSL(下面专讲)的时间之后,才真正进入 CLOSED。
-
主机被动关闭的一侧(假设是服务端):
- ESTABLISHED -> CLOSE_WAIT:收到客户端的 FIN,号角被吹响——客户端想关了。服务端先回一个确认报文段(ACK),然后进入 CLOSE_WAIT,意思是"我已经知道你要关了,但我自己还有数据要处理/要发送,需要一段时间才能准备好关门";
- CLOSE_WAIT -> LAST_ACK:服务端应用层处理完剩余数据、真正调用
close关闭 socket 时,向客户端发送 FIN,进入 LAST_ACK 状态,等待最后那个 ACK; - LAST_ACK -> CLOSED:收到客户端对这条 FIN 的确认,彻底关闭连接。
这里会出现一个新手常见视觉矛盾:客户端在 FIN_WAIT_2 -> TIME_WAIT 时说"发出 LAST_ACK",服务端状态里也有个 LAST_ACK。区分方式是——LAST_ACK 指的是服务端的状态名,代表"在等待最后一个 ACK";而主动关闭方发出的那最后一个确认报文,叫"最终 ACK"即可。别因为它们名字相近就懵掉。
为什么必须有四次挥手:从全双工说起
为什么断开非要四次?先记住一个基本面:TCP 是一条全双工的连接,两个方向是独立的。所以"关闭连接"这件事,本质上是两个方向的、各自独立的关闭。A 关掉 A→B 方向,只代表 A 这边不再发数据了,并不代表 B 不能再往 A 发;B 关掉 B→A 方向同理。这两个方向要各走一遍"发 FIN -> 收 ACK"的过程,加起来就是四次。
更关键的是:被动方无法一收到 FIN 就立刻回 FIN。它收到 FIN 后,可能还有一堆应用层数据没发完,或者还要做收尾处理。所以它只能先回一个 ACK(表示"我知道你要关,我配合"),等自己真的处理完剩余数据、调用 close 时,再发自己的 FIN。这就把原本"可以在第二次挥手的回应里顺带捎上一个 FIN"的可能性给拆开了——被动方回 ACK 和发 FIN 这两个动作,在时间上被拉开了,于是从"可以两次"变成了"非四次不可"。
对照一下就会更清楚:
| 动作 | 为什么不能合并 |
|---|---|
| 挥手① 主动方发 FIN | 必需,开启关闭流程 |
| 挥手② 被动方回 ACK | 必需,确认收到关闭请求 |
| 挥手③ 被动方发 FIN | 必须等应用层处理完才能发,和②隔了很久,无法合并 |
| 挥手④ 主动方回 ACK | 必需,确认收到对方的 FIN |
一句话理解:建连时两端都"没东西要发,可以一口气成交"(所以三次就够);断连时被动方"还有尾巴要处理",ACK 和 FIN 拉不开,只能老老实实分成两步。 这就是"三次建连、四次断连"的底层原因。
思考题 5: 四次挥手里有一个环节容易出 bug:被动方如果一直不退 CLOSE_WAIT,会怎样?这通常是什么原因造成的?
答案: 被动方一旦滞留 CLOSE_WAIT,说明它"迟迟没有调用 close 发 FIN",第四次挥手也就永远完成不了,这条连接会成为"半死"的连接,占着 socket 和文件描述符不释放。大量 CLOSE_WAIT 会在高并发服务器上耗尽文件描述符,导致新连接无法 accept。最常见的原因就是应用层代码忘了 close:比如循环结构里
new_sock(accept 出来的连接 socket)没有被关闭。这正对应课件里那个"去掉 new_sock.Close() 后出现大量 CLOSE_WAIT"的实验,修复就是补上 close(详见后面 CLOSE_WAIT 专题)。
减半关闭:shutdown 与 close 的分工
挥手讨论到这里,有一个工具必须出场——半关闭。先厘清概念:所谓半关闭,就是只关闭一个方向的数据传输,另一个方向还能继续用。这是 TCP 全双工性质赋予的特殊能力。
socket 编程里,close 关闭整个连接(两个方向的读写都会断),而 shutdown 可以只关一个方向:
shutdown(sock, SHUT_WR):关闭写方向(发送侧),相当于主动发出 FIN,告诉对方"我不再发数据了",但读方向仍然活着,我还能收你的数据;shutdown(sock, SHUT_RD):关闭读方向;shutdown(sock, SHUT_RDWR):两个方向都关,类似 close。
它在工程上的价值在 HTTP 一类的"请求-响应"模型里尤其明显:客户端发完请求后,可以先 shutdown(SHUT_WR) 告诉服务端"请求发完了,你开始处理并回响应吧",但客户端还继续 read,等着收服务端的响应。这样既明确地表达了"我不再发了",又保留了"我还在听"。用好半关闭,是写出健壮 TCP 应用的基本功。
对应到四次挥手的状态图上,主动方发的那个 FIN,本质就是 shutdown(SHUT_WR) 语义的体现——FIN 只宣告写方向的结束,而读方向要等对方也发 FIN 才结束(所以主动方要经历 FIN_WAIT_2 漫长的等待)。
TCP 状态转化全图
把上面所有状态连起来,就是一张 TCP 状态机。把课件里那张"服务端粗虚线、客户端粗实线"的总图,用纯文本复刻给你。CLOSED 其实是个"假想"的起始/终止点,本身不是一个真实存在、能被你观测到的状态,它只表示"连接数据已清空":
(CLOSED 是假想起点/终点,不是可观测的真实状态)
(“→”箭头上的标注为触发该转移的事件。)
客户端主动建连: 服务端建连:
CLOSED CLOSED
│ connect()(发 SYN) │ listen()
▼ ▼
SYN_SENT LISTEN
│ 收到 SYN+ACK │ 收到 SYN → 回 SYN+ACK
│ (发 ACK) ▼
│ SYN_RCVD
│ │ 收到 ACK
▼ ▼
┌────────────────────────────┐
▼ ▼
ESTABLISHED(可双向 read/write)
│
· 双方主动关闭的分支 · 单边主动关闭的分支
├── A、B 几乎同时 close └── 仅一方 close:
│ 双方都走 FIN_WAIT_1 主动方 ESTABLISHED → FIN_WAIT_1
│ ↑ 若在收到对方 ACK 前 · 被动方 ESTABLISHED → CLOSE_WAIT
│ 先收到对方 FIN, (回 ACK,等应用层 close)
│ 则双方都进入 CLOSING · 被动方真正 close → 发 FIN
│ 等收到对方 ACK 后再进 → LAST_ACK(等主动方最终 ACK)
│ TIME_WAIT · 主动方收到对方 FIN → 回最终 ACK
└──► TIME_WAIT(皆需 2MSL) ◄──────── → 进入 TIME_WAIT;被动方收到最终
│ 2MSL 后 ACK → CLOSED
▼
CLOSED
图比较密,我们把上面的状态机重点拆成一张对照表(这是理解状态机最有效的方式),每个状态标注它的角色与下一步:
| 状态 | 出现在哪一段 | 含义/去向 |
|---|---|---|
| CLOSED | 假想起点/终点 | 无连接可言(CLOSED 不是可观测的真实状态) |
| LISTEN | 服务端建连前 | 调用 listen 后,等待客户端连接 |
| SYN_SENT | 客户端建连 | 客户端已发 SYN,等待服务端 SYN+ACK |
| SYN_RCVD | 服务端建连 | 收到 SYN、发出 SYN+ACK,等最后 ACK |
| ESTABLISHED | 建连成功 | 连接建立,可以读写数据 |
| FIN_WAIT_1 | 主动关闭方 | 已发 FIN,等待对方确认 |
| FIN_WAIT_2 | 主动关闭方 | 已收对方对 FIN 的确认,等待对方 FIN |
| CLOSING | 同时关闭 | 两边同时发 FIN,都处于等对方确认的过渡态(见下节) |
| CLOSE_WAIT | 被动关闭方 | 收到对方 FIN,已回 ACK,等待自己应用层 close |
| LAST_ACK | 被动关闭方 | 已再发 FIN,等待主动方最终 ACK |
| TIME_WAIT | 主动关闭方 | 已发最后 ACK,等待 2MSL 后彻底释放 |
面试官十有八九会让你画状态机图与会话。能把上面这张表熟练串起来,配合三次握手、四次挥手的时序,就稳了。
TIME_WAIT:主动关闭方为什么迟迟不肯退出
TIME_WAIT 是网络工程师和 C 语言老手最常打交道的状态之一,其中藏着一个成本极高的规则:主动关闭连接的一方,必须停留 TIME_WAIT 状态,等待 2MSL(最大报文段生存时间的两倍)后,才回到 CLOSED。
先解释 MSL。MSL 全称 Maximum Segment Lifetime(最大报文段生存时间),指一个 TCP 报文段在网络中存活的最长期限——超过这个时间,报文段就会被认为"寿终正寝"、彻底消失。RFC 1122 里给出的参考上限是两分钟,但那是给"最坏情况"的兜底,各操作系统实际取值不同:Linux(CentOS 7 / Ubuntu)上 MSL 通常取 30 秒,于是 2MSL = 60 秒,这就是现实中你能观测到 TIME_WAIT 大概停留一分钟的原因。
课件原文提到用 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看"MSL 的值",这里给你做一个更严谨的校正:tcp_fin_timeout 这个参数实际控制的是 FIN_WAIT_2 状态的超时时长(默认 60 秒,即半关闭后主动方最多等对方 FIN 等多久),并不是直接可写的 MSL。Linux 内核里,MSL 对应的宏体(TCP_TIMEWAIT_LEN)是 60 秒,这正好就是 2MSL,即 TIME_WAIT 通常持续 60 秒。理解到这个层面就够了。
那么灵魂拷问来了:为什么必须是 2MSL? 课件和业界公认两条理由:
理由一:保证最后一个 ACK 可靠到达对方。 回想四次挥手的第四步——主动方发出的最终 ACK。这个 ACK 在回程上也可能丢。假设它丢了,被动方(LAST_ACK)迟迟等不到确认,就会重发它的 FIN。如果主动方已经早早进入 CLOSED、连接信息全销毁了,那就没人能再回这个 FIN 的确认,被动方就永远卡在 LAST_ACK。而如果主动方在 TIME_WAIT 里多待 2MSL,此时它的连接其实还"活着",一旦收到被动方重发的 FIN,还能再次回 ACK,从而保证被动方也能正常关闭。一个 MSL 是从被动方重发到主动方收到的最坏时间,给两个方向各留一个 MSL,正好 2MSL。
理由二:确保网络上两端传输方向上"尚未被接收或已经迟到的重复报文段"全部消散。 如果连接结束后立刻把同一个端口地址重新起用,很可能会收到"上一个连接"遗留的迟到重复数据,这些"幽灵数据"若被误当成新连接的数据,会让新连接挂在严重错误上。停留 2MSL,就能保证无论哪一方向的旧报文段,都在网络里消散殆尽(一个 MSL 足够一个方向上的报文彻底消失,两个方向各一个,所以 2MSL),不给新连接留雷。
思考题 6: 我用 Ctrl-C 结束 server 后,立刻重新运行 server,结果报"bind 失败:Address already in use"。这是为什么?和 TIME_WAIT 什么关系?
答案: Ctrl-C 终止的是应用进程,但协议层的连接并没有立刻消失。Ctrl-C 会触发进程退出时对 socket 的关闭,使 server 成为主动关闭的一方,于是它的连接要进入 TIME_WAIT 并停留 2MSL(约 60 秒)。TIME_WAIT 期间虽然进程不在了,但该连接的五元组(server 的 IP、端口是固定的)依然被协议栈记着,所以同一端口不能立刻重新 bind 监听。你等上一分钟左右再启动 server 通常就能成功,或者用下面要讲的
SO_REUSEADDR来豁免。
被 TIME_WAIT 卡住的 bind:SO_REUSEADDR 解法
这种"Connection refused / Address already in use"在开发时极其折磨人,更别提高并发服务器在生产环境会积攒海量 TIME_WAIT。要讲透应对之道,先看清问题是怎么被放大的:
- 服务器要处理非常大量的客户端连接,每个连接生存期很短,但每秒都有大批客户端来请求;
- 如果由服务器主动关闭连接(比如某些客户端不活跃,被服务器主动清理),服务器就会成为主动关闭的一方,从而产生大量 TIME_WAIT 连接;
- 每个 TIME_WAIT 连接都占着一个 通信五元组(源 IP、源端口、目的 IP、目的端口、协议)。其中服务器那一侧的 IP、端口、协议都是固定的,能变化的只有客户端的 IP 和端口;
- 当新来的客户端恰好复用了某个"正被 TIME_WAIT 占用"的(客户端 IP, 客户端端口)组合时,就撞车了,新连接建立会遇到麻烦。
解决的手段是在服务端对 listen 用的 socket 设置 SO_REUSEADDR 选项。当年课件里的说法是"允许创建端口号相同但 IP 地址不同的多个 socket",这句可以看成它的一个侧面,但更本质的作用是:允许一个 socket 绑定到正处于 TIME_WAIT 状态的本地地址和端口。这样服务端主动关闭、连接牌 TIME_WAIT 还没散场时,新监听也能立刻复用同一个端口。
代码写法,以 Linux socket 为例:
int yes = 1;
/* 允许重用处于 TIME_WAIT 的端口,避免重启服务器时报 Address already in use */
// 注意:该调用要在 bind 之前完成
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));在 python 里则对应:
# Python 版:sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 也须在 bind 前
import socket
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 9090))
s.listen(5)要注意几点边界:SO_REUSEADDR 只豁免 TIME_WAIT 状态的端口占用,它并不豁免别的状态;同时要分角色——它通常对"服务端(被动端)要再次监听"的场景有效;而**客户端(主动端)**若要复用由自己 TIME_WAIT 占用的(源 IP, 源端口)组合,需要的是另一个选项 SO_REUSEPORT 或在能容忍时配合设置。另外,同一份 SO_REUSEADDR 名字在不同平台的语义还会有细微出入(比如和地址绑定、时间等待可重用等纠缠),但本课你只需吃透它在 TIME_WAIT 上的用途,就够应付绝大多数场景了。
CLOSE_WAIT:服务器最常见的"僵尸状态"bug
与 TIME_WAIT 对应的经典坑,是 CLOSE_WAIT。它是被动关闭方(通常是服务器)的状态。一旦大量连接停在 CLOSE_WAIT,几乎可以断定:应用层忘了 close。
回顾课件里的实验:写一个 TCP echo 服务器,把 accept 出来的连接 socket new_sock.Close() 那句去掉。正常收发时没什么异样,但观察别的主机关闭客户端程序之后的连接状态会发现:
$ netstat -an | grep 9090
tcp 0 0 0.0.0.0:9090 0.0.0.0:* LISTEN 5038/./dict_server
tcp 0 0 127.0.0.1:49958 127.0.0.1:9090 FIN_WAIT2 -
tcp 0 0 127.0.0.1:9090 127.0.0.1:49958 CLOSE_WAIT 5038/./dict_server
客户端那侧停在 FIN_WAIT2,服务端这侧停在 CLOSE_WAIT。回忆四次挥手:客户端发 FIN、服务端回 ACK 进入 CLOSE_WAIT,正常应紧接着由服务端调用 close 发 FIN 收尾,可现在因为代码里没有 close,服务端不得不一直停在 CLOSE_WAIT。于是四次挥手死在这里,两边都僵住了。
这正是我们要在代码层面认真对待的规范:
- 服务端在 accept 得到连接后,处理完业务必须负责关闭它(除非有特殊复用机制);
- 读循环
Recv失败(对端关闭)后,立刻new_sock.Close()再break; - 一旦发现生产环境里
ss -st或netstat出现大量 CLOSE_WAIT,九成原因是服务端忘记 close 连接的 socket。这就是一个货真价实的 BUG,把它补上即可。
这个例子给了我们极聪明的反向利用:很多网上教程都把"TIME_WAIT 太多"和"CLOSE_WAIT 太多"混为一谈,但它们在语义上完全相反——TIME_WAIT 多通常说明主动方关闭得"很规范"(尤其服务端主动清理);CLOSE_WAIT 多几乎总是"该关没关"的代码缺陷。能一句话分清这两者,面试就赢了一半。
CLOSING 与同时关闭:双方一起说拜拜
课件把 CLOSING 状态定为"课后调研",这里直接填坑。CLOSING 是一个比较罕见、但逻辑上很有意思的状态,它只在"双方几乎同时发起关闭"时才会出现。
想象 A、B 都想结束这段关系,几乎同时各自调用 close、各自发出 FIN:
- A 发 FIN 进入 FIN_WAIT_1;
- 与此同时 B 也发 FIN 进入 FIN_WAIT_1;
- A 在还没收到 B 对 A 的 FIN 的确认之前,就先收到了 B 发来的 FIN。此时 A 会先回一个 ACK 确认 B 的 FIN,然后进入 CLOSING 状态——意思是"我发了 FIN 等你确认,你也发了 FIN 且我已经确认了,现在我要等你的 ACK 来确认我的 FIN"。B 那边情况完全相同,也进入 CLOSING;
- 之后 A 收到 B 对 A 的 FIN 的确认,进入 TIME_WAIT;B 同理,也进入 TIME_WAIT;
- 各自等过 2MSL 后 CLOSED。
正常"一方主动"的挥手不会出现 CLOSING,只有对称的同时关闭才会。你不用背它背得头皮发麻,只需记住它是"同时关闭时的过渡状态",并知道双向同时 FIN 时双方都会短暂经过它,就能应对最常见的考察了。
思考题 7: 同时关闭时,双方都会经过 CLOSING,那请问最后谁是"主动关闭方"?都要进 TIME_WAIT 吗?
答案: 同时关闭时,两方互相确认了对方的 FIN,因此双方都会进入 TIME_WAIT(因为每一方都承担了"主动发出 FIN 并等待对方确认"的角色)。也就是说这种极端情况下会出现两条 TIME_WAIT 连接,各守各的 2MSL。这与"一方主动"的典型四次挥手里只有主动方进 TIME_WAIT 形成鲜明对比。区分"谁进 TIME_WAIT"的口诀很简单:谁先发出 FIN(主动关闭方),谁进 TIME_WAIT;同时关闭则两方都算主动,所以都进。
滑动窗口:一发一收的传输效率太低
可靠性的骨架搭好了,现在进入性能的话题。前面"确认应答、超时重传"那一套,从字面上看是"发一个,等 ACK,再发下一个"——这种一发一收的方式,在往返时间(RTT)很长时,会把效率拖到惨不忍睹。想想跨洋链路,一个往返动辄数百毫秒,如果发一个等一个,带宽再高也白搭。
痛点在于:每个段都在"干等"一个确认,众多段的等待时间被串行了,无法重叠。 于是 TCP 引入滑动窗口机制,一次发多个段,把这些等待并行压进同一段时间里。
课件里的例子:窗口大小 4000 字节 = 4 个段(假设每个段 1000 字节)。规则是:
- 窗口大小指的就是"无需等待确认应答,可以继续发送的数据的最大量";
- 发送前四个段的时候,不需要等任何 ACK,直接连续发出去;
- 收到第一个 ACK 后,把滑动窗口整体向后(向右)移动,再继续发送第五个段,依此类推——窗口"滑"过去,名字就是这么来的;
- 内核为维护这个窗口,需要在发送缓冲区里记录"还有哪些数据没有应答";只有被确认过的数据,才可以从缓冲区删掉;
- 直观结论:窗口越大,网络吞吐率越高。
滑动窗口让人终于能把带宽真正用起来了。我们继续——当你收到 ACK 1001,说明 01000 已确认,窗口右移,发送端就能继续发送序号 40015000 的新数据了。发送端能发的数据范围,有一个很典型的"窗口视图":
已发送已确认 已发送未确认 可立即发送 将来才能发送
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ……
└───────────┴─────────────────┘
← 这个区间就是“滑动窗口” →
←─ 确认后右移,区间整体前移 ──
这个窗口不是固定不变的。它右端不断扩展(因为又有新 ACK 回来),左端不断前移(因为确认清除),最终效果就是窗口"滑"着移动。这是 TCP 提高吞吐的第一个大杀器。
三种丢包形态与快速重传
滑动窗口方案在链路干净时非常高效,可一旦丢包,就轮到重传机制发力了。丢包主要分三种形态,处理策略各不相同:
形态一:ACK 丢了(数据已到达)。 比如发送端发了 1001、2001、3001、4001 四段,中间的 ACK 2001 在半路丢了。没关系——因为 TCP 用累计确认,后面的 ACK 3001 一上来,发送端自然知道"3001 之前我都收到了",于是把丢掉的中间确认“一笔勾销”。所以部分 ACK 丢失并不要紧,可通过后续 ACK 递补确认。
形态二:某个数据段直接丢了。 比如序号 10012000 这一段在途中消失,而后面的 20013000、30014000 都顺利到了接收端。这时接收方会持续回"2000 这一段丢了,于是立刻重发这段数据。这就是快速重传(课件里叫"高速重发控制",也叫 fast retransmit)。它相比"死等超时"的优势在于"快"——不必挨满 500ms 或更长的超时,收到三个重复 ACK 就动手。而接收端在收到重传的 1001 之后,因为 2001~7000 这些数据之前其实已经收到、暂存在接收缓冲区里了,所以它这次返回的 ACK 会直接跳到 7001,把紧跟着的、早就收到的所有数据一并确认掉。ack = 1001",就像不断提醒发送端"我想要的是 1001"。如果发送端连续三次收到同样的 ack = 1001,那么它不需要等超时,就可以推断出 1001
形态三:超时重传。 如果一个段丢了,但又没有触发"三次重复 ACK"那条快速通道,发送端就只能等 RTO 超时后走经典的超时重传(前面讲过指数退避)。一般而言:少量丢包,可能只触发超时重传;成片丢包(判断为拥塞),则要联动拥塞控制(下面讲)大幅降速。
思考题 8: "收到三个重复的 ACK"为什么能说明"数据丢了",而不是"网络只是慢"?直接用超时重传不就行了,为什么还要搞快速重传?
答案: 三个重复 ACK 传递了一个很强的信号:接收端明明还在正常收后续数据(否则它不会持续回 1001),这说明链路还通、只是中间的某一段确实没了。这是比"超时"更具体、更早的“丢包证据”,因此可以立即介入重传。而超时重传是较粗略的最后手段,它无法区分是拥堵还是丢包,只会做指数退避。快速重传的价值就是把真实丢包的重传时延从"等满 RTO"压缩到"三个 ACK 的往返时间",显著减小吞吐的抖动;同时严格来说,真正的快速重传在触发后还应联动拥塞控制降窗(ssthresh 减半等),这是它与纯超时重传在行为深度上的差异。
流量控制:让发送速度听从接收方的安排
滑动窗口解决了"发送端不等待"的问题,但它没解决另一个关键矛盾:接收方的处理能力是有限的。如果发送端一股脑发太快,把接收方缓冲区打满,接下来的数据只能排队、溢出、再触发丢包重传,陷入连锁反应。TCP 需要用一种机制来根据接收端的处理能力,动态调整发送端的发送速度——这就是流量控制(Flow Control)。
它的实现非常直白,几乎全部依托于前面报文段格式里的"16 位窗口大小"字段:
- 接收方把自己接收缓冲区剩余的可用空间,放进每次 ACK 里的"窗口大小"字段,随着 ACK 一起通告给发送方;
- 窗口字段越大,说明接收方越吃得下,网络的吞吐量越高;
- 接收方一旦发现缓冲区快满了,就把窗口大小设成更小的值通知给发送方;
- 发送方看到窗口变小,就放慢自己的发送速度,让节奏贴合接收方的消化进度;
- 如果接收方缓冲区彻底满了,就会把窗口置为 0。此时发送方停止发送数据,但为了不至于永远等不到"窗口开了"的通知,发送方需要定期发送一个窗口探测数据段,去询问接收方"窗口刷新了吗",接收方借此把新的窗口大小告诉发送方,双方才能恢复通信。
一句话记住要点:流量控制管的是"接收方能吞多快",放哨的指标就是对方 ACK 里的窗口大小。
思考题 9: 课件里有个疑问:"16 位数字最大表示 65535,那么 TCP 窗口最大就是 65535 字节吗?" 请给出答案并解释。
答案: 不是,实际可以更大。TCP 首部 40 字节的可选字段里,含有一个窗口扩大因子 M(window scale,在三次握手的 SYN 段里协商)。真正的接收窗口大小 = 窗口字段的值左移 M 位(即 × 2 的 M 次方)。Windows/Linux 上把 M 协商到最大 14 位,就能把"16 位字段"扩展到最多 1 GB 量级,从而支撑高带宽长时延链路(带宽延迟积大)下的高吞吐。这也是为什么高带宽网络下必须使用窗口缩放,18 年前老式 TCP 那种"最多 64KB"的窗口在现代网络里是远远不够的。
拥塞控制:别在某时刻给网络雪上加霜
滑动窗口让发送方能高效发数据,流量控制让发送方贴合接收方消化速度,听起来已经很完善了。但还缺一块:网络那么大、那么多台主机对路,你现在这个连接往网络上灌多少数据,不仅关系到你自己,更关系到沿途所有其他连接。 在不清楚当前网络到底拥堵不拥堵时,贸然把数据量拉满,很可能给本来已经拥堵的网络"雪上加霜",引发雪崩式的丢包和重传。于是 TCP 引入拥塞控制,核心思想是:先发一点探探路,摸清网络当前的脾气,再决定以多快的速度传输。
拥塞控制引入一个新名词——拥塞窗口(cwnd)。发送端"实际能发的窗口" = min(接收方通告的窗口, 拥塞窗口),取两者较小的那个。拥塞窗口是"发送端对网络承载能力的估计",它由发送端自己根据网络反馈动态调整。整个策略叫慢启动(slow start),术语里"慢"恰如其名:
- 连接开始的时候,把拥塞窗口初始化为 1(很小,探路);
- 每收到一个 ACK,拥塞窗口加 1;
- 每发送数据包时,拿
拥塞窗口和接收方通告的窗口取较小者作为实际发送窗口。
看到这里先别急着纠结:课件里说"每收到一个 ACK 窗口加 1"会让窗口指数级增长(发 1 段收 1 个 ACK → cwnd 变 2 → 一批发 2 段收 2 个 ACK → cwnd 变 4……)。所以哪怕叫慢启动,它也仅仅指"初始时很慢",增长速度其实非常快,呈指数级。
为避免窗口无限飙,拥塞控制再引入一个慢启动阈值 ssthresh:
- 当拥塞窗口没超过 ssthresh 时,按上述指数方式增长(慢启动阶段);
- 一旦拥塞窗口超过 ssthresh,就不再翻倍了,改为每一个 RTT 只加 1 个段的线性增长(这阶段叫拥塞避免);要理解"慢启动阈值等于窗口最大值"就好:TCP 刚开始时,把 ssthresh 设成窗口的最大值。
那么,什么时候意识到"哦,网络开始堵了"?答案在丢包信号里:
- TCP 开始启动时,慢启动阈值 = 窗口最大值;
- 每次发生超时重传(认为网络拥塞)时,把 ssthresh 降为原来的一半,同时把拥塞窗口重置回 1,从头再来一遍慢启动。这条"减半 + 重置"的配合,让 TCP 在检测到拥塞后立刻大幅退缩、再逐步试探着爬回来,形成"锯齿状"的吞吐曲线。
更细来讲,故障的分级处理是:少量丢包往往只触发超时重传;大量丢包(判断为网络拥塞)除了触发重传,还会联动 ssthresh 减半、cwnd 归 1,进入"慢启动-拥塞避免"的循环。此外现代 TCP 还有快速恢复等更细腻的机制,不作为本课重点。
用一句人话概括拥塞控制的本质:TCP 想尽可能快地把数据送给对方,却又得避免给整个网络造成太大压力——它是"传输欲望"与"网络承载力"之间的一个折中方案。 课件里那个"TCP 与热恋"的比喻很贴切:刚开始小心翼翼地靠近(slow start),然后越处越熟、越来越放开(窗口指数涨),一旦碰壁就知趣地收敛(拥塞降窗),再重新培养信任(重开慢启动)。
延迟应答与捎带应答:两个廉价的提速技巧
在滑动窗口、拥塞控制的"大件"之外,TCP 还有两个小而美的技巧,都服务于同一目标——在保证不折腾网络的前提下,把吞吐顶上去。
延迟应答(Delayed ACK)。 思考这个场景:接收方缓冲区 1M,一次收到 500K 数据。如果接收方立刻回 ACK,它此刻通告的窗口就是 500K;但如果接收端的应用程序其实处理得飞快——比如 10ms 就把这 500K 从缓冲区消费完了——那你立刻应答就"白白浪费了"本就有的 1M 吞吐能力。所以接收方稍微等等再应答,比如等 200ms,这时缓冲区已空出一大截,它回的窗口就能是满的 1M。窗口越大,吞吐越高,这就是延迟应答的动机。 但要守住抑制住的边界——并不是所有包都能无限延迟应答:
- 数量限制:每隔 N 个包(一般取 N=2)就应答一次;
- 时间限制:超过最大延迟时间(比如 200ms)就必须应答一次,免得把发送方憋出超时。
具体 N 和时间因操作系统而异,记住"数量 + 时间双保险"这个框架即可。
捎带应答(Piggyback ACK)。 应用层很多协议是"一发一收"的问答式:客户端发"Hello/How are you",服务器回"Fine, thank you". 那么服务器回给对方的应用数据,就可以"搭个顺风车"——在它发响应数据的时候,把本应单独发的 ACK 一并捎带上,合并在同一个报文段里发送。这样既确认了对方的数据,又顺手回了业务响应,省掉一个纯 ACK 的往返。它的前提是"有应用层数据要回",这也是为什么问答式协议里能自然用上它。
这两个机制都是"几乎零成本、却能显著提升吞吐"的优化,理解了它们看抓包时就不会对着"为什么不立刻 ACK"犯迷糊。
粘包问题:字节流没有边界
讲了面向字节流、缓冲区、延迟应答、捎带应答,终于引出了应用层程序员最常栽的坑之一——粘包问题。
先定义清楚这里的"包"指代谁。粘包问题里的"包",指的是应用层的数据包,即"一个完整的逻辑请求/响应"(比如一次请求结构体)。TCP 有没有办法告诉应用层"这一个包到此为止"?回忆报文段格式,TCP 首部里没有像 UDP 那样的"报文长度"字段,只有序号。
从传输层视角看,TCP 是一个一个报文段过来的,靠序号排序后放进缓冲区;但从应用层视角看,它只能看到一根连续的字节流,根本无从分辨"这段到哪是一个完整的应用层包"。举例来说,客户端一次性 write 了 "hello" 和 "world" 两次,对方一次读到的可能恰好是 "helloworld" 连在一起——你看不出来这是两个还是四个应用层包。这就是粘包(以及狭义的半包/分包)问题的来源:应用层拿到的是一条没有边界的字节流。
那么怎么避免?归根结底一句话:明确两个应用层包之间的边界。 三种成主流手段,任选其一:
- 定长包:如果应用层包是固定长度(比如一个固定结构的
Request),那么从缓冲区开头按sizeof(Request)依次读取即可,每个块就是一个完整的包。课件里的Request结构就属此类; - 包头带长度:对于变长包,在包的最前面约定一个"包总长度"字段,接收端先读这个长度,就知道整个包到哪结束——这是最常用、最健壮的方案(HTTP 的
Content-Length就是典型); - 分隔符:对变长包,在包与包之间使用一个明确的分隔符(应用层协议由程序猿自己定义,只要保证分隔符和正文内容不冲突即可)。缺点是正文里若出现分隔符要转义。
顺带回应课件里的思考题:UDP 到底存不存在"粘包问题"? 答案是不存在。因为:
- UDP 把完整的数据报一个个交付给应用层,首部里有明确的"报文长度",边界无从混淆;
- 站在应用层看 UDP,要么收到一个完整的 UDP 报文,要么就不收(被丢弃那个),绝不会出现"半个"这种分割重叠情况。
所以"粘包"本质上是"面向字节流 + 无边界"的 TCP 专属麻烦,而 UDP 天然以"整体报文"为交付单位,没有分界难题。面试时能一句话说清"粘包是 TCP 面向字节流导致的、需要应用层通过边界约定解决,UDP 没有"就算拿下了。
思考题 10: 有人说"粘包是 TCP 协议本身的 bug",你怎么看?
答案: 这不是 TCP 的 bug,而是面向字节流设计带来的固有语义。TCP 从设计之初就明确"提供字节流、不保证应用消息边界",这是为了性能和通用性而做的有意取舍——它把"怎么划分业务消息"这件事完全交给应用层协议去定义。所以正确的态度不是吐槽 TCP,而是在应用层协议层面解决(定长、长度字段、分隔符皆是为此,像 HTTP 这种成熟协议都自带边界约定)。UDP 因为以完整数据报为交付单位所以天然无此困扰。
TCP 在异常面前的表现
前面都是"正常情况",真正区分老手和新手的,往往是异常。TCP 在几种异常下如何表现,分四类:
- 进程终止:进程挂掉会触发释放它持有的文件描述符,而关闭文件描述符会触发连接关闭流程,于是仍然可以正常发出 FIN。所以从网络层看,"进程崩溃退出"和"正常 close"几乎没区别,都会走完整的四次挥手(或至少发 FIN)。这也是为什么
kill杀掉一个进程后,对端往往能及时感知到 EOF。 - 机器重启:和进程终止类似——重启会关闭所有 socket,触发 FIN 正常发出。所以重启在 TCP 语义上也被当作"正常关闭"处理(只是更快)。
- 机器掉电 / 网线被拔:这是最难感知的。对端没有机会发 FIN,接收端只能看到"连接突然没动静了"。但这种情况下接收端很可能还认为连接活着。处理机制有两级:一是接收端一旦有写入操作,就会发现在网络层已经完全不可达,从而触发 RST(复位),把连接强行 reset 掉;二是即便双方一直安静没有写入,TCP 自己也内置了一个保活定时器(Keepalive),会定期探测对方是否还活着,长期无响应就把连接释放。有了保活,物理链路断了就不再是"永远杳无音信",而是有兜底清理。
顺带一提,应用层的一些协议也有类似的"心跳"检测机制来更快地感知异常,比如 HTTP 长连接也会定期探测对端状态,QQ 断线后也会定期尝试重连——这些本质上都是在 TCP 保活之上,为更快、更精准地感知对端存活而做的应用层补充。
思考题 11: 我拔掉网线,为什么对方的进程不是立刻报错,而是可能过很久才反应过来(甚至永远不报)?
答案: TCP 是一个"自己不动就没人知道"的协议。拔网线这类物理断连不会主动产生任何包,对端如果没有写入、而保活定时器又没触发,它确实可能长时间处于"连接无恙"的错觉中。直到两类事件之一发生,才会察觉:(1) 自己要发送数据,发送失败/触发 RST;(2) 保活探测超时判定对端死亡。所以"多久能感知断连"取决于你的应用多久发一次数据,以及内核保活参数设置。这也是为什么实时性要求高的应用要自己做应用层心跳,而不是仅依赖系统默认(往往很久)的保活周期。
可靠性小结:TCP 的严谨与高效怎么取舍
把所有机制摊开,你会发现它们几乎完美地分成两组——一组是"保命"的可靠性,一组是"提速"的高效。这正是标题那句"为什么 TCP 这么复杂"的答案:为了保证可靠性,同时尽可能提高性能。
为可靠性服务的手段:
- 校验和:发现数据在传输中损坏,直接丢弃;
- 序列号:识别重复、保证按序到达、支撑去重;
- 确认应答:逐段确认,确信数据到达;
- 超时重传:丢包时的后手兜底;
- 连接管理:三次握手、四次挥手,把"谁和谁在对话"管得井井有条;
- 流量控制:贴合"接收方消化速度",不让缓冲溢出;
- 拥塞控制:贴合"网络承受能力",不给网络添乱。
为提升性能服务的手段:
- 滑动窗口:把"一发一收"变成"多段并发",重叠等待;
- 快速重传:不等超时,三次重复 ACK 就出手,降低抖动;
- 延迟应答 / 捎带应答:让应答更经济,抬高吞吐。
其他支撑: 还有一整套定时器在幕后值守——超时重传定时器、保活定时器、TIME_WAIT 定时器等,每一个都对应一项可靠性/清理职责。
如果说 UDP 是一锤子买卖,那 TCP 就是一个严谨苛刻的"总调度",它把所有环节都标准化、监控起来,每一环都在"可靠"和"高效"的天平上找位置。记住这句话,理解 TCP 就像抓住了一根主线。
站在 TCP 之上的应用层协议
因为可靠,很多对"准确性"有硬要求、又需要"连接式"语义的协议,都长在 TCP 之上。这是你平时眼睛就能看到的一层,俯拾即是:
- HTTP / HTTPS:网页传输的基石,HTTP 用 TCP 承载请求响应,HTTPS 在 TCP 之上叠加 TLS 加密;
- SSH / Telnet:远程登录协议,SSH(加密)与 Telnet(明文)都跑在 TCP 上;
- FTP:文件传输协议,控制通道和数据通道都依赖 TCP 的可靠;
- SMTP:简单邮件传输协议,邮件的投递需要可靠的逐段确认。
当然,也包括你自己写 TCP 程序时自定义的任何应用层协议。从这里可以看到一条清晰的主线:一旦业务对"准确、不丢、有序"有强需求,TCP 就是顺手的底座。 至于那些对实时性更敏感的场景(直播、语音、游戏快照),则可能反过来更青睐 UDP,这正是下一节讨论的。
TCP 与 UDP:没有绝对优劣,只有合适场景
前面反复说 TCP 可靠,那是不是意味着TCP 一定优于 UDP? 千万别得出这个结论——TCP 和 UDP 之间的优缺点不能进行简单、绝对的比较。它们是两件不同的工具,各有各最顺手的地方。
- TCP 适用于强调"可靠传输"的场景:比如文件传输(FTP)、重要状态更新、邮件、网页等,丢不得、乱不得,容忍重传的时延。
- UDP 适用于强调"高速与实时"的场景:早期的 QQ 音视频、现在的直播推流、游戏对战快照、VoIP 语音等。它们宁可偶尔丢一两帧,也不愿为重传等待;而且 UDP 一人可发广播/组播,这是 TCP 做不到的一对多场景。
更中肯的判断是:TCP 和 UDP 都是程序员的工具,什么时候用、具体怎么用,要由具体需求场景去决定。 很多实时业务在 UDP 之上再自建一层"类可靠"机制,就是想要"TCP 的可靠 + UDP 的速度"两全其美——这也是下一节的引子。
用 UDP 手写可靠传输:经典面试题的完整思路
面试里有一个超高频率的送分/送命题——"如果不能用 TCP,请用 UDP 实现可靠的传输。" 其实它不要求你真的从零写一套工业级协议,而是考察一个道理:回到 TCP 被发明的起点,看看可靠性到底由哪些机制支撑,然后在 UDP 之上把它们重新实现一遍。 思路完全是"向 TCP 借经验":
| 可靠性目标 | TCP 的实现 | 在 UDP 之上怎么补 |
|---|---|---|
| 数据有序到达 | 32 位序号,接收端排序 | 自己给每个数据包加一个序号字段,接收端缓存乱序包并按序交付 |
| 确认数据已收到 | 确认应答 ACK | 接收端收到后回一个 ACK 包(包含已收到的序号),发送端据此推进 |
| 数据丢失能恢复 | 超时重传 | 发送端为每个未确认的包维护定时器,超时未收到 ACK 就重发 |
| 识别重复包 | 靠序号去重 | 接收端按序号去重,重复包直接丢弃 |
| 不把缓冲打满 | 流量控制(窗口) | 接收端通告自己的可用窗口,发送端据此限速 |
| 不顺道挤爆网络 | 拥塞控制(慢启动/ssthresh) | 发送端维护拥塞窗口,收到一段 ACK 指数扩张,丢包时减半并归 1 |
在此基础上还能继续追加"滑动窗口"(把多个在途包重叠起来)、"快速重传"(三个重复 ACK 立即重发)等,一步步把"可靠 TCP 的皮"穿到 UDP 身上。所以这道题的真正考点不是"你会不会写代码",而是**"你懂不懂 TCP 可靠性由哪些机制构成"**。把自己的思路讲成"序号 + 确认 + 超时重传 + 去重 + 流控 + 拥塞控制"的完整框架,面试官基本就满意了。
思考题 12: 如果我照上面方法,把序号、ACK、超时重传都加进了自定义 UDP 协议,是不是就彻底可靠了?还有什么风险?
答案: 并不彻底。至少有三层未解决:其一,TCP 校验和这类错误检测也必须加上(UDP 本身校验很弱),否则传输中损坏的数据无法发现;其二,流量控制与拥塞控制若缺失,纯"序号 + ACK + 超时"方案在高速/长链路下会要么打爆接收方缓冲、要么无脑放流挤爆网络;其三,这也是最容易忽略的——乱序与去重要配合接收端缓冲做"窗口外的按序重组",否则只是一堆有号的散件。所以一个"看起来 80% 相似"的玩具协议,和真正经得起生产考验的可靠协议之间,差的就是这些"兜底的边角"。这也反过来让人更佩服 TCP 设计的完整与严谨。
附录:直接看看 Linux 内核里的 TCP 首部
纸上得来终觉浅。下面把 Linux 内核里的 struct tcphdr(位于 include/linux/tcp.h)贴出来,与我们前面讲的首部字段逐一对应——你会发现,前面文字里每一个 field 都能在这段 C 结构里找到影子(它包含了我们没细讲的 ECE/CWR,那是 ECN 显式拥塞通知的标志,了解一下即可):
// linux kernel include/linux/tcp.h
struct tcphdr {
__be16 source; /* 源端口 */
__be16 dest; /* 目的端口 */
__be32 seq; /* 序号 */
__be32 ack_seq; /* 确认号 */
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u16 res1:4, /* 保留位(4bit) */
doff:4, /* 数据偏移/首部长度(4bit) */
fin:1, /* FIN */
syn:1, /* SYN */
rst:1, /* RST */
psh:1, /* PSH */
ack:1, /* ACK */
urg:1, /* URG */
ece:1, /* ECE:ECN-Echo */
cwr:1; /* CWR:拥塞窗口已缩减 */
#elif defined(__BIG_ENDIAN_BITFIELD)
__u16 doff:4,
res1:4,
cwr:1,
ece:1,
urg:1,
ack:1,
psh:1,
rst:1,
syn:1,
fin:1;
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif
__be16 window; /* 窗口大小 */
__sum16 check; /* 校验和 */
__be16 urg_ptr; /* 紧急指针 */
};几个对照说明,帮你把"图里的字段"和"代码里的位"串起来:
doff(Data Offset):就是前面说的"4 位 TCP 首部长度",以 32 位字为单位。默认无选项时doff = 5(5 × 4 = 20 字节)。res1:保留位,必须为 0,留给未来协议扩展。cwr(Congestion Window Reduced):发送方在收到带 ECE 的 ACK 后置位,表示"我已根据拥塞信号削减了拥塞窗口",属于 ECN 机制。ece(ECN-Echo):接收方检测到网络拥塞时,置位通知发送方"网络正在拥塞,请降速",与 CWR 配套构成显式拥塞通知(ECN)。
可以看到,我们前文讲到的六位标志位、窗口、校验和、紧急指针全部一一在列;位域里的 tiny 4bit res1 那几位,就是"6 bit 保留"一部分,配合 doff 把 16 位字排满。看懂这张结构体,你对 TCP 首部的记忆就从"图画"升华到了"机器上的真实排布"。
收尾
走完这一大圈,你手里已经攥着了一张完整的 TCP 地图:它为什么叫"传输控制协议"——因为要对每一次传输做详尽而精确的控制。可靠性这块,靠校验和、序号、确认应答、超时重传、连接管理、流量控制、拥塞控制层层兜底;性能这块,用滑动窗口、快速重传、延迟应答、捎带应答把吞吐顶上去;而连接的生命周期,则被三次握手、四次挥手、TIME_WAIT、CLOSE_WAIT、CLOSING 这些状态管得明明白白。
还记得开头那个"严谨到近乎偏执的管家"吗?现在你应该能体会它为什么偏执:网络天生不靠谱,可它要用一套严密而有分寸的规则,让数据在"尽最大可能不错"与"尽最大可能不快不慢"之间安全抵达。学习 TCP,学到的不只是十几个术语和状态——更是一种"在不可靠的环境里设计可靠系统"的工程哲学。你亲眼看到它面对迟到、丢包、拥塞、断连时的每一次反应,也就学会了如何在任何地方设计健壮的传输逻辑。
下一站,我们该往前迈一步,去看看 UDP 的报文段格式、传输特性,以及它和 TCP 在具体 socket 编程里的触感差异。网络的世界,越往里走,越觉得有意思。我们下次见。
还没有评论 — 第一条由你来留。