程云来 / 杭州
返回文章·计算机网络
·约 13 分钟

网络面试(十一):TCP 为什么不能一直发?

用字节窗口算出还能发送多少,区分接收端的 rwnd 与发送端的 cwnd,再追踪零窗口恢复、慢启动、拥塞避免和经典 Reno 的丢包响应。


能重传,为什么还要限制发送?

A 向 S 传输文件。即使 TCP 能发现缺口并重传,A 也不能把整个文件一下子塞进网络:S 的应用可能读得很慢;双方都很快,中间的共享出口也可能排起长队。

这两种等待发生在不同位置。流量控制关心接收方还能接多少,拥塞控制关心发送方应该向路径注入多少。 能修复丢失,不代表制造更多丢失没有代价。

下面固定研究 A → S 方向,数据起点为 1001。所有数值都是教学设定,没有真实抓包;字节窗口、理想增长轨迹和两条丢包分支分别列出前提。

窗口允许的是一批未确认字节

如果每发 1000 字节就等一次 ACK,RTT 为 100ms,那么理想情况下每秒只能确认约 10 段,也就是 10,000 字节。链路即使还有余力,也会大量闲着。

TCP 可以让多段数据在确认回来前同时处于未确认状态。接收端通过报文的 Window 字段通告接收窗口(Receiver Window,rwnd);它配合 ACK 指示当前可接收的字节范围。发送端随着确认和窗口更新移动这个范围,这就是滑动窗口。RFC 9293 §3.8.6

先假设 cwnd 足够大、无丢失乱序、窗口不缩小,A 有持续待发数据:

时刻S 通告的 ACK / rwndA 已发但未累计确认A 还能新发的范围
初始1001 / 4000无[1001, 5001),4000 字节
发出四段后仍为 1001 / 4000[1001, 5001)暂无
S 收到前两段、应用尚未读走3001 / 2000[3001, 5001)暂无
S 应用读走这 2000 字节并更新窗口3001 / 4000[3001, 5001)[5001, 7001),2000 字节

本例接收缓冲容量固定为 4000 字节,通告窗口用剩余容量简化表示;真实系统还涉及缓存管理和窗口更新策略。第三行的 ACK 已经前进,接收窗口右边界却仍是 3001 + 2000 = 5001。因此“收到 ACK”并不保证立刻有新的发送空间。

第四行说明另一个容易混淆的关系:TCP 收到字节就可以确认,应用读取字节则可能释放接收空间,两件事不必同时发生。 窗口更新也可以不推进 ACK。若应用持续不读,即使数据没有丢失,发送仍可能停下来。

Window 字段表达的是发送这份报文的一方自己的接收能力。因此 S → A 的 ACK 通告的窗口,约束的是 A → S 的数据;反方向的数据有另一套窗口。

rwnd 与 cwnd,要取小值,还要扣掉已经发出的量

即使 S 表示“我还能接”,路径也未必承受得住。A 维护拥塞窗口(Congestion Window,cwnd),根据拥塞控制算法调整;它不是 TCP 头部里由 S 通告的字段。RFC 5681 §2

量由谁提供或维护回答的问题
rwndS 通告,A 保存其有效更新接收方允许多少未确认数据?
cwndA 的拥塞控制维护发送方当前允许向路径注入多少?
FlightSizeA 根据发送与累计确认状态计算多少字节已经发送但还未累计确认?

在普通按序发送、无恢复过程的简化模型中,可用下面两步估算:

text
未确认数据的窗口上限 = min(rwnd, cwnd)
还能新发的字节数 = max(0, min(rwnd, cwnd) - FlightSize)

例如 rwnd=8000、cwnd=5000、FlightSize=3000,窗口上限是 5000,但新发额度只有 2000 字节;再发 5000 会把未确认量推到 8000,超过拥塞限制。若 rwnd 降为 2000,而已有 3000 字节未确认,新发额度按零处理,已经发出的字节不会被“撤回”。

这个差值不是完整的 TCP 发送器算法。实际发送还受应用是否有数据、字节序号与有效窗口更新、分段、节奏控制等因素影响;SACK 恢复会用更细的在途估计与规则,不能只套一个减法处理所有重传。RFC 6675 §2、§5

窗口的单位也不是速度。若稳定传输主要受 5000 字节窗口限制,RTT 为 100ms,则理想吞吐量级约为 5000 / 0.1 = 50,000 字节/秒。这是本例推导,忽略协议开销、丢包和应用等待;它不是“cwnd 为 5000,所以带宽为 5000”。

动手算一算,还能新发多少

先看路径限制,再把接收窗口降到未确认量以下。上限与新发额度为什么不是同一个数字?最后把额度用满,观察两个窗口非零时为什么仍不能继续新发。

窗口不是新发额度,还要扣掉多少?

固定 A → S。切换场景或调整三个字节量,比较接收限制、路径限制与已占用额度。

未确认窗口上限:5000 字节

min(8000, 5000)

当前受拥塞窗口限制
还能新发:2000 字节

max(0, 5000 − 3000)

上限需要扣除已经发送的未确认字节。

教学状态快照,单位为字节,不是速率。假设普通按序发送、无丢失恢复,应用持续有数据;调整量是切换设定,不模拟真实窗口收缩轨迹。只演示 max(0, min(rwnd,cwnd)−FlightSize),不覆盖SACK恢复、节奏控制、窗口更新校验或具体拥塞算法。

rwnd 变成零,为什么不会永远互相等?

沿用 A → S:S 的应用暂时停止读取,缓冲占满,向 A 通告零窗口。A 暂停普通新数据发送,连接不因这个窗口值自动结束。

现在 S 的应用恢复,读走数据并发出“窗口重新打开”的更新。如果这份更新丢了,A 仍认为窗口为零;S 又可能认为自己已经通知过。仅等待新业务数据,双方就可能持续停住。

TCP 使用零窗口探测(Zero-Window Probing):发送方按探测机制发出少量探测,接收方回复当前确认号和窗口,使打开后的窗口能够再次被发现。它解决的是窗口更新丢失后的进展问题;不要把它等同于连接空闲时的 Keepalive。RFC 9293 §3.8.6.1

本例的排查路径是:看到 S 通告零窗口 → 检查 S 应用读取是否停滞 → 恢复读取 → 检查后续窗口更新与发送进展。增大缓冲只能延后填满的时间,不能让一个始终不读数据的应用恢复工作。业务仍需自己的超时与资源策略,不能把探测当作无限成功保证。

窗口太小会限制长 RTT 路径上的吞吐,所以 TCP 还可在握手时协商窗口扩大(Window Scale)。例如 S 为自己的接收窗口通告 shift=3,后续非 SYN 报文的 Window 字段为 4000,A 按 4000 × 2³ = 32,000 字节解释。两个方向的扩大因子可不同,SYN 中的 Window 字段本身不扩大;握手之后也不能随意重新协商因子。RFC 7323 §2.2

这只是让接收窗口能表达更大的数,仍然没有取消 cwnd 的限制。

接收端很空,网络为什么仍会慢?

如果 S 读取很及时,rwnd 很大,而 A 与其他连接共用一个出口,突发数据会在出口排队。队列增大可推高 RTT,缓冲耗尽还可能丢包。盲目重传会继续占用这个出口。

发送端无法直接读取整条路径的“剩余带宽表”。它依据反馈调整发送策略。经典的丢包型算法把丢失作为拥塞信号;这不意味着每一次丢失都能证明路由器队列满了。若启用显式拥塞通知(Explicit Congestion Notification,ECN),路径也可以通过标记让端点得知拥塞,不必先丢掉这份报文。RFC 3168 §5、§6

面试中的“慢启动—拥塞避免—快速恢复”通常是在解释经典算法。下面明确采用 Reno 风格模型;真实系统可以选择其他拥塞控制,不能拿同一条锯齿轨迹代替所有实现。

慢启动:起点小,不是每轮只加一点

设定发送端最大数据段(SMSS)为 1000 字节,初始 cwnd 为 1 SMSS,慢启动阈值(ssthresh)为 8 SMSS。这里用段数方便阅读,窗口仍可换算成字节。

再假设应用持续有数据、rwnd 足够大、无丢失,每个完整数据段都得到独立的及时累计 ACK。按 ACK 驱动增长,理想轮次如下:

时点cwnd接下来采用的阶段
初始1 SMSS慢启动
一轮数据确认后2 SMSS慢启动
两轮数据确认后4 SMSS慢启动
三轮数据确认后8 SMSS本例在阈值处转入拥塞避免
之后一轮确认后约 9 SMSS拥塞避免
再一轮确认后约 10 SMSS拥塞避免

在慢启动中,新增确认驱动窗口增长;一轮里能收到的确认数量也随窗口增大,所以理想情况下出现约每 RTT 翻倍。进入经典拥塞避免后,增长收敛为约每 RTT 增加 1 SMSS。等于阈值时,规范允许选择慢启动或拥塞避免,本例选后者。RFC 5681 §3.1

表格把连续的 ACK 过程归纳成轮次,并不是内核每隔一个 RTT 才一次性调整 cwnd。延迟 ACK、接收窗口限制或应用没有足够数据,都可能让实际增长偏离这个轨迹。

初始 1 SMSS 是为了看清倍增的设定,不能答成“所有 TCP 都从一个包开始”。例如 RFC 6928 提出了更大的可选初始窗口,文档类别是 Experimental,具体上界还与 MSS 有关;实际初始值需核对目标实现与配置。RFC 6928 §2

两种丢包反馈,为什么回退幅度不同?

在经典 Reno 模型下,超时与三个重复 ACK 给出的反馈不同:超时缺少及时进展,回退更保守;重复 ACK 仍表明后续数据在抵达,快速恢复可以保留确认驱动的节奏。

为便于比较,另外取同一个决策前状态:SMSS=1000、cwnd=10,000、FlightSize=10,000,rwnd 不构成限制。以下是互相独立的两条分支,不是一次传输先后经历的步骤;只考虑一次缺口,省略 Limited Transmit,阈值取规范给出的上界值。

检测方式本例更新 ssthresh本例 cwnd 变化随后的增长
首次 RTO 检出该段丢失max(10,000 / 2, 2000) = 5000降到 1000,重传后从小窗口恢复慢启动,达到阈值后转拥塞避免
三个重复 ACK5000快速重传时暂设 5000 + 3 × 1000 = 8000;确认缺口及后续数据后降到 5000退出本例快速恢复,转拥塞避免

快速恢复中的临时增加用于反映已被后续重复 ACK 指示离开路径的数据,不表示发送端认定网络容量突然变大。“快速重传”决定及时补哪段,“快速恢复”管理补发期间和之后的发送额度。 这正是上一章只讲重传还没有回答的问题。RFC 5681 §3.1、§3.2

阈值计算使用 FlightSize,而非无条件用 cwnd。若 cwnd=10,000,但实际只有 6000 字节未确认,本例上界应为 max(6000 / 2, 2000) = 3000。把“窗口减半”背成所有状态下的精确公式,会在应用受限或接收窗口受限时算错。

多处丢失、SACK 恢复或其他拥塞算法有额外规则。CUBIC 就以距离上次拥塞事件的时间和三次函数塑造窗口增长,并非始终遵循 Reno 的每 RTT 加一段轨迹。RFC 9438 §3.1

30—60 秒参考回答

TCP 不能一直发,一方面要防止接收方来不及处理,另一方面要防止给路径注入过多数据。接收方通告 rwnd,发送方维护 cwnd,普通发送受两者较小值限制;算还能发多少,还要考虑已有未确认数据。ACK 前进与接收空间释放不一定同步,零窗口需要探测来发现重新打开的窗口。经典慢启动在理想条件下约每 RTT 翻倍,拥塞避免增长更缓;超时和重复 ACK 的恢复不同。回答具体增长和回退数值时,我会先说明采用哪种算法。

四个追问及解析

1. S 的 Window 字段很大,为什么 A 仍发得少?

先检查 A 的 cwnd 与未确认量,再看应用是否有待发数据、发送节奏和其他等待。rwnd 只是接收限制。TCP 头部没有一个可以直接读取的 cwnd 字段,不能仅凭 S 的一份 ACK 判断 A 的完整状态。

2. ACK 前进了,滑动窗口一定向右增加发送空间吗?

不一定。上面的 ACK=3001、rwnd=2000 与 ACK=1001、rwnd=4000 有相同右边界 5001;前两段已确认,但释放的发送位置被较小 rwnd 抵消。要同时检查 ACK、有效窗口更新和已发位置。

3. 三个重复 ACK 后,cwnd 就直接减半吗?

经典 Reno 中先更新阈值,快速重传时 cwnd 有临时增加;缺口被本例新 ACK 确认后再收敛到阈值。阈值还按 FlightSize 计算。本例 10 段未确认得到 5 段阈值、8 段临时窗口;省略算法与阶段就说“直接减半”不准确。

4. 想让长 RTT 下载更快,增大接收缓冲一定有效吗?

只有接收窗口构成主要限制时才可能有效。若路径拥塞、cwnd 较小、应用供数不足或磁盘处理慢,扩大接收缓冲未必改善吞吐。应观察窗口、RTT、丢失与应用读写,再定位限制;吞吐目标对应的带宽时延积可帮助估算所需在途数据量,但不是无限增大队列的理由。

两个易错辨析

“流量控制保护接收方,拥塞控制保护某一台路由器。” 前半句可作简述,后半句过窄。拥塞控制依据端到端反馈约束发送,使共享路径上的负载更可持续,并非只管理某一台路由器的缓存。

“rwnd=0 就是 TCP 已断开;窗口扩大协商可以救回来。” 零窗口表示当前接收额度为零,通常靠应用继续读取、窗口更新与探测恢复。扩大因子在握手中确定,不能在连接中途当作重新开窗的按钮。

到这里,我们知道 TCP 怎样确认字节、修补缺口并控制发送量。但即使所有字节按序到达,应用仍不知道“一条消息”在哪里结束。下一章用拆分与合并实验解释一次 send 为什么不对应一次 recv,以及应用怎样设计消息边界。

本章核验资料

核验日期:2026-10-08。上述窗口和轮次均为给定前提下的教学推导,不是目标操作系统的默认参数或真实测量。

  • RFC 9293:接收窗口与零窗口探测。
  • RFC 5681:经典拥塞控制的状态量、增长与恢复。
  • RFC 6675:SACK 恢复中的发送规则。
  • RFC 7323:Window Scale 的协商与方向。
  • RFC 6928:更大初始窗口的实验性方案。
  • RFC 3168:TCP 的显式拥塞反馈。
  • RFC 9438:CUBIC 增长模型。