Skip to content

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,发送方会怎么做?

基于 VitePress 构建