TCP 滑动窗口与流量控制
想象一下:你是一个邮递员,负责送信。如果收件人家门口的信箱只能放 10 封信,而你送了 100 封,会发生什么?
剩下的 90 封信只能堆在你手里——要么扔掉,要么等你找到新箱子。
TCP 的滑动窗口,就是解决这个问题的机制。
滑动窗口的核心思想
滑动窗口让发送方可以连续发送多个数据段,而不需要等待每个数据段的确认。
传统方式(停-等协议):
发送 ─── 数据1 ──> 等待确认 ─── 数据2 ──> 等待确认 ─── 数据3 ──> ...
ACK ACK ACK
滑动窗口:
发送 ─── 数据1 ──── 数据2 ──── 数据3 ────> 无需等待 ACK
ACK ACK ACK滑动窗口的好处是:让网络带宽得到充分利用。
滑动窗口的三个区域
┌──────────────────────────────────────────────────────────────────────┐
│ 发送方滑动窗口 │
├──────────────┬─────────────────────────┬────────────────────────────┤
│ 已发送 │ 可发送 │ 不可发送 │
│ 已确认 │ │ │
├──────────────┼─────────────────────────┼────────────────────────────┤
│ │ 已发送 │ 等待发送 │ │
│ │ 未确认 │ │ │
│ │ │ │ │
│ seq 已确认 │ seq 已发送 │ seq 可发送 │ seq > 窗口右沿 │
│ │ 未收到 ACK │ │ │
└──────────────┴─────────────────────────┴────────────────────────────┘
↑ ↑
左边收到 ACK 右边收到窗口更新
向右滑 向右滑- 已发送,已确认:这部分数据已经成功交付,可以释放
- 已发送,未确认:已发送但还在等待 ACK
- 可发送:窗口内,可以立即发送
- 不可发送:窗口外,不能发送
窗口大小的决定因素
窗口大小 = min(接收方通告窗口, 拥塞窗口)流量控制和拥塞控制共同决定窗口大小。
流量控制详解
为什么需要流量控制?
接收方处理能力有限:
- 缓冲区大小有限
- CPU 处理速度有限
- 应用层读取速度有限
如果发送方发得太快,接收方会缓冲区溢出,导致丢包。
窗口通告
接收方在 TCP 头部中通告自己的窗口大小:
TCP 头部字段:
┌──────────────────────────────────────────────────────────────┐
│ 源端口 │ 目的端口 │
├──────────────────────────────────────────────────────────────┤
│ 序号 │
├──────────────────────────────────────────────────────────────┤
│ 确认号 │
├──────────┬──────────┬──────────┬──────────┬─────────────────┤
│ 数据偏移 │ 保留 │ NS │CWR│ECE│URG│ACK│PSH│RST│SYN│FIN│
├──────────┴──────────┴──────────┴──────────┴─────────────────┤
│ 窗口大小 │
├──────────────────────────────────────────────────────────────┤
│ 校验和 │ 紧急指针 │
└──────────────────────────────────────────────────────────────┘
↑
16 位的窗口大小字段流量控制过程
接收方缓冲区情况:
┌─────────────────────────────────────────────────────┐
│ 接收缓冲区 │
│ │
│ 已接收并确认 │ 可用空间 │ 缓冲区满 │
│ │ │ │
│ 0 ────────────│───────────────────────│────── 64KB │
│ │ 32KB │ │
│ │ (win=32KB) │ │
└─────────────────┴─────────────────────┴─────────────┘
发送 ACK + win=32KB发送方收到 ACK:
┌─────────────────────────────────────────────────────┐
│ 发送方滑动窗口 │
│ │
│ 已发送 │ 可发送 (32KB) │ 不可发送 │
│ 已确认 │ │ │
│ │ │ │
│ 0 ──────│─────────────────│─────── 32KB │
│ │ │ │
└──────────┴──────────────────┴───────────────────────┘窗口更新
正常情况
接收方应用层读取数据后,释放缓冲区空间,窗口变大:
时间 0: win = 32KB
应用层读取 16KB
时间 1: win = 48KB(窗口变大)
发送方收到 ACK(win=48KB)
发送方可以多发 16KB 数据零窗口
接收方缓冲区满了,窗口变为 0:
接收方 ──── ACK (win=0) ───────────────────────> 发送方
「我没有空间了,暂停发送」
发送方收到零窗口,停止发送数据窗口探测(Window Probing)
发送方不能一直等,要定期探测窗口是否恢复:
发送方 ──── 探测 1 字节 ──────────────────────> 接收方
窗口大小 = 0,不能发正常数据
接收方 ──── ACK (win=?) ───────────────────────> 发送方
如果 win > 0,恢复发送
如果 win = 0,继续等待探测间隔通常是指数增长的:
- 第一次:1 秒后
- 第二次:2 秒后
- 第三次:4 秒后
- ...
糊涂窗口综合征(Silly Window Syndrome)
问题描述
如果接收方每次只释放很少空间就通告窗口,发送方每次只发送很少数据,网络效率会很低。
场景:
- 接收方缓冲区只有 1 字节可用
- 通告 win=1
- 发送方发送 1 字节数据
- 大量小报文,网络利用率极低解决方案:Nagle 算法(发送端)
Nagle 算法:收集小数据,凑成大块再发送。
规则:
1. 如果有未确认的数据,先缓存新数据
2. 如果没有未确认的数据,立即发送
3. 收到 ACK 后,发送所有缓存的数据(或直到达到 MSS)java
// Java 中 Nagle 算法控制
Socket socket = new Socket();
socket.setTcpNoDelay(true); // 禁用 Nagle 算法
socket.setTcpNoDelay(false); // 启用 Nagle 算法(默认)解决方案:Clark 解决方案(接收端)
不要为小空间通告窗口,只在缓冲区达到一定大小后才通告。
规则:
- 只有缓冲区空间 >= MSS 时才通告窗口
- 或者缓冲区已空时才通告窗口滑动窗口在协议中的体现
三次握手时的窗口协商
客户端 ──── SYN, win=65535 ──────────────────> 服务端
客户端通告窗口大小
服务端 ──── SYN+ACK, win=65535 ───────────────> 客户端
服务端也通告窗口大小
双方确定对方的接收能力数据传输
发送方 ──── 数据1 (seq, len) ──────────────────> 接收方
发送方 ──── 数据2 (seq+len, len) ──────────────> 接收方
发送方 ──── 数据3 (seq+2*len, len) ────────────> 接收方
接收方 ──── ACK (ack=seq+3*len, win=?) ───────> 发送方
确认收到 seq 开始的全部数据
通告剩余空间Java 代码演示
java
import java.io.*;
import java.net.*;
public class SlidingWindowDemo {
public static void main(String[] args) throws Exception {
// 创建服务器
ServerSocket serverSocket = new ServerSocket(0);
int port = serverSocket.getLocalPort();
System.out.println("服务端监听端口: " + port);
// 获取默认缓冲区大小
System.out.println("默认接收缓冲区: " +
serverSocket.getReceiveBufferSize() + " bytes");
// 启动客户端
new Thread(() -> {
try {
Socket client = new Socket("127.0.0.1", port);
// 设置缓冲区大小
client.setReceiveBufferSize(1024); // 1KB
client.setSendBufferSize(1024);
System.out.println("客户端缓冲区设置:");
System.out.println(" 接收: " + client.getReceiveBufferSize());
System.out.println(" 发送: " + client.getSendBufferSize());
// 发送数据
OutputStream out = client.getOutputStream();
for (int i = 0; i < 100; i++) {
out.write(("Message " + i + "\n").getBytes());
out.flush();
Thread.sleep(10);
}
client.close();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
// 服务器接受连接并读取
Socket socket = serverSocket.accept();
InputStream in = socket.getInputStream();
BufferedReader reader = new BufferedReader(
new InputStreamReader(in));
String line;
while ((line = reader.readLine()) != null) {
System.out.println("收到: " + line);
}
socket.close();
serverSocket.close();
}
}查看滑动窗口状态
bash
# Linux 查看 TCP 内存和窗口信息
ss -t -m
# 输出示例
# State Recv-Q Send-Q Local Address:Port Peer Address:Port
# ESTABLISHED 0 0 127.0.0.1:8080 127.0.0.1:54321
# skmem:(r8192,rb369280,t2097152,tb262144,f717824,w2097152,o0,...)
# r8192: 当前接收缓冲区使用
# rb369280: 接收缓冲区上限
# t2097152: 发送缓冲区上限bash
# 查看详细统计
netstat -an | grep :8080
# Windows 查看
netstat -ano | findstr :8080常见问题
问题一:高并发下窗口太小
现象:网络带宽很大,但传输速度上不去
原因:接收方窗口通告太小
解决:
1. 增加 socket 缓冲区大小
2. 检查是否有零窗口问题java
// 增大缓冲区
socket.setReceiveBufferSize(1024 * 1024); // 1MB
socket.setSendBufferSize(1024 * 1024);问题二:零窗口导致卡死
现象:连接建立成功,但数据传不动
原因:接收方缓冲区满,通告零窗口
解决:
1. 确认接收方应用层在读取数据
2. 检查是否有大量 CLOSE_WAIT 未关闭问题三:窗口缩放
现代网络带宽很大,16 位窗口字段(最大 64KB)不够用。
TCP 窗口缩放(Window Scaling)允许协商更大的窗口:
三次握手时:
选项:窗口缩放因子 (shift)
shift=3 → 实际窗口 = 通告窗口 * 8
最大 shift = 14
最大窗口 = 65535 * 2^14 ≈ 1GB面试追问方向
- 什么是滑动窗口?它解决了什么问题?
- 滑动窗口有三个区域分别是什么?
- 什么是流量控制?和拥塞控制的区别是什么?
- 接收方如何通告窗口大小?
- 什么是零窗口?如何处理?
- 什么是糊涂窗口综合征?如何解决?
- Nagle 算法的作用是什么?
- 什么是 TCP 窗口缩放?为什么需要它?
- 如果接收方窗口为 0,发送方会怎么做?
