前端知库

TCP 的拥塞控制和流量控制有什么区别?

流量控制防止发送方把接收方的缓冲区塞满,靠接收方通告的窗口 rwnd;拥塞控制防止把网络塞满,靠发送方自己维护的拥塞窗口 cwnd。实际能发的数据量取两者较小值。拥塞控制包括慢启动、拥塞避免、快速重传和快速恢复。

参考答案

简答

两者都是限制发送方“一次能发多少”,区别在于怕把谁塞满。流量控制怕的是接收方:接收方在每个 ACK 里告诉发送方自己还剩多少缓冲空间,这个值叫接收窗口 rwnd。拥塞控制怕的是中间的网络:发送方自己估计网络能承受多少,维护一个拥塞窗口 cwnd。RFC 5681 规定,未确认的数据不能超过 cwnd 和 rwnd 中较小的那个。

拥塞控制分四个算法。连接刚建立时不知道网络状况,cwnd 从很小的初始窗口开始,每收到一个确认新数据的 ACK 就涨,大约每个往返翻一倍,这叫慢启动。涨到阈值 ssthresh 以后改为每个往返只加一个报文段,叫拥塞避免。

丢包被当作拥塞的信号。如果是重传定时器超时,说明情况比较严重,ssthresh 降到在途数据量的一半,cwnd 退回一个报文段重新慢启动。如果是连续收到 3 个重复 ACK,说明后面的包还在陆续到达,就不等超时直接重传丢失的那个,这叫快速重传,之后进入快速恢复,cwnd 减半左右继续发,不回到 1。

要注意,RFC 5681 描述的是标准的基础算法。按 RFC 9438 的说法,Linux、Windows 和 Apple 的协议栈默认用的是 CUBIC,它改变了拥塞避免阶段窗口的增长方式。面试时讲清这套基础模型即可,不要把它说成所有系统的具体实现。

流量控制:接收方说了算

TCP 首部里有一个窗口字段,接收方每次发 ACK 都会带上它,表示“从这个确认号往后,我还能收多少字节”。发送方在 RFC 9293 里用 SND.WND 记录这个值,发出去但没确认的数据不能超出它。

接收方的应用读得慢,缓冲区满了,就会通告窗口为 0,发送方只好停下。可是窗口重新打开的那个 ACK 本身也可能丢失,双方就会互相干等。所以 RFC 9293 要求发送方必须支持零窗口探测:窗口为 0 持续一个重传超时以后,发一个小探测段,迫使接收方回复当前窗口,探测间隔按指数增长。

拥塞控制:发送方自己估计

接收方缓冲区再大,中间路由器的队列也可能被挤满。网络不会直接告诉发送方“我堵了”,所以发送方只能通过丢包去推断,并用 cwnd 约束自己。RFC 5681 的原话是:任何时候,发送的序号不能超过“已确认的最高序号 + min(cwnd, rwnd)”。

下面用报文段为单位做一个简化模拟,每轮代表一个往返,看 cwnd 怎么变化。假设 ssthresh 初始为 8,第 6 轮发生超时。

JavaScript
let cwnd = 1;
let ssthresh = 8;
const log = [];
for (let round = 1; round <= 9; round++) {
  log.push(`${round}:${cwnd}`);
  if (round === 6) {
    ssthresh = Math.max(Math.floor(cwnd / 2), 2);
    cwnd = 1;
  } else if (cwnd < ssthresh) {
    cwnd = Math.min(cwnd * 2, ssthresh);
  } else {
    cwnd += 1;
  }
}
console.log(log.join(' ')); // 1:1 2:2 3:4 4:8 5:9 6:10 7:1 8:2 9:4
console.log('ssthresh =', ssthresh); // ssthresh = 5

const canSend = (cwnd, rwnd) => Math.min(cwnd, rwnd);
console.log(canSend(10, 4)); // 4
console.log(canSend(3, 64)); // 3

前 4 轮 cwnd 按 1、2、4、8 翻倍,这是慢启动。为什么叫“慢”却涨得这么快?因为它是从很小的窗口起步的,相对“一上来就按最大速度发”算慢。到了 ssthresh 以后每轮只加 1,这是拥塞避免。

第 6 轮超时,ssthresh 变成当时窗口的一半,也就是 5,cwnd 退回 1,又从慢启动开始。模拟里把在途数据量近似为 cwnd;RFC 用的是 FlightSize,也就是实际发出但未确认的数据量,公式是 ssthresh = max(FlightSize / 2, 2*SMSS)。

最后两行说明流量控制和拥塞控制同时生效:网络允许发 10 个但接收方只能收 4 个,就只发 4 个;接收方能收 64 个但网络只允许 3 个,就只发 3 个。

初始窗口有多大

RFC 5681 给初始窗口定的上限跟报文段大小有关:最大报文段不超过 1095 字节时最多 4 个,在 1095 到 2190 字节之间最多 3 个,更大时最多 2 个。ssthresh 的初始值则建议设得很高,让网络状况决定什么时候停止慢启动。后来 RFC 6928 作为实验性标准提出把初始窗口提高到 min(10*MSS, max(2*MSS, 14600)) 字节,大约 10 个报文段。

为什么 3 个重复 ACK 不用退回慢启动

接收方收到乱序的报文段时,会重复确认它还在等的那个序号,这就是重复 ACK。能收到重复 ACK,说明后面的报文段已经到达接收方,离开了网络,网络并没有完全堵死。

所以 RFC 5681 的做法是:收到第 3 个重复 ACK 时,把 ssthresh 设为在途数据量的一半,立刻重传丢失的报文段,把 cwnd 设为 ssthresh + 3 个报文段。之后每多一个重复 ACK,cwnd 再加一个报文段;等确认新数据的 ACK 到来,cwnd 收回到 ssthresh,继续拥塞避免。这样不用等超时,也不用从 1 重新开始。

常见追问

这些和前端有什么关系?

慢启动意味着新连接一开始发不了多少数据,所以首屏关键资源小一点、复用已有连接,都能少等几个往返。HTTP 的持久连接能复用已经“热起来”的连接,见《HTTP Keep-Alive 和持久连接是怎么回事?》。

连接空闲很久以后还能保持原来的窗口吗?

不一定。RFC 5681 描述了广泛部署的做法:超过一个重传超时都没收到报文段时,发送前先把 cwnd 降到重启窗口,也就是 min(IW, cwnd),再用慢启动重新建立 ACK 节奏,因为空闲期间网络状况可能已经变了。

流量控制和拥塞控制哪个窗口是写在报文里的?

只有接收窗口写在 TCP 首部的窗口字段里,由接收方通告。拥塞窗口是发送方内部的状态变量,不会出现在报文中。