
文章目录
- [第一讲:传输层基石与 UDP 协议剖析(端口概念、UDP 特性、报文机制)](#第一讲:传输层基石与 UDP 协议剖析(端口概念、UDP 特性、报文机制))
-
- [1. 核心前提:数据如何找到对应的程序?](#1. 核心前提:数据如何找到对应的程序?)
- [2. UDP 协议:寄明信片式的通信](#2. UDP 协议:寄明信片式的通信)
- [3. UDP 的缓冲区与全双工](#3. UDP 的缓冲区与全双工)
- [4. 必要的系统调用演示](#4. 必要的系统调用演示)
- 本讲高频面试题解析
- [第二讲:TCP 初探与连接管理(报文结构、三次握手、四次挥手、状态流转深度解析)](#第二讲:TCP 初探与连接管理(报文结构、三次握手、四次挥手、状态流转深度解析))
-
- [1. 认识 TCP 的"控制开关"](#1. 认识 TCP 的“控制开关”)
- [2. 建立连接:三次握手 (Three-way Handshake)](#2. 建立连接:三次握手 (Three-way Handshake))
- [3. 断开连接:四次挥手 (Four-way Wave)](#3. 断开连接:四次挥手 (Four-way Wave))
- [4. 关键状态深度剖析](#4. 关键状态深度剖析)
- [5. 必要的系统调用演示](#5. 必要的系统调用演示)
- 本讲高频面试题解析
- [第三讲:TCP 可靠性基石(确认应答、超时重传、序列号机制)](#第三讲:TCP 可靠性基石(确认应答、超时重传、序列号机制))
-
- [1. 序列号 (Sequence Number):给每一个字节贴上编号](#1. 序列号 (Sequence Number):给每一个字节贴上编号)
- [2. 确认应答 (ACK) 机制:明确无误的回执](#2. 确认应答 (ACK) 机制:明确无误的回执)
- [3. 超时重传机制:耐心等待与重复丢弃](#3. 超时重传机制:耐心等待与重复丢弃)
- 本讲高频面试题解析
- [第四讲:TCP 性能起飞(滑动窗口、快重传、流量控制与拥塞控制)](#第四讲:TCP 性能起飞(滑动窗口、快重传、流量控制与拥塞控制))
-
- [1. 滑动窗口 (Sliding Window):批量发送的艺术](#1. 滑动窗口 (Sliding Window):批量发送的艺术)
- [2. 快重传 (Fast Retransmit):高速异常处理](#2. 快重传 (Fast Retransmit):高速异常处理)
- [3. 流量控制 (Flow Control):照顾读者的接收极限](#3. 流量控制 (Flow Control):照顾读者的接收极限)
- [4. 拥塞控制 (Congestion Control):照顾整个社会的快递网络](#4. 拥塞控制 (Congestion Control):照顾整个社会的快递网络)
- 本讲高频面试题解析
- [第五讲:TCP 特性进阶与终极对比(延迟应答、捎带应答、粘包问题及场景剖析)](#第五讲:TCP 特性进阶与终极对比(延迟应答、捎带应答、粘包问题及场景剖析))
-
- [1. 延迟应答 (Delayed ACK):让子弹飞一会儿](#1. 延迟应答 (Delayed ACK):让子弹飞一会儿)
- [2. 捎带应答 (Piggybacking):顺风车模式](#2. 捎带应答 (Piggybacking):顺风车模式)
- [3. 面向字节流与"粘包问题" (核心避坑指南)](#3. 面向字节流与“粘包问题” (核心避坑指南))
- [4. TCP 异常情况处理](#4. TCP 异常情况处理)
- [5. TCP 与 UDP 终极对比](#5. TCP 与 UDP 终极对比)
- 本讲高频面试题解析
第一讲:传输层基石与 UDP 协议剖析(端口概念、UDP 特性、报文机制)
1. 核心前提:数据如何找到对应的程序?
在讲解具体协议前,我们需要先明确一个概念:网络通信的本质是两台机器上的进程在交换数据。
你可以把互联网想象成一个庞大的城市群:
- IP 地址:就像是小区的详细地址(比如:XX路XX号)。它负责把数据包送达到特定的那台电脑(主机)上。
- 端口号 (Port):就像是小区里面具体的门牌号。一台电脑上同时运行着浏览器、QQ、微信等多个程序,数据包到了电脑后,操作系统就是通过端口号来决定把数据交给哪个具体的应用程序。
为了在茫茫网海中唯一确定一次通信,网络底层采用了一个五元组来进行标识:源 IP、源端口号、目的 IP、目的端口号、协议号。
+-------------------+ +-------------------+
| 主机 A | | 服务器 |
| | | |
| +-------------+ | 网络传输轨迹 | +-------------+ |
| | 浏览器(2001) | | -------------------------> | | HTTP服务(80)| |
| +-------------+ | 源IP: 172.20.100.34 | +-------------+ |
| | 目的IP: 172.20.100.32 | |
+-------------------+ +-------------------+
注:0到1023是系统保留的知名端口,比如 HTTP 默认占用 80 端口,SSH 占用 22 端口。我们自己写 C++ 服务端程序时,一定要避开这些范围,通常选择 1024 到 65535 之间的动态端口。
2. UDP 协议:寄明信片式的通信
UDP(用户数据报协议)的核心设计理念就是:简单、直接。它的传输过程非常像是在邮局寄明信片。
- 无连接:你寄明信片不需要先给对方打个电话确认,只要知道对方的地址(IP 和端口),填好扔进邮筒就可以直接发送。
- 不可靠:明信片在半路上如果丢了、被大雨淋湿了,邮递员不会通知你,也不会帮你重新寄一张。UDP 也是如此,没有确认应答机制,也没有重传机制,数据发出去就不管了。
- 面向数据报:你在这张明信片上写了多少字,对方收到就是多少字。UDP 绝对不会把你的数据拆成两半发送,也不会把两次发的数据合并到一起。应用层交给 UDP 多长的报文,它就原样发送。
比如你用 UDP 发送了 100 个字节:发送端调用一次发送函数,接收端必须对应调用一次接收函数完整收取这 100 个字节,绝不能分 10 次每次收 10 个字节。
3. UDP 的缓冲区与全双工
在操作系统底层,UDP 的收发机制非常干净利落:
- 发送端:UDP 没有真正意义上的发送缓冲区。你的代码一旦调用发送函数,数据就立刻交给了系统内核,内核直接把它丢给网络层发走。
- 接收端:UDP 具有接收缓冲区。网络上源源不断来的数据包会先暂存在这个缓冲区里等你的程序来读。但注意,如果你的程序读得太慢,导致缓冲区满了,后续再到达的 UDP 数据包就会被内核毫不留情地直接丢弃。
同时,UDP 的套接字(Socket)是全双工的,意味着同一个通道,你可以一边源源不断地发数据,一边同时收数据,互不干扰。
4. 必要的系统调用演示
在 Linux 环境下使用 C++ 编写 UDP 通信,有两个最基础的系统调用函数你必须掌握:
函数1:socket
原型:int socket(int domain, int type, int protocol);
大白话说明:这就好比去电信营业厅申请一部座机电话,调用成功后系统会交给你一个电话把手(文件描述符),你后续所有的网络收发操作都要对着这个把手进行。
函数2:bind
原型:int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
大白话说明:光有电话把手还不行,这个函数的作用是给你的座机插上电话线,并绑定一个专属的电话号码(IP 和端口号),这样别人的数据包才能准确找到你的程序。
简单的 C++ 服务端绑定演示(无需完整执行,仅作逻辑参考):
int sock = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(8080);
local.sin_addr.s_addr = INADDR_ANY;
bind(sock, (struct sockaddr*)&local, sizeof(local));
本讲高频面试题解析
1. 一台主机上的一个进程是否可以 bind 多个端口号?
可以。一个进程可以创建多个 Socket(文件描述符),每一个 Socket 都可以独立 bind 一个不同的端口号。就像一个大公司(进程)可以申请多部不同的座机电话(端口号)来接听不同业务的来电。
2. 一个端口号是否可以被多个进程 bind?
通常情况下不可以。端口号是用来唯一标识接收端进程的,如果多个进程绑定同一个端口,操作系统收到数据包后就不知道该把数据交给谁了。这就好比一个电话号码不能同时分配给两户独立的人家。(注:在 Linux 中通过特殊选项如 SO_REUSEPORT 可以实现多进程监听同端口用于负载均衡,但在没有特殊配置的常规语境下,答案是否定的)。
3. UDP 协议能够传输的最大数据是多少?如果超过了会怎样?
UDP 协议首部自带一个 16 位的长度字段,这意味着一个 UDP 数据报(包含头部和数据)的最大总长度是 65535 字节(约 64K)。在当今互联网环境下,64K 是一个非常小的限制。如果需要传输大于 64K 的数据,开发者必须在应用层用 C++ 代码手动对数据进行分包发送,并在接收端手动将这些小包拼装成完整数据。
第二讲:TCP 初探与连接管理(报文结构、三次握手、四次挥手、状态流转深度解析)
如果说上一讲的 UDP 是随手丢进邮筒、不管死活的明信片,那么 TCP(传输控制协议)就是需要双方先打电话沟通、过程严密监控、并且每次交接都要求签字回执的"机密级保价快递"。TCP 的设计初衷是为了在不可靠的网络环境中,硬生生砸出一条可靠 的传输通道。要做到这一点,第一步就是:建立和断开连接。
1. 认识 TCP 的"控制开关"
在 TCP 发送的每一个数据包(报文)头部,除了和 UDP 一样有源端口和目的端口外,还有 6 个非常关键的"标志位"(由 0 和 1 组成的开关)。我们今天主要认识其中三个负责连接管理的开关:
- SYN (Synchronize) :同步开关。当这个开关置为 1 时,表示"我想和你建立连接"。我们称之为同步报文段。
- FIN (Finish) :结束开关。当置为 1 时,表示"我的数据发完了,我们断开连接吧"。我们称之为结束报文段。
- ACK (Acknowledge):确认开关。当置为 1 时,表示"你刚才说的话我收到了"。几乎除了最开始的请求外,后续所有包的 ACK 都会是 1。
2. 建立连接:三次握手 (Three-way Handshake)
TCP 通信的第一步,就像两个人打电话。必须确认双方的麦克风(发送能力)和听筒(接收能力)都正常,才能开始聊正事。
text
客户端状态 (Client) 服务端状态 (Server)
CLOSED LISTEN (等待呼叫)
| |
| ------------- (1) SYN (我想连你) -------------> |
SYN_SENT SYN_RCVD (收到请求)
| |
| <------- (2) SYN + ACK (好,我也连你+收到) ------- |
| |
ESTABLISHED ------- (3) ACK (收到你的同意) ---------> |
(可以发数据了) ESTABLISHED (可以发数据了)
一环扣一环的逻辑:
- 第一次握手 :客户端发送 SYN。服务端收到后,服务端可以得出结论:客户端的发送正常,我的接收正常。
- 第二次握手 :服务端回复 SYN(我也要连你)+ ACK(你的请求我收到了)。客户端收到后,客户端得出结论:我的收发正常,服务端的收发也正常 。此时客户端单方面认为连接已经建立,进入
ESTABLISHED状态。 - 第三次握手 :但这还不够,服务端此时还不知道自己的发送能力好不好。所以客户端必须再回一个 ACK。服务端收到后,彻底放心:我的发送和接收也都正常 。双方正式进入
ESTABLISHED状态。
3. 断开连接:四次挥手 (Four-way Wave)
数据传完了,要拆除通道。由于 TCP 是全双工的(双向通道独立存在),所以断开连接就像男女朋友和平分手,双方都要明确表态"我没有话要说了"。
text
主动断开方 (常为Client) 被动断开方 (常为Server)
ESTABLISHED ESTABLISHED
| |
| ------------- (1) FIN (我发完了) -------------> |
FIN_WAIT_1 CLOSE_WAIT (准备关闭)
| |
| <------------ (2) ACK (知道了) ---------------- |
FIN_WAIT_2 |
| (服务端可能还有剩余数据要发,此时继续发)
| |
| <------------ (3) FIN (我也发完了) ------------ |
TIME_WAIT LAST_ACK (等最后确认)
| |
| ------------- (4) ACK (彻底拜拜) -------------> |
| CLOSED
(等待 2MSL 时间)
|
CLOSED
逻辑推演:
- 客户端发 FIN,表示自己不再发送数据了(但还能接收)。
- 服务端回 ACK,表示收到了。此时进入半关闭状态。服务端把手头还没处理完的数据继续处理发走。
- 服务端确实没东西发了,也发送 FIN。
- 客户端回 ACK。彻底结束。
4. 关键状态深度剖析
初学者最容易在面试和实战中栽在以下两个状态上:
- TIME_WAIT (客户端的倔强等待)
主动断开连接的一方,在发送完最后一次 ACK 后,不能立刻销毁自己,必须在原地进入TIME_WAIT状态,死等 2MSL (最大报文生存时间的 2 倍,Linux 通常默认约 60 秒)。
为什么? 因为如果最后这个 ACK 迷路丢了,服务端收不到确认,就会以为自己的 FIN 没发出去,从而重发一遍 FIN。如果客户端不等待直接退出了,收到重发的 FIN 就会引发混乱。等 2MSL 就是为了确保最后一个 ACK 绝对到达,同时让本次连接在网络中迷路的残余数据包彻底自然消亡,干干净净。 - CLOSE_WAIT (服务端的致命 BUG)
被动方收到 FIN 并回复 ACK 后,就会进入CLOSE_WAIT状态。此时被动方需要在代码逻辑里把剩下的事情做完,然后主动调用close()发送 FIN 才能往下走。如果你在 Linux 上用netstat命令发现服务器上有大量的CLOSE_WAIT,别怀疑,一定是你的 C++ 业务代码里有 BUG,在连接断开时忘了调用 close() 关闭文件描述符(Socket)。
5. 必要的系统调用演示
在实现 TCP 服务端时,相比 UDP,我们需要新的系统调用来支撑"连接"的概念。
函数1:listen
原型:int listen(int sockfd, int backlog);
大白话说明:这就好比营业厅把你的座机设置成了"客服热线"模式,让这个 Socket 从主动变被动,开始在后台静静地排队监听外面打进来的电话(backlog 参数就是排队等候的最大长度)。
函数2:accept
原型:int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
大白话说明:这就好比客服专员按下接听键,从外面排队打进来的电话中接起一个。它会返回一个全新的电话把手(新 Socket),专门用来和这个特定的客户一对一单线联系,而原来的客服热线(监听 Socket)还在继续接别人的电话。
函数3:connect
原型:int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
大白话说明:这是客户端专用的函数,好比拿着电话把手主动拨打服务端的电话号码,调用这个函数的过程,底层就在自动帮你完成那著名的"三次握手"。
C++ TCP 服务端连接搭建 Demo(示意伪代码逻辑):
cpp
// 1. 创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0); // SOCK_STREAM 代表面向字节流的TCP
// 2. 绑定IP和端口 (和UDP一样)
struct sockaddr_in local;
// ... 填充 local 结构体信息 (IP:INADDR_ANY, Port:8080)
bind(listen_sock, (struct sockaddr*)&local, sizeof(local));
// 3. 启动监听,转为被动接收状态
listen(listen_sock, 5);
// 4. 死循环处理外部连接
while
(true) {
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
// 阻塞等待,直到有客户端完成三次握手进来
int client_sock = accept(listen_sock, (struct sockaddr*)&client_addr, &len);
if(client_sock >= 0) {
// 连接建立成功!开始使用 client_sock 进行 read/write 收发数据
// ... (处理业务)
// 业务处理完毕,必须调用 close,否则会导致 CLOSE_WAIT 泄漏!
close(client_sock);
}
}
本讲高频面试题解析
1. 为什么建立连接是三次握手,而不是两次或者四次?
解析与答案 :网络是不可靠的,建立可靠连接的底线是双方都要确认自己的发送 和接收 能力正常。
如果是两次握手 ,服务端收到 SYN 只能确认客户端能发,自己回复了 SYN+ACK 后,服务端不知道客户端有没有收到,即服务端无法确认自己的发送能力是否正常。这就容易导致一种极端情况:服务端回复完就以为连接建立了,分配了内存资源,但网络拥堵导致客户端没收到,客户端继续发请求,服务器就会产生大量死连接(SYN 洪泛攻击的原理)。
如果是四次握手,服务端把 SYN 和 ACK 分两次发,完全可以,但没必要,一起发可以提高效率,所以合并成了三次。
2. 为什么断开连接需要四次挥手,而不是三次?
解析与答案:因为 TCP 是全双工的。当客户端发送 FIN 时,只是意味着"客户端到服务端"的单向通道关闭了,客户端不发了。但是服务端收到 FIN 时,手头可能还有没发完的数据,不能立刻也跟着发 FIN。所以服务端只能先回一个 ACK 安抚客户端(导致两次挥手)。等服务端磨磨唧唧把所有剩余业务数据发完,才会发送自己的 FIN(第三次挥手),客户端再确认(第四次挥手)。所以中间的 ACK 和 FIN 中间夹杂着业务数据的发送,无法像握手那样合并。
3. 解决服务器 TIME_WAIT 状态引起的 bind 失败的方法是什么?
解析与答案 :如果服务器主动断开连接然后挂掉,重启时会发现端口被占用(Address already in use),这就是因为底层的连接还卡在 2MSL 的 TIME_WAIT 状态。在 C++ 服务器开发中,我们可以通过系统调用 setsockopt 设置 SO_REUSEADDR 选项。它相当于告诉系统:允许创建端口号相同但 IP 地址不同的多个 Socket 描述符,直接绕过这个等待限制进行端口复用。
第三讲:TCP 可靠性基石(确认应答、超时重传、序列号机制)
在上一讲中,我们通过三次握手建立了一条专线。但真实的网络环境非常恶劣,数据包在路由器之间传递时,随时可能因为网络拥堵、设备断电而"半路失踪",或者因为不同路径导致"后发先至"。
为了把复杂的机制讲透,我们来模拟一个"跨国对讲机汇报机密文件"的场景:你要向远在海外的老板汇报一份 10000 字的商业计划书,但对讲机信号极差,随时有杂音,甚至会断断续续。TCP 是怎么保证老板能一个字不差地听完的?
1. 序列号 (Sequence Number):给每一个字节贴上编号
如果对讲机信号不好,你肯定不会一口气把 10000 字全念完,那样一旦中间断了,老板根本不知道从哪里接上。
TCP 的做法极其严谨:它把要发送的数据全部拆散,并且给每一个字节(注意,是每一个字节,不是每一个包)都编上号。这就好比你把 10000 字的计划书里的每一个字都标上序号(第1字、第2字...第10000字)。
text
第1字节 第2字节 第10000字节
+--------+--------+--- ...... ---+--------+
| 字 | 符 | | 据 |
+--------+--------+--- ...... ---+--------+
^ ^ ^
序号1 序号2 序号10000
发送时,TCP 会把一小批连着的字(比如第 1 到 1000 个字节)打包成一个"报文段"发出去,并且在包裹外面写上:"本包裹从序号 1 开始"。有了这个序号,接收方就算先收到了后发的包裹,也能根据序号在本地缓冲区里把它们重新拼成正确的顺序(解决乱序问题)。
2. 确认应答 (ACK) 机制:明确无误的回执
老板在对讲机那头听到了你念的第 1 到 1000 个字。为了让你放心,他必须给你一个反馈。
TCP 的确认应答不是简单地说一句"我收到了"。
TCP 的 ACK 会带有一个"确认序列号"。它的核心含义是:"前面的我都收到了,下一次请从第 N 个字节开始发给我。"
text
发送方 (你) 接收方 (老板)
| |
| ------------- 数据 (序号 1 ~ 1000) --------> |
| |
| <--------- 确认应答 (下一个请发 1001) ------ |
| |
| ----------- 数据 (序号 1001 ~ 2000) -------> |
| |
| <--------- 确认应答 (下一个请发 2001) ------ |
这种设计非常巧妙。假设你一口气发了两个包(1~ 1000,1001~2000),老板回了一个"下一个请发 2001"。就算第一个确认包(下一个发1001)半路丢了,你只要收到了"2001"的确认,你就能百分之百断定:2001 之前的所有数据,老板已经全部安全收到了。
3. 超时重传机制:耐心等待与重复丢弃
在极差的对讲机环境中,总会有意外发生。TCP 的超时重传机制完美处理了两种最常见的丢包场景。
场景一:你发的数据在路上丢了
你把第 1001~2000 个字念出去了,等了半天对讲机没动静。TCP 内部有一个定时器,只要在一个"特定的时间间隔"内没有收到老板的回执,TCP 就会认为包丢了,自动把 1001~2000 的数据重新发一遍。
场景二:老板的回执在路上丢了,导致重复发送
老板其实收到了 1001~2000,并且回复了"下一个发 2001",但这句话在对讲机里被杂音吞了。
你等了一会没听到反馈,以为刚才发的老板没听到,于是又念了一遍 1001~2000 。
此时,老板收到了两份一模一样的内容。老板会懵吗?不会!因为每个数据包都有序列号 。老板一看,这批字的序号我刚才已经收过了,于是直接把重复的包丢弃(去重),并再次回复:"我已经收到了,下一个请发 2001!"
text
发送方 (你) 接收方 (老板)
| |
| ----------- 数据 (1001 ~ 2000) ------------> |
等待ACK | | (老板收到并回复,
超时触发 | <----------- X 确认 (下发2001) 丢失 X ------- | 但回执丢了)
| |
| ====(重传) 数据 (1001 ~ 2000) =============> | (老板发现序号重复,
| | 直接丢弃,并重发回执)
| <--------- 确认应答 (下一个请发 2001) ------ |
超时的时间到底定多久?
如果定得太长,重传效率极低;定得太短,网络稍微卡一下就疯狂重发。TCP 采用了动态调整的策略 :
在 Linux 中,超时时间通常以 500ms 为单位。
- 第一次没回音,等 500ms 重传。
- 如果重发一次还不行,等 2 * 500ms 重传。
- 还不行,等 4 * 500ms,以此类推,呈指数形式递增。
- 当累计重传次数达到一定阈值,TCP 就会绝望,认为网络已经彻底瘫痪,强制关闭连接。
本讲高频面试题解析
1. TCP 为什么能保证数据按顺序到达且不重复?
解析与答案 :核心在于序列号(Sequence Number)机制。TCP 传输时将每个字节的数据都进行了编号。接收端可以通过序列号,将网络中乱序到达的数据包重新排序拼接;同时,如果因为超时重传导致接收端收到了相同序列号的数据包,接收端操作系统内核可以利用序列号精准识别出重复数据,并将其直接丢弃,从而实现去重。
2. 简述 TCP 的超时重传机制以及超时时间的确定策略。
解析与答案:当发送端发出数据后,如果在一个特定的时间间隔内未收到接收端的确认应答(ACK),就会重新发送该数据段,无论是因为数据丢了还是 ACK 丢了。超时时间是动态计算的,以 500ms 为单位,采用指数避退的原则(500ms, 1000ms, 2000ms...)。连续多次重发无果后,TCP 会认为对端主机异常或网络故障,强制断开连接。
3. (经典开放题)如何用 UDP 实现可靠传输?
解析与答案:这是一道非常考验底层理解的综合题。UDP 本身不可靠,要实现可靠传输,必须在应用层(比如你的 C++ 代码中)去"抄袭" TCP 的作业:
- 引入序列号:在应用层协议头部加上 ID 编号,保证接收端能按序组装数据并丢弃重复包。
- 引入确认应答:接收端收到数据后,必须向发送端回传一个带有对应 ID 的确认包。
- 引入超时重传 :发送端在内存中维护一个定时器和未确认数据的缓存,如果超过设定时间没收到对应 ID 的应答,就从缓存中调出数据重新调用
sendto发送。
第四讲:TCP 性能起飞(滑动窗口、快重传、流量控制与拥塞控制)
在上一讲中,我们弄懂了 TCP 是如何通过"发一个包,等一个确认,再发下一个包"来保证绝对可靠的。但是,这种"一发一收"的串行模式太慢了。如果两台机器相隔万里,数据往返一次需要很久,那传输效率简直不堪入目。
为了让性能起飞,TCP 引入了一套非常优雅的组合拳。我们把场景升级一下:你现在是一个图书出版商 ,要把一套 10000 页的百科全书邮寄给远方的读者。
1. 滑动窗口 (Sliding Window):批量发送的艺术
既然一页一页寄太慢,那我们就一次性寄出一大批。
TCP 允许发送方在没有收到确认应答(ACK)的情况下,连续发送多个数据段。这个无需等待确认就可以发送的最大数据量,就叫窗口大小。
场景模拟:
假设窗口大小是 4000 字节(相当于一次可以连发 4 个 1000 字节的包裹)。
你一口气把第 1 到 4000 字节(包裹 A、B、C、D)全寄出去了。
当你收到包裹 A 的确认回执后,说明包裹 A 已经安全抵达。此时你的"窗口"就可以向前滑动一下,把你配额里的空位腾出来,继续寄出包裹 E(4001-5000 字节)。
=== 批量发送与窗口滑动演示 ===
[发送方视角]
数据: 1---1000---2000---3000---4000---5000---6000
窗口: [---------------------------]
(一口气发4个包裹,无需等待)
收到 1000 的确认后,窗口向右滑动:
数据: 1---1000---2000---3000---4000---5000---6000
窗口: [---------------------------]
(腾出额度,立刻发送 4001-5000)
在操作系统的内核中,这个机制是通过发送缓冲区来实现的。只有被确认应答过的数据,才能从缓冲区里真正删掉;没被确认的数据,必须留在窗口里以备不测。
2. 快重传 (Fast Retransmit):高速异常处理
既然是批量发送,万一中间某个包裹丢了怎么办?滑动窗口机制下,有两种丢包情况,处理方式极其聪明。
情况一:回执(ACK)丢了
包裹 A、B、C 都到了,但读者回复的"A收到,下发B"和"B收到,下发C"的回执在半路丢了。
结果:完全不要紧!因为只要你收到了"C收到,下一个请发D"的回执,你就百分百确定 A 和 B 绝对已经到了(这叫累积确认)。
情况二:真正的包裹丢了(触发快重传)
如果你一口气发了 1、2、3、4、5 号包裹。结果 2 号包裹掉河里了。
读者收到了 1,回复:"下一个请发 2"。
接着读者收到了 3,发现 2 还没来!读者不管,继续回复:"下一个请发 2"。
收到 4,回复:"下一个请发 2"。
收到 5,回复:"下一个请发 2"。
你作为发送方,连续收到了三个完全一样的确认应答 ("下一个请发 2")。这时候你立刻心领神会:2 号包裹绝对是丢了!此时你根本不需要等待超时重传的定时器到期 ,而是立刻把 2 号包裹重新补发过去。
读者收到补发的 2 号后,因为之前 3、4、5 都已经存在接收缓冲区里了,所以会直接回复:"下一个请发 6"。
3. 流量控制 (Flow Control):照顾读者的接收极限
读者的书桌(接收缓冲区)大小是有限的。如果你发的太猛,把读者的书桌堆满了,再寄过去的书就会掉在地上(丢包),引发一连串的重传灾难。
机制:
TCP 的接收端,会在每次回复 ACK 时,在 TCP 报文头部的 16位窗口大小 字段里,填上自己当前"还剩多少空桌子"(缓冲区剩余大小)。
- 如果你看到读者回复的窗口变小了,你就自觉减慢发送速度。
- 如果读者回复窗口大小是 0 ,你就彻底停止发送。但你会定期发送一个窗口探测包,去问问读者:"老铁,桌子清理出空位了吗?"
细节补充:16 位最大只能表示 65535 字节。现代网络下这个窗口太小了,所以在 TCP 头部的 40 字节选项区里,有一个窗口扩大因子 M,实际窗口大小等于窗口字段的值左移 M 位。
4. 拥塞控制 (Congestion Control):照顾整个社会的快递网络
流量控制是照顾对方 ,拥塞控制是照顾整个网络环境 。
如果你们俩的网卡都很强,书桌也很大,你一上来就发几万个包裹,但此时正值"双十一",中间的路由器早就堵死了。这时候再强行发,只会导致大面积丢包。
TCP 为此引入了慢启动 机制,并维护了一个拥塞窗口 。实际发送时,发送方会取 拥塞窗口 和 对方反馈的接收窗口 中的较小值。
整个过程就如同热恋的感觉,循序渐进又大起大落:
- 慢启动(试探) :一开始先试探,拥塞窗口设为 1。收到一个 ACK,窗口加 1。这导致发送量呈指数级增长(1, 2, 4, 8...),虽然叫慢启动,但前期增长极快。
- 拥塞避免(求稳) :当窗口达到一个设定的慢启动阈值 (ssthresh) 时,停止指数膨胀,改为每次加 1 的线性增长。
- 网络拥塞(遇挫) :如果发生了超时重传(说明网络出现严重拥堵掉包了),TCP 会立刻进入"贤者模式"。直接把慢启动阈值砍掉一半,把拥塞窗口瞬间重置为 1,重新开始慢启动。
通过不断地探测、增长、遇挫、折半,TCP 在尽可能快地发送数据和避免压垮网络之间,找到了一条完美的折中曲线。
本讲高频面试题解析
1. 在滑动窗口机制下,如果某个 ACK 报文丢失了,发送端是否一定会重传数据?
解析与答案:不一定。TCP 的确认应答具有"累积确认"的特性。如果前面部分的 ACK 丢失,但后续数据的 ACK 成功到达发送端,发送端通过后续的 ACK 就能确认前面的数据已经被成功接收。只有当真正的数据包丢失,或者最后一个包的 ACK 丢失导致超时,才会引发重传。
2. 简述 TCP 的快重传(快速重发控制)机制。
解析与答案 :当发送端连续发送多个数据段时,如果其中某个数据段丢失,接收端在收到后续乱序到达的数据时,会持续发送针对丢失数据段的确认应答。如果发送端连续收到 3 次 相同的确认应答,就会判定该序列号对应的数据包已丢失,并立即进行重发,而无需等待该数据包的超时定时器触发。这种机制大大提高了异常情况下的重传效率。
3. 流量控制和拥塞控制有什么区别?
解析与答案:
- 流量控制 的作用对象是接收端。它是为了防止发送方发送速度过快,导致接收方的缓冲区溢出(桌子堆满)而引发丢包。通过 TCP 报文头部的窗口大小字段进行端到端的动态反馈。
- 拥塞控制 的作用对象是整个网络链路。它是为了防止在网络本身拥堵时,通信双方还大量注入数据导致网络瘫痪。发送方通过维护"拥塞窗口"并执行慢启动、拥塞避免等算法,主动探测和适应网络的承载能力。
第五讲:TCP 特性进阶与终极对比(延迟应答、捎带应答、粘包问题及场景剖析)
经过前四讲,我们已经掌握了 TCP 如何通过连接管理、序列号、超时重传保证"稳",又如何通过滑动窗口、快重传、流量控制保证"快"。今天,我们要看看 TCP 在应用层交互时,还有哪些极其聪明的"小动作",以及我们在实际 C++ 开发中最容易踩坑的"粘包问题"。
1. 延迟应答 (Delayed ACK):让子弹飞一会儿
上一讲我们提到,接收端在回复 ACK 的时候,会顺便把自己的"窗口大小(剩余缓冲区空间)"告诉发送端。窗口越大,发送端下次就能发越多的数据。
场景模拟:
假设你的接收缓冲区总共 1M,刚收到了 500K 的数据。
如果操作系统立刻 回复 ACK,那它只能告诉对方:"我还有 500K 空间"。发送端一看,窗口只有 500K,那就慢点发吧。
但实际上,你的 C++ 服务端程序处理数据极快,只要 10 毫秒就能把这 500K 数据从缓冲区里读走消费掉。
这时候 TCP 耍了个聪明:我不立刻回复,我等一等(比如等 200 毫秒)。在这 200 毫秒内,应用程序早就把数据抽干了。此时 TCP 再回复 ACK,就可以理直气壮地告诉对方:"我还有 1M 的空间!"
通过"延迟应答",TCP 巧妙地放大了返回的窗口大小,从而在保证网络不拥塞的前提下,最大限度地提升了传输效率。
注:当然不能无限等。通常是每隔 N 个包(比如 2 个)就必须应答一次,或者超过最大延迟时间(比如 200ms)也必须应答。
2. 捎带应答 (Piggybacking):顺风车模式
在很多 C++ 网络应用(比如我们写的 HTTP Web 服务器或 RPC 调用)中,客户端和服务端的交互通常是"一发一收"的。
客户端对服务端说:"How are you?"
服务端不仅需要在底层回复一个 ACK(确认收到),还需要在应用层回复一句:"Fine, thank you"。
既然服务端反正都要发一个数据包回去,TCP 就会把那个底层的 ACK 确认信号直接搭在业务数据的"顺风车"上一起发过去。这就是捎带应答,极大减少了网络中纯 ACK 包的数量,节省了带宽。
3. 面向字节流与"粘包问题" (核心避坑指南)
这是 C++ 后端开发面试的绝对高频考点,也是新手极易写出 Bug 的地方。
TCP 的传输机制叫面向字节流 。回忆一下第一讲,UDP 叫"面向数据报",像寄明信片,一张是一张。而 TCP 就像自来水管里的水流。
必要的系统调用演示:
函数:read / write (或 recv / send)
原型:ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
详细与大白话:在 Linux 系统中,网络套接字(Socket)也是一种文件描述符。这两个系统调用就是最基础的文件读写函数,向指定的 fd 写入字节,或从 fd 读取字节。
大白话说明:write 就是往水管子里倒水(发数据),你可以一次倒 100 毫升,也可以分 100 次每次倒 1 毫升;read 就是拿着桶在管子另一头接水(收数据),只要管子里有水,你想接多少就接多少,你根本不知道发送方当时是分几次倒进来的。
八戒吃馒头(粘包问题):
应用层发数据,就像是在传送带上放馒头。
UDP 放馒头,每个馒头都装在独立的盒子里(有报文边界),八戒(接收端)拿一个盒子就是一个完整的馒头。
TCP 放馒头,是把面团揉在一起变成一条长长的面筋放在传送带上。
TCP 接收缓冲区视角(纯字节流):
+----+----+----+----+----+----+----+----+----+----+
| { | m | s | g | 1 | } | { | m | s | g | ...
+----+----+----+----+----+----+----+----+----+----+
毫无边界的一串字符,应用程序怎么知道哪里是一个完整的业务请求?
如果你直接调用 read,可能一次性读到了"一个半"请求,这就叫粘包问题(这里的"包"指的是应用层的业务数据包)。
解决粘包的根本思路:明确数据包之间的边界。在 C++ 开发中,我们通常有三种做法:
- 定长包 :约定每个请求固定大小(如
sizeof(Request))。缓冲区每次严格按这个长度截取。 - 特殊分隔符 :在包与包之间加上明确的字符。比如 HTTP 协议常用的
\r\n。读取时扫描到分隔符就算一个完整包。 - 包头带长度字段(最常用):定义一个结构体,头部指明正文有多长。
C++ 解决粘包的包头约定 Demo:
cpp
// 约定通信协议的数据包结构
struct NetPackage {
uint32_t length; // 包体的长度
// char data[0]; // 柔性数组,后面紧跟实际数据
};
// 读取侧的逻辑思路:
// 1. 先严格 read 4 个字节,解析出 length 的值。
// 2. 根据 length 的值,再去 read 对应长度的字节,这就完美取出了一个完整的应用层数据包。
4. TCP 异常情况处理
如果连接正在进行中,发生了意外怎么办?
- 进程终止 / 机器重启:你在 Linux 上用 Ctrl+C 杀掉 C++ 进程,或者直接重启机器。操作系统内核非常负责任,它会在进程销毁时自动关闭对应的文件描述符(Socket),底层依然会向对方发送 FIN 报文,经历正常的断开流程。
- 机器掉电 / 网线物理断开 :断电是一瞬间的事,操作系统根本来不及发 FIN。此时接收端还傻傻地以为连接活着。但没关系,一旦接收端尝试写入数据,就会发现连不通,从而引发 Reset(复位报文);即使不写入数据,TCP 内部也内置了保活定时器(Keep-Alive),会定期探活,发现对方失联后会自动释放连接。
5. TCP 与 UDP 终极对比
TCP 这么好,是不是一定要用 TCP?绝对不是。TCP 和 UDP 没有绝对的优劣,只有适不适合。
- TCP 的代名词是"可靠"。 它拥有复杂的校验和、序列号、确认应答、超时重传、滑动窗口、流量与拥塞控制。适用于文件传输、重要状态更新、Web 网站(HTTP/HTTPS)、远程登录(SSH)等绝对不允许丢哪怕一个字节的场景。
- UDP 的代名词是"快速与实时"。 它轻量、无连接、不重传。适用于视频直播流、语音通话、早期的 QQ 聊天,以及局域网广播。在这些场景里,丢一两帧画面只会闪烁一下,但如果为了重传这一帧画面导致整个直播卡顿 2 秒,反而是不可接受的。
本讲高频面试题解析
1. 什么是 TCP 的粘包问题?为什么 UDP 没有粘包问题?如何解决 TCP 粘包?
解析与答案 :粘包问题是指应用层无法从接收缓冲区中剥离出完整的业务数据包。
TCP 存在粘包是因为 TCP 是面向字节流的,协议头中没有如同 UDP 一样的"报文长度"字段,数据在传输和缓冲时失去了应用层的边界。
UDP 没有粘包是因为 UDP 是面向数据报的,报文头部有 16 位 UDP 长度字段,要么收到完整的报文交付给上层,要么不收,绝不会出现"半个包"的情况。
解决方案是在应用层约定协议明确边界,常用方法有:1. 发送固定长度的数据包;2. 在数据包末尾添加特殊分隔符;3. 在数据包头部添加长度字段(Length-Value 格式)。
2. 简述 TCP 的延迟应答机制,它有什么好处?
解析与答案:延迟应答是指接收端在收到数据后,不立刻返回 ACK 确认,而是稍微等待一段时间(或者等收到一定数量的包后)再发送 ACK。好处在于,这段延迟时间可以让接收端的应用层有时间处理掉接收缓冲区里的数据,从而在回复 ACK 时,能在 TCP 头部填入一个更大的"窗口大小"。窗口变大,网络的吞吐率和整体传输效率就会随之提升。
3. 发现服务器上出现了大量的 CLOSE_WAIT 状态,可能是什么原因?
解析与答案 :根据四次挥手的状态流转图,当服务端(被动关闭方)收到客户端的 FIN 并回复了 ACK 之后,服务端就会进入 CLOSE_WAIT 状态。此时服务端需要在处理完剩余业务逻辑后,主动调用 close(fd) 发送自己的 FIN 才能推进状态。如果服务器上出现大量 CLOSE_WAIT,毫无疑问是代码层面出现了 BUG:通常是因为业务逻辑漏洞、异常分支没有处理干净,导致忘了调用 close 函数关闭 Socket 文件描述符。