TCP可靠性与拥塞控制:核心知识点整理
这篇文章整理了TCP可靠传输和拥塞控制相关的核心问题,包括滑动窗口、超时重传、拥塞控制算法,以及在项目中处理send()返回值的经验。
目录
- 前言
- [Q1: TCP如何保证可靠性?](#Q1: TCP如何保证可靠性?)
- [Q2: ACK确认号的含义](#Q2: ACK确认号的含义)
- [Q3: 超时重传是如何工作的?](#Q3: 超时重传是如何工作的?)
- [Q4: 什么是滑动窗口?](#Q4: 什么是滑动窗口?)
- [Q5: 接收窗口为0怎么办?](#Q5: 接收窗口为0怎么办?)
- [Q6: 什么是快重传?](#Q6: 什么是快重传?)
- [Q7: SACK解决了什么问题?](#Q7: SACK解决了什么问题?)
- [Q8: 拥塞控制和流量控制的区别](#Q8: 拥塞控制和流量控制的区别)
- [Q9: 拥塞控制的四个算法](#Q9: 拥塞控制的四个算法)
- [Q10: 项目中如何处理send()返回值?](#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=300
ACK=300的含义:
- "我已经收到了100-299的所有字节"
- "下一次请从序号300开始发送"
计算公式:
ACK号 = 收到的最后一个字节的序号 + 1
ACK = 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默认支持,可以通过以下命令查看:
bash
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=16
cwnd达到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 = 10
cwnd = 10 + 3 = 13
收到新ACK:
cwnd = 10
进入拥塞避免
完整状态转换:
慢启动(指数增长)
cwnd: 1 → 2 → 4 → 8 → 16
↓ cwnd >= ssthresh
拥塞避免(线性增长)
cwnd: 16 → 17 → 18 → 19 → 20
↓ 超时重传
慢启动(重新开始)
ssthresh = cwnd / 2 = 10
cwnd = 1
↓
拥塞避免
cwnd: 1 → 2 → 4 → 8 → 10 → 11 → 12
↓ 收到3个重复ACK
快恢复
ssthresh = cwnd / 2 = 6
cwnd = 6 + 3 = 9
↓ 收到新ACK
拥塞避免
cwnd = 6
我原来的疑问
Q:为什么叫"慢启动",明明是指数增长很快?
这个名字是相对于"一开始就全速发送"来说的。虽然是指数增长,但从1开始,比直接全速发送要"慢"。
Q10: 项目中如何处理send()返回值?
我的实践经验
在实现Reactor项目时,我发现send()的返回值处理很重要,处理不当会导致数据丢失或程序卡死。
send()返回值的三种情况:
情况1:发送成功(返回值 > 0)
cpp
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 < 0
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 发送缓冲区满,需要等待
// 非阻塞模式下常见
break; // 等待下次EPOLLOUT事件
} else {
// 发送错误
perror("send error");
return -1;
}
}
}
return total_sent;
}
情况2:发送缓冲区满(EAGAIN/EWOULDBLOCK)
这是我在项目中遇到的最常见的情况:
cpp
// 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()) {
// 全部发送完成,取消EPOLLOUT
epoll_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) {
// 缓冲区又满了,等待下次EPOLLOUT
break;
} else {
// 发送错误,关闭连接
close_connection();
break;
}
}
}
}
private:
int sockfd_;
int epollfd_;
string send_buffer_;
};
情况3:连接错误
cpp
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()会发送全部数据
cpp
// 错误代码
send(sockfd, data, len, 0); // 以为发送完了
// 实际上可能只发送了一部分!
坑2:没有处理EAGAIN
cpp
// 错误代码
int n = send(sockfd, data, len, 0);
if (n < 0) {
// 直接关闭连接
close(sockfd); // 错误!可能只是缓冲区满
}
坑3:没有使用应用层发送缓冲区
cpp
// 错误代码
int n = send(sockfd, data, len, 0);
// 只发送了部分,剩余数据丢失!
正确做法:
send()可能只发送部分数据,必须循环发送- 非阻塞模式下,处理EAGAIN,使用应用层发送缓冲区
- 注册EPOLLOUT事件,等待可写后再发送
学习总结
这段时间学习TCP可靠性和拥塞控制,收获很大:
理论层面:
- 理解了TCP如何通过序列号、ACK、重传保证可靠性
- 搞清楚了滑动窗口的工作原理
- 理解了拥塞控制四大算法的状态转换
实践层面:
- 在项目中正确处理
send()返回值 - 实现了应用层发送缓冲区
- 使用EPOLLOUT事件处理发送缓冲区满的情况
遇到的坑:
- 以为
send()会发送全部数据 - 没有处理EAGAIN导致连接被错误关闭
- 没有应用层发送缓冲区导致数据丢失
下一步学习:
- HTTP协议基础
- Cookie和Session机制
- HTTP缓存机制
参考资料
- 王道计算机网络视频课程
这篇文章记录了我对TCP可靠性和拥塞控制的理解。如果有错误或不准确的地方,欢迎指正!
