图 1:本文封面,点明两套调速器的分工。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「说说 TCP 的流量控制和拥塞控制」是网络面试的保留题——两个词都带「控制」,混为一谈的人一抓一把,面试官就靠这个筛人。
想象面试现场,面试官问:TCP 有流量控制也有拥塞控制,它们分别解决什么问题?先把一句话的答案钉在台面上:流量控制解决点对点的问题——别把接收方撑爆;拥塞控制解决全局的问题——别把网络压垮。一个看接收方的缓冲,一个看网络的承载,根本不是一回事。
第一套调速器:滑动窗口流量控制。
接收方每次回 ACK,都会捎上一个窗口通告:我的接收缓冲还剩多少地方,这个数就是 rwnd(接收窗口)。发送方立下规矩:已经发出去但还没被确认的数据,不能超过 rwnd——接收方说只能再接 8 段,你就最多压着 8 段在途。
所谓滑动窗口,是把发送方的数据流看成四段:已确认、已发未确认、可发、暂不可发。每收到一个 ACK,窗口左沿右移;接收方读走数据、缓冲腾出地方,下一次 ACK 的通告窗口随之变大,发送方才获准提速。这套机制在 TCP 规范(RFC 9293)里就是窗口机制的本职:让发送速率匹配接收方的消化能力。极端情况下缓冲满了会通告零窗口,发送方停发,靠后续窗口更新(必要时加零窗口探测)恢复。
第二套调速器:慢启动与拥塞控制。
接收方缓冲够大,不代表网络扛得住。路由器队列溢出就是丢包,丢包就要重传,全网都这么蛮干就是拥塞崩溃。所以发送方自己再维护一个拥塞窗口 cwnd,用探测的方式摸网络的脾气。RFC 5681 定义的经典算法里,和本题最相关的是两个:
慢启动:连接刚建立,cwnd 从很小的初始值出发,每收到一个新数据的 ACK 就加一个段——效果是每个 RTT 翻倍,指数爬坡,快速逼近网络的可用容量。
拥塞避免:cwnd 涨到阈值 ssthresh 后转入保守模式,每个 RTT 只加一个段,线性爬坡,小心试探。
一旦检测到丢包,就当作网络喊疼了:把 ssthresh 砍到当时在途数据量的一半,cwnd 重新起跳(超时这种严重丢包直接回到一个段,重新慢启动)。于是经典的 cwnd 曲线呈锯齿状:指数升、线性升、丢包回落、再升——永远在「快一点」和「退一步」之间找平衡。
两套调速器怎么配合:谁小听谁的。
答案简单到一句话:发送方实际能发的在途数据量,上限是 min(rwnd, cwnd)。rwnd 是接收方给的许可,cwnd 是自己对网络的估计,两个约束同时生效,谁更紧就听谁的。RFC 5681 的定义原文也是这么规定的:发送方不得发出序号超过「最高已确认序号加上 cwnd 与 rwnd 较小者」的数据。
用可运行代码把两套窗口演一遍。
下面这份代码按 RTT 模拟 cwnd 的慢启动、拥塞避免与丢包回落,并让 rwnd 全程在场,观察实际发送窗口由谁决定,用 Node.js 22.15.0 直接运行(node tcp-windows.js)即可:
'use strict';
// 每 RTT 模拟一次:慢启动翻倍、拥塞避免加一、丢包砍半重起;实际窗口 = min(rwnd, cwnd)
const RWND = 8; // 接收方通告的剩余缓冲(段)
let cwnd = 1; // 拥塞窗口(段)
let ssthresh = 16;
console.log('RTT | cwnd | rwnd | 实际可发 min(rwnd, cwnd) | 阶段');
for (let rtt = 1; rtt <= 20; rtt++) {
const send = Math.min(RWND, cwnd);
const phase = cwnd < ssthresh ? '慢启动' : '拥塞避免';
console.log(`${String(rtt).padStart(3)} | ${String(cwnd).padStart(4)} | ${String(RWND).padStart(4)} | ${String(send).padStart(4)} | ${phase}${send === RWND && cwnd > RWND ? '(被接收方限速)' : ''}`);
if (rtt === 14) { // 模拟丢包(超时):阈值砍半,窗口回到 1 重新慢启动
ssthresh = Math.max(Math.floor(cwnd / 2), 2);
cwnd = 1;
console.log('--- 检测到丢包:ssthresh 降为 ' + ssthresh + ',cwnd 重起 ---');
continue;
}
if (cwnd < ssthresh) cwnd *= 2; // 慢启动:每 RTT 翻倍
else cwnd += 1; // 拥塞避免:每 RTT 加一段
}
实测输出如下(第 6 至 13 轮与第 5 轮同形态,省略):
RTT | cwnd | rwnd | 实际可发 min(rwnd, cwnd) | 阶段
1 | 1 | 8 | 1 | 慢启动
2 | 2 | 8 | 2 | 慢启动
3 | 4 | 8 | 4 | 慢启动
4 | 8 | 8 | 8 | 慢启动
5 | 16 | 8 | 8 | 拥塞避免(被接收方限速)
…(第 6~13 轮:cwnd 线性爬升到 25,实际可发始终被 rwnd 按在 8)…
--- 检测到丢包:ssthresh 降为 12,cwnd 重起 ---
15 | 1 | 8 | 1 | 慢启动
16 | 2 | 8 | 2 | 慢启动
17 | 4 | 8 | 4 | 慢启动
18 | 8 | 8 | 8 | 慢启动
19 | 16 | 8 | 8 | 拥塞避免(被接收方限速)
20 | 17 | 8 | 8 | 拥塞避免(被接收方限速)
两个阶段一目了然:前 4 个 RTT,cwnd 比 rwnd 小,拥塞控制说了算,窗口指数爬;第 5 个 RTT 起 cwnd 超过 8,实际发送被 rwnd 按在 8——接收方成了瓶颈,流量控制接管。第 14 轮丢包后阈值砍半、窗口重起,锯齿曲线成型。
图 2:两套调速器各自的目标、机制与汇合规则。
可能的追问。
「丢包一定是拥塞吗?」不一定,无线链路会随机丢包——这正是后来 BBR 等新算法改用带宽与 RTT 实测代替丢包信号的动机,一句带过即可。「rwnd 为零会怎样?」零窗口,发送方停发并周期性探测,等接收方腾地方。「三次握手里窗口怎么定?」初始 rwnd 由接收方通告,cwnd 有规范给定的初始上限,都从保守起步。
易错点清单。
一,把两者混成一个「窗口机制」——rwnd 是接收方通告的,cwnd 是发送方自己估的,来源都不同。二,以为慢启动很慢——它是指数增长,是爬坡最快的阶段,「慢」只相对一开始的保守而言。三,忘记 min 规则,声称「窗口大就能猛发」。四,把流量控制说成拥塞控制的子集——它们目标不同、缺一不可。
回到面试现场,建议的答题节奏:先用「点对点 vs 全局」一句话把两者劈开,再分别讲 rwnd 通告与 cwnd 的慢启动、拥塞避免、丢包回落,最后用 min(rwnd, cwnd) 收口。大模型推理是典型的长耗时连接,网关和客户端的缓冲、超时配置都要懂这套速率调节;拥塞控制「探测式提速、受挫即退」的思路,和服务端限流异曲同工。
你排查过「带宽够但传输就是快不起来」的问题吗?最后发现卡在哪一层?欢迎分享。