10个TCP可靠性与拥塞控制题目整理
TCP可靠性与拥塞控制:核心知识点整理
这篇文章整理了TCP可靠传输和拥塞控制相关的核心问题,包括滑动窗口、超时重传、拥塞控制算法,以及在项目中处理send()返回值的经验。
目录
- 前言
- Q1: TCP如何保证可靠性?
- Q2: ACK确认号的含义
- Q3: 超时重传是如何工作的?
- Q4: 什么是滑动窗口?
- Q5: 接收窗口为0怎么办?
- Q6: 什么是快重传?
- Q7: SACK解决了什么问题?
- Q8: 拥塞控制和流量控制的区别
- Q9: 拥塞控制的四个算法
- Q10: 项目中如何处理send()返回值?
- 学习总结
前言
学完TCP连接管理后,接着学习TCP的可靠传输机制。这部分内容比较复杂,涉及到序列号、ACK、滑动窗口、拥塞控制等概念。
最开始看王道视频时,感觉概念特别多,理解起来比较吃力。后来通过画图、写代码、实际调试,慢慢理解了这些机制的作用。
这篇文章记录了我对这些机制的理解,以及在Reactor项目中遇到的实际问题。
Q1: TCP如何保证可靠性?
我的理解
TCP通过以下几个机制来保证可靠传输:
核心机制:
-
序列号和ACK确认
- 每个字节都有序列号
- 接收方发送ACK确认收到的数据
- 发送方根据ACK知道哪些数据已经送达
-
超时重传
- 发送方设置定时器
- 超时未收到ACK,重传数据
- 确保数据不会因为丢包而丢失
-
滑动窗口
- 接收方告诉发送方可以接收多少数据(rwnd)
- 发送方根据rwnd调整发送速度
- 防止接收方来不及处理
其他机制:
- 快重传:收到3个重复ACK,立刻重传,不等超时
- 选择确认(SACK):明确告诉发送方哪些数据收到了
- 校验和:检测数据是否损坏
- 顺序控制:保证数据按顺序交付
我原来的疑问
Q:这么多机制,哪些是最重要的?
学习过程中发现,前三个机制(序列号+ACK、超时重传、滑动窗口)是最核心的,后面的机制都是在这基础上的优化。
Q2: ACK确认号的含义
我的理解
ACK号表示:“这个序号之前的所有数据我都已经收到了,下次请从这个序号开始发送。”
例子:
发送方发送:seq=100,数据长度200字节数据范围:100-299(共200字节)接收方回复:ACK=300ACK=300的含义:- "我已经收到了100-299的所有字节"- "下一次请从序号300开始发送"计算公式:ACK号 = 收到的最后一个字节的序号 + 1ACK = 299 + 1 = 300
累积确认:
如果接收方发送ACK=500,表示1-499的所有字节都已收到,不仅仅是某一段。
我原来的疑问
Q:为什么是+1,而不是直接用最后一个字节的序号?
一开始我也搞不清楚,后来明白了:ACK表示"期望收到的下一个字节",所以要+1。
Q3: 超时重传是如何工作的?
我的理解
工作原理:
- 发送方发送数据后,启动一个定时器(RTO时间)
- 等待接收方的ACK
- 如果在RTO时间内收到ACK → 停止定时器
- 如果超时未收到ACK → 重传数据
两种丢包情况:
情况1:数据丢失
发送方 → 接收方:数据包丢失 X
发送方:等待RTO超时
发送方:重传数据
接收方:收到数据,发送ACK
情况2:ACK丢失
发送方 → 接收方:数据包到达 ✓
接收方 → 发送方:ACK丢失 X
发送方:等待RTO超时
发送方:重传数据
接收方:检测到重复数据,丢弃,重发ACK
RTO的计算:
RTO(Retransmission Timeout)不是固定值,是动态计算的:
RTO = SRTT + 4 × RTTVAR其中:SRTT:平滑的RTT(Smoothed RTT)RTTVAR:RTT的偏差(Variation)Linux典型值:- 最小RTO:200ms- 初始RTO:1秒- 最大RTO:120秒
指数退避:
如果重传后还是超时:
第1次超时:RTO = 1秒,重传
第2次超时:RTO = 2秒,重传
第3次超时:RTO = 4秒,重传
第4次超时:RTO = 8秒,重传
...最多重传15次(Linux默认),然后放弃连接
我原来的疑问
Q:RTO为什么要动态计算,不能固定成1秒吗?
因为网络状况不同:
- 局域网RTT可能只有几毫秒,固定1秒太慢
- 跨国网络RTT可能几百毫秒,固定1秒可能太快
动态计算可以适应不同的网络环境。
Q4: 什么是滑动窗口?
我的理解
滑动窗口是TCP流量控制的核心机制。接收方告诉发送方自己能接收多少数据(rwnd),发送方根据rwnd调整发送速度。
为什么需要滑动窗口?
没有滑动窗口的问题(停止等待):
发送1个数据包 → 等待ACK → 发送1个 → 等待ACK问题:- 效率低:大量时间浪费在等待- 网络利用率低例子:RTT = 100ms数据段大小 = 1KB吞吐量 = 1KB / 100ms = 10KB/s如果网络带宽是100MB/s,利用率只有0.01%!
有滑动窗口后:
连续发送多个数据包,不需要每次都等待ACK例子:窗口大小 = 10个数据段(10KB)吞吐量 = 10KB / 100ms = 100KB/s利用率提高了10倍!
工作原理:
发送方维护一个发送窗口,窗口大小由接收方的rwnd决定:
发送方缓冲区:
┌──────────────────────────────────────────┐
│ 已发送已确认 │ 已发送未确认 │ 可发送 │ 不可发送 │
└───────────┴─────────────┴──────┴────────┘└──────────────────┘发送窗口接收方缓冲区:
┌──────────────────────────────────────────┐
│ 已接收已确认 │ 可接收(接收窗口)│ 不可接收 │
└───────────┴─────────────────┴──────────┘└───────────────┘rwnd发送窗口大小 = 接收方通知的rwnd
项目中的体现
在C++程序中,我们不需要手动实现滑动窗口,这是TCP协议栈自动处理的。但需要注意:
send()可能只发送部分数据(受发送窗口限制)- 需要循环发送,直到全部发送完成
Q5: 接收窗口为0怎么办?
我的理解
如果接收方缓冲区满了,会通知发送方rwnd=0,发送方就不能再发送数据了。
问题: 接收方缓冲区有空间后,如何通知发送方?
解决方法:零窗口探测(Zero Window Probe)
发送方的处理:
1. 收到rwnd=0后,停止发送数据
2. 定期发送1字节的探测包(Zero Window Probe)
3. 接收方回复ACK,携带最新的rwnd
4. 如果rwnd > 0,发送方恢复发送数据探测间隔:- 从1秒开始,指数退避- 第1次:1秒后- 第2次:2秒后- 第3次:4秒后- 最多探测15次
流程:
发送方 接收方| ||--- 数据 ---------------------->| 缓冲区满|<-- ACK, rwnd=0 ---------------|| || 停止发送,设置探测定时器 || ||--- Zero Window Probe (1字节)-->| 缓冲区仍满|<-- ACK, rwnd=0 ---------------|| || (1秒后) ||--- Zero Window Probe --------->| 缓冲区有空间|<-- ACK, rwnd=4096 ------------|| ||--- 继续发送数据 -------------->|
Q6: 什么是快重传?
我的理解
快重传(Fast Retransmit):收到3个重复ACK后,立刻重传数据,不需要等待超时。
触发条件: 收到3个重复ACK(同一ACK号共4次)
详细解释:
- 第1个ACK=101:正常确认(不算重复)
- 第2个ACK=101:重复ACK #1
- 第3个ACK=101:重复ACK #2
- 第4个ACK=101:重复ACK #3 ← 触发快重传!
完整流程:
发送方 接收方|------ seq=1 ------------------>| ✓ 收到|<----- ACK=101 ------------------| 正常ACK| ||------ seq=101 ---------------->| X(丢失)| ||------ seq=201 ---------------->| ✓(失序)|<----- ACK=101 ------------------| 重复ACK #1| ||------ seq=301 ---------------->| ✓(失序)|<----- ACK=101 ------------------| 重复ACK #2| ||------ seq=401 ---------------->| ✓(失序)|<----- ACK=101 ------------------| 重复ACK #3| || 收到3个重复ACK! || 立刻重传seq=101 ||------ seq=101 ---------------->| ✓ 收到| ||<----- ACK=501 ------------------| 确认所有数据
为什么是3个重复?
这是个经验值:
- 太少(1-2个):可能误判(正常的乱序)
- 太多(4-5个):反应太慢
- 3个:平衡了灵敏度和误判率
优势:
相比超时重传更快:
超时重传:等待RTO(可能1秒)
快重传:等待3个ACK(可能只需10-30ms)
延迟降低97%!
Q7: SACK解决了什么问题?
我的理解
SACK(Selective Acknowledgment,选择确认):在ACK报文中,明确告诉发送方哪些数据收到了。
累积确认的问题:
场景:多个数据包丢失发送方发送:1-100, 101-200, 201-300, 301-400接收方收到:1-100 ✓101-200 X(丢失)201-300 ✓301-400 ✓接收方只能回复:ACK=101表示:"101之前的都收到了"发送方的困惑:- 不知道201-300是否收到- 不知道301-400是否收到- 只能重传101-200, 201-300, 301-400(全部)- 浪费带宽
SACK的解决方案:
接收方回复:ACK=101, SACK=(201-400)含义:- ACK=101:101之前的都收到了- SACK=(201-400):201-400也收到了发送方的理解:- 101-200丢失- 201-400已收到- 只需要重传101-200 ✓
完整流程:
发送方 接收方|------ 1-100 ------------------->| ✓|------ 101-200 ----------------->| X(丢失)|------ 201-300 ----------------->| ✓|------ 301-400 ----------------->| ✓| ||<----- ACK=101, SACK=(201-400)--|| || 只重传101-200 ✓ ||------ 101-200 ----------------->| ✓| ||<----- ACK=401 -----------------|
我原来的疑问
Q:SACK是默认开启的吗?
不一定,需要双方都支持。Linux默认支持,可以通过以下命令查看:
cat /proc/sys/net/ipv4/tcp_sack
# 输出:1(开启)
Q8: 拥塞控制和流量控制的区别
我的理解
这两个容易混淆,但作用完全不同:
流量控制(Flow Control):
目的:防止发送方发送过快,接收方来不及处理
控制对象:接收方的接收能力
实现机制:滑动窗口(rwnd)
决定者:接收方通知发送方例子:接收方缓冲区:4096字节已接收未读取:1096字节剩余空间:3000字节接收方通知:rwnd=3000发送方:最多发送3000字节
拥塞控制(Congestion Control):
目的:防止网络拥塞
控制对象:网络的传输能力
实现机制:拥塞窗口(cwnd)
决定者:发送方自己判断例子:网络拥塞时:丢包率高、RTT增大发送方判断:网络拥塞了发送方:减小cwnd,降低发送速度
实际发送窗口:
发送窗口 = min(rwnd, cwnd)- rwnd:接收方的接收能力(流量控制)- cwnd:网络的传输能力(拥塞控制)- 取两者最小值例子:rwnd = 10000字节(接收方能接收)cwnd = 5000字节(网络能承受)发送窗口 = min(10000, 5000) = 5000字节
Q9: 拥塞控制的四个算法
我的理解
拥塞控制有四个核心算法,通过调整cwnd(拥塞窗口)来控制发送速度。
算法1:慢启动(Slow Start)
目的:连接建立时,探测网络容量原理:- cwnd初始值:1个MSS- 每收到1个ACK,cwnd += 1- cwnd指数增长:1 → 2 → 4 → 8 → 16 → 32...终止条件:- cwnd >= ssthresh(慢启动阈值)- 进入拥塞避免阶段例子:初始:cwnd=1, ssthresh=16第1个RTT:发送1个,收到1个ACK,cwnd=2第2个RTT:发送2个,收到2个ACK,cwnd=4第3个RTT:发送4个,收到4个ACK,cwnd=8第4个RTT:发送8个,收到8个ACK,cwnd=16cwnd达到ssthresh,进入拥塞避免
算法2:拥塞避免(Congestion Avoidance)
目的:避免网络拥塞,缓慢增加cwnd原理:- 每收到1个ACK,cwnd += 1/cwnd- cwnd线性增长:16 → 17 → 18 → 19...- 增长速度慢(每个RTT增加1个MSS)终止条件:- 发生超时重传(网络拥塞)- 进入慢启动(ssthresh = cwnd / 2,cwnd = 1)或- 收到3个重复ACK- 进入快恢复
算法3:快重传(Fast Retransmit)
目的:快速检测丢包并重传原理:- 收到3个重复ACK- 立刻重传,不等超时- 进入快恢复例子:cwnd=20, ssthresh=16收到3个重复ACK重传丢失的数据包进入快恢复
算法4:快恢复(Fast Recovery)
目的:快速恢复发送速度原理:- ssthresh = cwnd / 2- cwnd = ssthresh + 3- 每收到1个重复ACK,cwnd += 1(暂时增大窗口)- 收到新的ACK,cwnd = ssthresh,进入拥塞避免例子:发生快重传时:cwnd=20进入快恢复:ssthresh = 20 / 2 = 10cwnd = 10 + 3 = 13收到新ACK:cwnd = 10进入拥塞避免
完整状态转换:
慢启动(指数增长)cwnd: 1 → 2 → 4 → 8 → 16↓ cwnd >= ssthresh
拥塞避免(线性增长)cwnd: 16 → 17 → 18 → 19 → 20↓ 超时重传
慢启动(重新开始)ssthresh = cwnd / 2 = 10cwnd = 1↓
拥塞避免cwnd: 1 → 2 → 4 → 8 → 10 → 11 → 12↓ 收到3个重复ACK
快恢复ssthresh = cwnd / 2 = 6cwnd = 6 + 3 = 9↓ 收到新ACK
拥塞避免cwnd = 6
我原来的疑问
Q:为什么叫"慢启动",明明是指数增长很快?
这个名字是相对于"一开始就全速发送"来说的。虽然是指数增长,但从1开始,比直接全速发送要"慢"。
Q10: 项目中如何处理send()返回值?
我的实践经验
在实现Reactor项目时,我发现send()的返回值处理很重要,处理不当会导致数据丢失或程序卡死。
send()返回值的三种情况:
情况1:发送成功(返回值 > 0)
int send_data(int sockfd, const char* data, size_t len) {size_t total_sent = 0;while (total_sent < len) {int n = send(sockfd, data + total_sent, len - total_sent, 0);if (n > 0) {// 发送成功,可能没发完total_sent += n;// 关键:send()可能只发送部分数据// 因为受限于发送缓冲区大小和TCP窗口} else if (n == 0) {// 连接关闭return -1;} else { // n < 0if (errno == EAGAIN || errno == EWOULDBLOCK) {// 发送缓冲区满,需要等待// 非阻塞模式下常见break; // 等待下次EPOLLOUT事件} else {// 发送错误perror("send error");return -1;}}}return total_sent;
}
情况2:发送缓冲区满(EAGAIN/EWOULDBLOCK)
这是我在项目中遇到的最常见的情况:
// Reactor模式中的处理
class Connection {
public:void send_response(const string& response) {// 尝试直接发送int n = send(sockfd_, response.c_str(), response.size(), 0);if (n == response.size()) {// 全部发送完成return;}if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {n = 0; // 一个字节都没发送}if (n < response.size()) {// 只发送了部分,剩余数据放入发送缓冲区send_buffer_.append(response.c_str() + n, response.size() - n);// 注册EPOLLOUT事件,等待可写epoll_event ev;ev.events = EPOLLIN | EPOLLOUT | EPOLLET;ev.data.ptr = this;epoll_ctl(epollfd_, EPOLL_CTL_MOD, sockfd_, &ev);}}void handle_write_event() {// 发送缓冲区中的数据while (!send_buffer_.empty()) {int n = send(sockfd_, send_buffer_.c_str(), send_buffer_.size(), 0);if (n > 0) {send_buffer_.erase(0, n);if (send_buffer_.empty()) {// 全部发送完成,取消EPOLLOUTepoll_event ev;ev.events = EPOLLIN | EPOLLET;ev.data.ptr = this;epoll_ctl(epollfd_, EPOLL_CTL_MOD, sockfd_, &ev);}} else if (n < 0) {if (errno == EAGAIN || errno == EWOULDBLOCK) {// 缓冲区又满了,等待下次EPOLLOUTbreak;} else {// 发送错误,关闭连接close_connection();break;}}}}private:int sockfd_;int epollfd_;string send_buffer_;
};
情况3:连接错误
void handle_send_error(int sockfd, int err) {if (err == EPIPE) {// 对方已关闭连接(Broken pipe)LOG_WARN("Connection closed by peer");close(sockfd);} else if (err == ECONNRESET) {// 连接被重置(对方发送RST)LOG_WARN("Connection reset by peer");close(sockfd);} else {// 其他错误LOG_ERROR("send error: %s", strerror(err));close(sockfd);}
}
我踩过的坑
坑1:以为send()会发送全部数据
// 错误代码
send(sockfd, data, len, 0); // 以为发送完了
// 实际上可能只发送了一部分!
坑2:没有处理EAGAIN
// 错误代码
int n = send(sockfd, data, len, 0);
if (n < 0) {// 直接关闭连接close(sockfd); // 错误!可能只是缓冲区满
}
坑3:没有使用应用层发送缓冲区
// 错误代码
int n = send(sockfd, data, len, 0);
// 只发送了部分,剩余数据丢失!
正确做法:
send()可能只发送部分数据,必须循环发送- 非阻塞模式下,处理EAGAIN,使用应用层发送缓冲区
- 注册EPOLLOUT事件,等待可写后再发送
学习总结
这段时间学习TCP可靠性和拥塞控制,收获很大:
理论层面:
- 理解了TCP如何通过序列号、ACK、重传保证可靠性
- 搞清楚了滑动窗口的工作原理
- 理解了拥塞控制四大算法的状态转换
实践层面:
- 在项目中正确处理
send()返回值 - 实现了应用层发送缓冲区
- 使用EPOLLOUT事件处理发送缓冲区满的情况
遇到的坑:
- 以为
send()会发送全部数据 - 没有处理EAGAIN导致连接被错误关闭
- 没有应用层发送缓冲区导致数据丢失
下一步学习:
- HTTP协议基础
- Cookie和Session机制
- HTTP缓存机制
参考资料
- 王道计算机网络视频课程
这篇文章记录了我对TCP可靠性和拥塞控制的理解。如果有错误或不准确的地方,欢迎指正!

