土法炼钢 · 系统与基础设施

滑动窗口与流量控制:ARQ 效率、序号空间与 TCP/QUIC 的接收窗口

文章导航

分类入口
algorithmsnetwork
标签入口
#sliding-window#flow-control#arq#go-back-n#selective-repeat#tcp#sack#window-scaling#nagle#delayed-ack#quic#http2#linux

目录

“滑动窗口”这个词在网络里至少指三件事:可靠传输里允许在途的序号区间,接收方通告给发送方的缓冲额度,以及拥塞控制算出来的 cwnd。三者被混着讲时,常见的结论都只对一半:“窗口开到带宽时延积(BDP)就够了”,在有丢包时对选择重传并不成立;“SR 的序号空间要大于窗口”,实际需要的是窗口不超过序号空间的一半;“rwnd 小了是网络不好”,其实它只反映接收方的缓冲和读取速度。

本文只讨论前两件事:可靠传输的窗口(ARQ)和流量控制(flow control,保护接收方缓冲区)。拥塞控制(保护网络)见上一篇 TCP 拥塞控制:从 Jacobson 的 AIMD 到 CUBIC 与 BBR;按速率而不是按缓冲额度限制发送方的限流见下一篇 限流算法:令牌桶、漏桶与 GCRA 的等价性和工程实现。算法题里的”双指针滑动窗口”只是同名,本文不涉及。

顺序是:先用一个时隙模型推出停等、回退 N(Go-Back-N,GBN)和选择重传(Selective Repeat,SR)的效率,再用模拟器 reproduce/arq_sim.c 验证并找出公式没说的部分;然后证明序号空间的上界;最后按规范和 Linux v6.12 源码逐项核对 TCP、HTTP/2、QUIC 的接收窗口。

一、问题模型:窗口为什么要覆盖 BDP

时隙模型

把时间切成时隙,一个时隙恰好发送一帧。帧在第 \(t\) 个时隙发出,确认(ACK)在第 \(t+K\) 个时隙回到发送方。\(K\) 是以帧为单位的往返时间,也就是带宽时延积:若帧长 \(L\) 比特、链路速率 \(R\)、往返时间 \(\mathrm{RTT}\),则

\[ K = \frac{R \cdot \mathrm{RTT}}{L}. \]

教科书常用单程传播时延与发送时延之比 \(a\) 来写,此时 \(K = 1 + 2a\)。数据帧独立地以概率 \(p\) 丢失,ACK 不丢;发送方在发出后恰好 \(K\) 个时隙仍未收到 ACK 就判定丢失,相当于理想的否定确认。效率 \(U\) 定义为每个时隙成功交付的新帧数,上限是 1。

停等:一个 RTT 只送一帧

停等(stop-and-wait)每发一帧就等它的 ACK。每次尝试花 \(K\) 个时隙,成功概率 \(1-p\),期望尝试次数 \(1/(1-p)\),所以

\[ U_{\mathrm{SW}} = \frac{1-p}{K}. \]

\(K=64\) 时即使不丢包,效率也只有 \(1/64 \approx 1.6\%\)。这类协议的谱系可以追到 Bartlett、Scantlebury 和 Wilkinson 1969 年在 CACM 上发表的交替位协议(alternating bit protocol),它只用 1 位序号,本质上就是停等。

窗口:让管道里始终有 \(K\) 帧

允许最多 \(W\) 帧未确认时,无丢包的效率是

\[ U = \frac{\min(W, K)}{K}. \]

\(W \ge K\) 时发送方在第一个 ACK 回来之前恰好发完 \(K\) 帧,之后每收到一个 ACK 就能再发一帧,管道始终是满的。这就是”窗口要不小于 BDP”的来源。按字节写,就是 TCP 调优里常说的”缓冲区要不小于带宽乘以 RTT”:1 Gbit/s、RTT 20 ms 的路径,BDP 是 \(10^9 \times 0.02 / 8 = 2.5 \times 10^6\) 字节。

在 TCP/IP 的历史里,Cerf 与 Kahn 1974 年在 IEEE Transactions on Communications 上发表的 “A Protocol for Packet Network Intercommunication” 同时用窗口做了两件事:一是重复检测,发送方在收到确认前最多发 \(w\) 字节,并注明这一策略借鉴自法国 CYCLADES 和 ARPANET;二是流控,ACK 里带一个”建议窗口”(suggested window),接收方可以按任意算法调整它,只要不超过序号空间的一半。论文还把这种做法与”增量分配缓冲”(incremental buffer allocation)的方案作了对比,第六节会看到这两条路线在 HTTP/2 和 QUIC 里重新相遇。Stenning 1976 年在 Computer Networks 上的 “A Data Transfer Protocol” 给出一个使用循环序号的主机间传输协议,并对其性质做了形式化论证。Lin、Costello 和 Miller 1984 年在 IEEE Communications Magazine 上的综述把停等、GBN、SR 三类 ARQ 及其吞吐分析整理成了后来教科书的标准框架。

流控与拥塞控制:两个上限,取较小者

在 TCP 里,窗口有两个来源。RFC 5681 第 2 节写得很直接:任何时候,发送的序号不得超过”已确认的最高序号加上 \(\min(\mathit{cwnd}, \mathit{rwnd})\)“。

flowchart LR
    RB["receiver buffer free space"] -->|"window field in ACK"| RWND["rwnd"]
    NET["loss / delay / ECN"] -->|"congestion control"| CWND["cwnd"]
    RWND --> MIN["send limit = min(cwnd, rwnd)"]
    CWND --> MIN
    MIN --> SND["sender may transmit up to SND.UNA + limit"]

两者的失败症状不同:rwnd 太小时,瓶颈在接收端(缓冲区上限、应用读得慢),网络可能完全空闲;cwnd 太小时,瓶颈在路径上的丢包或排队。下文的 ARQ 模型把两者合成一个 \(W\),第四节之后只讨论 rwnd 一侧。

二、GBN 与 SR:丢包时窗口要多大

两种接收规则

两种协议的发送方都维护 \([\mathit{base}, \mathit{base}+W)\) 的窗口,区别在接收方:

GBN 的效率

设 \(W \ge K\)。一次丢失发生后,发送方要到 \(K\) 个时隙后才发现,这期间发出的 \(K-1\) 帧全被接收方丢弃,再加上丢失的那一帧本身,每次丢失浪费恰好 \(K\) 个时隙。每成功交付一帧,期望经历 \(p/(1-p)\) 次失败,所以每帧的期望时隙数是 \(1 + Kp/(1-p)\),取倒数:

\[ U_{\mathrm{GBN}} = \frac{1-p}{1 + (K-1)p}, \qquad W \ge K. \]

\(W\) 再大也没用:发出 \(K\) 个时隙后,一帧要么已确认、要么已判定丢失,未确认的帧数永远不会超过 \(K\)。分母里的 \((K-1)p\) 是关键:BDP 越大,同样的丢包率代价越高。\(K = 64\)、\(p = 1\%\) 时,\(U_{\mathrm{GBN}} \approx 0.607\)。

SR 的理想效率与被忽略的窗口阻塞

SR 只重发丢失的帧,教科书的理想模型是

\[ U_{\mathrm{SR}} = \frac{\min(W, K)(1-p)}{K}, \]

即 \(W \ge K\) 时效率为 \(1-p\)。这个模型假设窗口大小不会限制重传期间的新数据,而这一点并不成立。设第 \(s\) 帧在时隙 \(t_0\) 发出并丢失:

  1. 时隙 \(t_0 + K\) 发送方判定丢失并重发,重发的 ACK 在 \(t_0 + 2K\) 才回来;
  2. 在此之前,\(\mathit{base}\) 停在 \(s\),发送方最多只能发到 \(s + W - 1\);
  3. 若 \(W = K\),发送方在 \(t_0 + K\) 时已经发到 \(s + K - 1\),此后除了重发 \(s\) 只能空等,又损失约 \(K - 1\) 个时隙。

所以 \(W = K\) 时 SR 每次丢失的代价和 GBN 一样是约 \(K\) 个时隙,一阶近似下两者效率相同,SR 只在同一窗口内有多次丢失时占优(它可以在等待期间顺带重发别的丢失帧)。要让单次丢失不阻塞发送,窗口至少要覆盖”丢失、发现、重发、确认”这两个 RTT,即 \(W \ge 2K\);重发的帧再丢一次,就需要 \(3K\),依此类推。

这个 2 倍因子在 Linux 接收缓冲自动调优里有直接对应。tcp_rcv_space_adjust() 的注释写的是 “To cope with packet losses, we need a 2x factor”(第七节)。

模拟验证

模拟器 reproduce/arq_sim.c 实现了上面的时隙模型:每个时隙最多发一帧,帧在 \(t + K/2\) 到达接收方,ACK 在 \(t + K\) 回到发送方,超时恰好为 \(K\)。SR 优先发送重传队列中的帧,其次才是新帧。指标是交付帧数除以时隙数,与时钟无关。

交付效率随丢包率变化的对数横轴曲线:停等始终约 0.016;GBN 在 W 等于 K 时与模型 (1-p)/(1+(K-1)p) 重合,p 为 1% 时约 0.61;SR 在 W 等于 K 时明显低于理想值 1-p,W 为 2K 时在 p 不超过 2% 时接近 1-p,W 为 4K 时在全部丢包率上最接近 1-p
丢包率 \(p\) 停等 GBN \(W=K\) GBN 模型 SR \(W=K\) SR \(W=2K\) SR \(W=4K\) \(1-p\)
0.001 0.0156 0.941 0.940 0.944 0.999 0.999 0.999
0.01 0.0155 0.608 0.607 0.717 0.984 0.990 0.990
0.05 0.0148 0.231 0.229 0.531 0.860 0.950 0.950
0.1 0.0141 0.124 0.123 0.449 0.731 0.898 0.900

读表的几个要点:

  1. GBN 的模拟值与推导在每个丢包率上相差不到 0.003,说明模型和实现一致。GBN 在 \(W = 2K, 4K\) 时的结果与 \(W = K\) 完全相同,图中只画了一条。
  2. \(p = 1\%\) 时,SR 在 \(W = K\) 只有 0.717,离理想值 0.99 很远;窗口加到 \(2K\) 才到 0.984。\(p = 10\%\) 时连 \(2K\) 都不够,\(4K\) 才接近 \(1-p\)。
  3. 代价的另一面是重传量。\(p = 5\%\) 时 GBN 平均每交付一帧要发送 4.33 次,SR(\(W = 4K\))只要 1.053 次,接近下限 \(1/(1-p) \approx 1.053\)。

固定 \(p = 1\%\),改变窗口:

p 为 1% 时交付效率随 W/K 变化:W 小于 K 时 SR 与 GBN 都随窗口线性增长但低于无丢包模型;GBN 在 W 达到 K 后停在约 0.61;SR 从 W 等于 K 时的约 0.72 继续上升,在 W 等于 2K 时达到约 0.98 后趋平

\(W < K\) 的区间里两条曲线都低于虚线 \(\min(W,K)(1-p)/K\):每次丢失除了占用一个重发时隙,还会让窗口多卡住一个 RTT,理想模型没有计入这部分。\(W\) 超过 \(K\) 之后,GBN 立刻停在 0.61 左右,SR 则一直涨到 \(2K\) 附近才趋平(\(W = 96\) 时 0.854,\(W = 128\) 时 0.984)。

放到 TCP 上,这个结论的含义是:接收缓冲只按 BDP 配置,在无丢包时能跑满,一旦有丢包,乱序数据会占住接收窗口,吞吐会明显低于 \(1-p\)。这一段是本文模型下的推论;真实 TCP 还叠加了拥塞控制在丢包后收缩 cwnd 的效应,两者不能分开读。

三、序号空间:SR 为什么只能用一半

命题

设序号取值于 \(\{0, 1, \ldots, M-1\}\)(\(n\) 位序号时 \(M = 2^n\)),按模 \(M\) 回绕;信道保序但可能丢帧、丢 ACK。发送窗口为 \(W_s\),接收窗口为 \(W_r\)。接收方永远不会把旧帧误认为新帧,当且仅当

\[ W_s + W_r \le M. \]

代入两种协议:

证明

必要性。构造最坏情形:发送方发出 \(0, \ldots, W_s - 1\),全部到达,接收方把窗口推进到 \([W_s, W_s + W_r)\);但所有 ACK 都丢了。发送方超时后重发第 0 帧。这时接收方看到的序号是 \(0\),而它的窗口覆盖的真实帧号 \(W_s, \ldots, W_s + W_r - 1\) 模 \(M\) 之后,若 \(W_s + W_r > M\),其中必有一个等于 \(0\)(真实帧号 \(M\) 落在窗口里)。接收方于是把旧的第 0 帧当成新的第 \(M\) 帧收下,数据错位。

充分性。任一时刻,发送方可能发出(含重发)的帧都在 \([\mathit{base}_s, \mathit{base}_s + W_s)\) 内,而接收窗口左沿满足 \(\mathit{base}_s \le \mathit{base}_r \le \mathit{base}_s + W_s\):接收方不可能收到发送方没发过的帧,发送方也只有在收到确认后才推进 \(\mathit{base}_s\)。于是线路上可能出现的真实帧号都落在 \([\mathit{base}_r - W_s, \mathit{base}_r + W_s)\)。接收方把序号解释为窗口 \([\mathit{base}_r, \mathit{base}_r + W_r)\) 中的某个位置 \(j\);与 \(j\) 同余、会被误认成 \(j\) 的帧号只有 \(j \pm M\)。由 \(j < \mathit{base}_r + W_r\) 和 \(W_s + W_r \le M\) 得 \(j - M < \mathit{base}_r - W_s\);由 \(j \ge \mathit{base}_r\) 得 \(j + M \ge \mathit{base}_r + W_s + W_r > \mathit{base}_r + W_s - 1\)。两者都不可能出现在线路上,所以每个落入接收窗口的序号只有一种解释。\(\blacksquare\)

下图是 \(M = 4\) 的 SR 反例:

SR 在 2 位序号下的歧义:左图 W 为 3,发送方的第 0 到 2 帧全部到达但 ACK 全丢,接收窗口移到第 3 到 5 帧,对应序号 3、0、1,重发的序号 0 落入新窗口,被当成第 4 帧收下;右图 W 为 2,接收窗口移到序号 2、3,重发的序号 0 不在窗口内,被识别为重复帧并重新确认

左图 \(W = 3 > M/2\),新窗口覆盖序号 \(\{3, 0, 1\}\),重发的序号 0 被收下,交付的第 4 帧其实是旧的第 0 帧。右图 \(W = 2\),新窗口是 \(\{2, 3\}\),序号 0 落在”已交付的前一个窗口”里,接收方识别为重复并再次确认,发送方由此得知它已收到。

用模拟器验证边界

arq_sim seqcheck 让线路上只携带模 \(M\) 的序号,帧里另带真实帧号用于校验;数据帧丢失率 0.2、ACK 丢失率 0.3,\(K = 2M\),每组 \(2 \times 10^6\) 个时隙。结果(第一次错误交付的帧号,-1 表示从未出错):

序号位数 \(n\) \(M\) GBN \(W = M-1\) GBN \(W = M\) SR \(W = M/2\) SR \(W = M/2 + 1\)
2 4 -1 12 -1 6
3 8 -1 19 -1 12
4 16 -1 106 -1 17

在界内的配置跑满 \(2 \times 10^6\) 个时隙都没有错误交付;超出界一帧,很快就出错。

TCP 的版本:\(2^{30}\) 与”一半序号空间”

TCP 的序号按字节计、32 位,比较两个序号时用有符号差值。Linux v6.12 的 include/net/tcp.h:

/* Linux v6.12 include/net/tcp.h */
static inline bool before(__u32 seq1, __u32 seq2)
{
        return (__s32)(seq1-seq2) < 0;
}
#define after(seq2, seq1)   before(seq1, seq2)

这个比较只在两个序号相差小于 \(2^{31}\) 时有意义,相当于把有效序号空间折成一半。RFC 7323 第 2.3 节由此推出窗口上限:发送方与接收方窗口最多错开一个窗口,所以两倍最大窗口必须小于 \(2^{31}\),即最大窗口小于 \(2^{30}\),窗口缩放的移位数因此不得超过 14。这与上面的 \(W_s + W_r \le M\) 是同一个论证,只是把 \(M\) 换成了 \(2^{31}\)。Cerf 与 Kahn 1974 年的论文里也已经要求窗口”不超过序号空间的一半”。

32 位序号在高速链路上回绕得很快,按字节计 \(2^{32}\) 字节约 4.3 GB,10 Gbit/s 下不到 4 秒就绕一圈。RFC 7323 用时间戳选项上的 PAWS(Protect Against Wrapped Sequences)区分同一序号的新旧两代数据,细节不在本文范围。

四、TCP 的发送窗口与接收窗口

TCP 的现行规范是 RFC 9293(2022 年 8 月),它取代了 RFC 793 以及 RFC 879、2873、6093、6429、6528、6691。下面的变量名和规则都以它为准。

发送序号空间

RFC 9293 第 3.3.1 节把发送方的序号空间分成四段:

TCP 发送序号空间的四段:已确认、已发送未确认、可用、不允许;三个边界依次是 SND.UNA、SND.NXT 和 SND.UNA 加 SND.WND;SND.WND 从 SND.UNA 量起,覆盖第二段和第三段;在途数据量为 SND.NXT 减 SND.UNA,可用窗口 U 为 SND.UNA 加 SND.WND 减 SND.NXT

第 3.8.6.2.1 节定义的可用窗口(usable window)是

\[ U = \mathrm{SND.UNA} + \mathrm{SND.WND} - \mathrm{SND.NXT}, \]

即通告窗口减去在途数据。注意 RFC 的图 3 把第 3 段称为 send window,而变量 SND.WND 覆盖的是第 2、3 两段,读规范时容易混淆。接收方对应的是 RCV.NXT(下一个期望的字节)和 RCV.WND,可接受的序号区间是 \([\mathrm{RCV.NXT}, \mathrm{RCV.NXT} + \mathrm{RCV.WND})\)。

窗口的滑动、关闭与收缩

每个 ACK 同时带着确认号和窗口字段:确认号推进左沿,窗口字段重新决定右沿。下图用段(segment)为单位演示五个时刻:

发送窗口在五个时刻的状态,每行 14 个段:(a) 开始时 0 到 7 可用;(b) 发出 0 到 5 后在途 6 段、可用 2 段;(c) 收到 ACK 3、窗口 8,左右沿一起右移,右沿到 11;(d) 收到 ACK 5、窗口 6,左沿右移而右沿停在 11,窗口关闭;(e) 收到 ACK 6、窗口 2,右沿从 11 退回 8,窗口收缩

窗口信息只从”足够新”的段更新:RFC 9293 第 3.10.7.4 节要求 SND.WL1 < SEG.SEQ,或 SND.WL1 = SEG.SEQ 且 SND.WL2 =< SEG.ACK 时才用 SEG.WND 覆盖 SND.WND,防止乱序到达的旧 ACK 把窗口改回去。

零窗口与持续探测

应用完全不读时,接收方会通告窗口 0。之后如果它发出的”窗口重新打开”的 ACK 丢了,双方会互相等待。RFC 9293 第 3.8.6.1 节因此要求发送方做零窗口探测(Zero-Window Probing):窗口为 0 持续一个重传超时后发第一个探测(SHLD-29),之后间隔指数增长(SHLD-30);只要接收方持续回应探测,连接就 MUST 保持打开(MUST-37),哪怕窗口永远不开。

窗口缩放:16 位字段与 \(2^{30}\)

TCP 头部的窗口字段只有 16 位,最多 65,535 字节。按第一节的公式,RTT 20 ms 时它最多支撑 \(65535 \times 8 / 0.02 \approx 26\) Mbit/s。RFC 7323(2014,取代 RFC 1323)的窗口缩放选项(Window Scale)只在 SYN 上协商一个移位数 \(S\),之后的窗口字段按 \(2^S\) 放大;\(S\) 不得超过 14,理由就是上一节的 \(2^{30}\) 上限,超过 14 的值按 14 处理。握手时没有协商成功,这条连接的窗口就永远停在 64 KB 以内。

SACK:TCP 里的”选择重传”,但可以反悔

TCP 的 ACK 是累积确认,本身接近 GBN 的反馈方式。RFC 2018 的 SACK 选项让接收方报告已收到的不连续块,发送方据此只重发空洞,恢复算法见 RFC 6675。两个限制:

第二点让 TCP SACK 与第二节的 SR 不完全等价:SR 接收方收下的帧不会丢,而 TCP 发送方必须为可能的反悔保留整段缓冲。Fall 与 Floyd(CCR 1996)用模拟比较了 Tahoe、Reno、NewReno 与 SACK TCP,摘要里的结论是:没有 SACK 时,一个窗口内丢了多个包,TCP 只能在”每个 RTT 最多重发一个丢失包”和”重发可能已经送达的包”之间二选一。前者让恢复时间随丢包数按 RTT 线性增长,后者就是 GBN 的做法。

五、糊涂窗口综合征、Nagle 与延迟 ACK

糊涂窗口综合征

糊涂窗口综合征(Silly Window Syndrome,SWS)这个名字来自 Clark 1982 年的 RFC 813:接收方的应用每次只读几个字节,接收方就立刻通告几个字节的窗口,发送方也就只发几个字节,然后循环往复。RFC 9293 第 3.8.6.2 节的定义是”窗口以很小的增量移动所形成的稳定模式”。每段只带几个字节的数据却要付 40 字节的 TCP/IP 头部。Nagle 在 RFC 896 里描述过发送侧的同类问题(small-packet problem):键盘逐字符发送时,每个包是 1 字节数据加 40 字节头部,开销 4000%,在重载网络上还会引发拥塞与重传。

治理分两侧,RFC 9293 要求两侧都 MUST 实现(MUST-38、MUST-39)。

接收方:攒够再开窗

接收缓冲区分三段:RCV.USER 是已确认但应用尚未读取的数据,RCV.WND 是已通告给发送方的空间,reduction 是空闲但尚未通告的空间;数据到达时 RCV.NXT 右移、RCV.WND 缩小,右沿 RCV.NXT 加 RCV.WND 保持不动;应用读取使 RCV.USER 缩小、reduction 增大;只有 reduction 不小于 Fr 乘 RCV.BUFF 与 MSS 中较小者时才打开右沿

RFC 9293 第 3.8.6.2.2 节的算法是:保持右沿 \(\mathrm{RCV.NXT} + \mathrm{RCV.WND}\) 不动,直到尚未通告的空闲空间满足

\[ \mathrm{RCV.BUFF} - \mathrm{RCV.USER} - \mathrm{RCV.WND} \ge \min\left(F_r \cdot \mathrm{RCV.BUFF},\ \mathrm{Eff.snd.MSS}\right), \qquad F_r = \tfrac{1}{2}, \]

然后一次把窗口开到 \(\mathrm{RCV.BUFF} - \mathrm{RCV.USER}\)。对常见的缓冲区大小,效果是右沿每次至少前进一个 MSS。

Linux 的实现不是逐字照搬。__tcp_select_window() 的注释引用了 RFC 1122 的同一条规则,然后说明它与首部预测(header prediction)冲突,改用 BSD 式的折中。核心分支如下(Linux v6.12 net/ipv4/tcp_output.c,删去了 MPTCP、内存压力与 tcp_shrink_window 分支):

/* Linux v6.12 net/ipv4/tcp_output.c, __tcp_select_window(),有删减 */
    int mss = icsk->icsk_ack.rcv_mss;
    int free_space = tcp_space(sk);
    int allowed_space = tcp_full_space(sk);
    ...
    full_space = min_t(int, tp->window_clamp, allowed_space);
    ...
    if (free_space < (full_space >> 1)) {
        ...
        free_space = round_down(free_space, 1 << tp->rx_opt.rcv_wscale);
        if (free_space < (allowed_space >> 4) || free_space < mss)
            return 0;
    }

空闲空间不到一半、并且小于一个 MSS 或小于总空间的 1/16 时,直接通告 0,而不是一个很小的正数。源码注释说明了 1/16 的来历:窗口很大时,只看 MSS 会触发得太晚,来不及在内存上限到达之前通告零窗口。没有窗口缩放时,窗口还会被取整到 MSS 的整数倍。

发送方:Nagle 与 SWS 规则

发送侧有两条互补的规则(RFC 9293 第 3.8.6.2 节原话:Nagle 管”待发数据以小增量增长”,SWS 规则管”右沿以小增量前进”)。

Linux 实现的是 Nagle 的 Minshall 变体:不是”有任何未确认数据就等”,而是”有未确认的小段才等”。Linux v6.12 net/ipv4/tcp_output.c:

/* Linux v6.12 net/ipv4/tcp_output.c */
/* Minshall's variant of the Nagle send check. */
static bool tcp_minshall_check(const struct tcp_sock *tp)
{
    return after(tp->snd_sml, tp->snd_una) &&
        !after(tp->snd_sml, tp->snd_nxt);
}

static bool tcp_nagle_check(bool partial, const struct tcp_sock *tp,
                int nonagle)
{
    return partial &&
        ((nonagle & TCP_NAGLE_CORK) ||
         (!nonagle && tp->packets_out && tcp_minshall_check(tp)));
}

snd_sml 记录最近一个不满 MSS 的段的末尾序号;它还没被确认,新的小段就要等。tcp_nagle_check() 返回 true 表示”现在不能发”。

延迟 ACK 与 Nagle 的相互等待

接收方为了少发 ACK,会延迟确认,希望把 ACK 捎带在应答数据上,或者攒两个段一起确认。RFC 9293 第 3.8.6.3 节规定延迟 MUST 小于 0.5 秒(MUST-40),并且 SHOULD 至少每两个满长段确认一次(SHLD-19)。Linux v6.12 的 include/net/tcp.h 把最小延迟定为 TCP_DELACK_MIN = HZ/25(40 ms),最大为 TCP_DELACK_MAX = HZ/5(200 ms)。

问题出在”写—写—读”模式:应用把一个请求分两次 write()(例如先写头部再写正文),然后等响应。

sequenceDiagram
    participant C as client (Nagle on)
    participant S as server
    C->>S: 1st write, header 40 B (sent at once, nothing in flight)
    Note over C: 2nd write, body 60 B<br/>held by Nagle, header still unacked
    Note over S: has only the header, no reply yet<br/>delayed-ACK timer running
    S-->>C: ACK after delayed-ACK timeout
    C->>S: body, 60 B
    S->>C: reply

第一个小段立即发出;第二个小段被 Nagle 扣住,要等第一个段的 ACK;服务器还没收到完整请求,没有数据可以捎带 ACK,于是等延迟 ACK 定时器超时。每个请求都凭空多出一个定时器周期。

reproduce/nagle_delack.py 在本机回环接口上复现了这个现象:客户端每个请求写 40 字节再写 60 字节,服务器收齐 100 字节后回 1 字节;每种模式 60 次请求,去掉前 10 次(连接刚建立时 Linux 处于快速确认模式),绑在 CPU 6 上连续跑 3 轮。环境为 WSL2 内核 6.6.87.2(CONFIG_HZ=250),Python 3.14.5。

模式 第 1 轮中位数 第 2 轮中位数 第 3 轮中位数
默认(Nagle 开) 44.007 ms 43.994 ms 43.993 ms
TCP_NODELAY 0.133 ms 0.147 ms 0.150 ms
两次写合并成一次 0.130 ms 0.111 ms 0.109 ms

三轮中位数都在 44 ms,与 40 ms 的最小延迟 ACK 同一量级,多出的约 4 ms 本文没有拆分来源。这是计时类结果,机器上同时有其他负载,只看量级:两种修复都把每个请求的时间从几十毫秒降到约 0.1 ms。第三行说明,关掉 Nagle 不是唯一的办法,让应用把一个逻辑消息一次写出(或用 writev()、TCP_CORK)同样有效,而且不会增加小包数量。

争论:Nagle 该不该默认开启

于是现状是:标准仍然 SHOULD 开启 Nagle,而大量延迟敏感的应用在第一行代码里就关掉它。另一种思路是保留 Nagle、改掉”写—写—读”的应用模式,上表第三行就是这种做法。

六、应用层流控:HTTP/2 与 QUIC 的信用

为什么 TCP 之上还要一层

TCP 的 rwnd 是整条连接共用的。HTTP/2 在一条 TCP 连接上复用多个流,如果某个流的接收方处理不过来,用 TCP 窗口去挡,会连同其他流一起挡住。RFC 9113 第 5.2.2 节举的例子是代理:它在很多连接之间共享内存,上游慢、下游快,需要单独限制某一个流而继续处理同一连接上的其他流。所以 HTTP/2 和 QUIC 都有流级和连接级两层流控。

这两层都是信用制(credit-based):接收方预先告诉发送方”你还可以发多少”,发送方用完就停。Kung 与 Morris(IEEE Network 1995)总结过 ATM 网络中按虚电路发放信用的流控方案;Cerf 与 Kahn 1974 年对比过的”增量分配缓冲”也属于这一类。

HTTP/2:增量式的 WINDOW_UPDATE

RFC 9113 的规则(第 5.2.1 节与第 6.9 节):

增量语义之所以可行,是因为 HTTP/2 跑在 TCP 上,WINDOW_UPDATE 帧不会丢、不会乱序、不会重复。第 5.2.1 节也明确规定协议只定义帧格式和语义,不规定接收方何时发 WINDOW_UPDATE、每次给多少。不需要这层保护的部署可以直接把窗口设为 \(2^{31}-1\),收到数据就补回去,相当于关掉 HTTP/2 流控(第 5.2.2 节)。

QUIC:绝对偏移的 MAX_DATA 与 MAX_STREAM_DATA

QUIC 自己负责可靠传输,控制帧可能丢失、重复或乱序。RFC 9000 第 4.1 节因此改用绝对偏移:

QUIC 两级信用示意:流 4 已发 0 到 40 KB,MAX_STREAM_DATA 为 64 KB,剩余 24 KB;流 8 已发 0 到 30 KB,剩余 34 KB;连接级按两流偏移之和计为 70 KB,MAX_DATA 为 96 KB,只剩 26 KB,所以尽管两个流合计还有 58 KB 流级额度,发送方总共只能再发 26 KB

“取最大值”这个规则让丢失、重复、乱序的更新帧都无害:晚到的旧值比当前上限小,直接忽略;丢了的更新会被下一次更大的值覆盖。这与第四节 TCP 用 SND.WL1/SND.WL2 过滤旧窗口是同一个问题的两种解法。

其余几条规则:

常见的两种误读

七、Linux 的接收缓冲自动调优(v6.12)

第二节说明窗口要跟着 BDP 走,但 BDP 事先不知道,而且每条连接都不同。给每个套接字都配上最坏情况的缓冲区,内存扛不住;配小了,长肥管道跑不满。Semke、Mahdavi 与 Mathis 在 SIGCOMM 1998 的 “Automatic TCP buffer tuning” 里提出按连接动态调整缓冲区;Weigle 与 Feng 在 ICCCN 2001 的 “Dynamic right-sizing: a simulation study” 研究了在接收端按观测到的吞吐动态确定窗口的做法。Linux 的实现在接收端按”每个 RTT 应用读走了多少字节”来估计需求,思路上与后者一致;这是本文的比较,不是内核文档的表述。

相关参数

以下默认值取自 Linux v6.12 的 Documentation/networking/ip-sysctl.rst,“本机”一列是实验机(WSL2 内核 6.6.87.2)上 sysctl 的实际输出。

参数 含义 文档默认值 本机
net.ipv4.tcp_rmem 接收缓冲的 min / default / max(字节) 4K / 131072 / 131072 到 6 MB 之间(随内存) 4096 / 131072 / 6291456
net.ipv4.tcp_moderate_rcvbuf 是否启用接收缓冲自动调优,上限为 tcp_rmem[2] 1 1
net.core.rmem_max setsockopt(SO_RCVBUF) 能设置的上限 不在该文件中 212992
net.ipv4.tcp_adv_win_scale 旧的缓冲开销系数 1,标注 “Obsolete since linux-6.6” 未使用

几条容易弄错的关系,都能在源码里找到:

tcp_rcv_space_adjust():每个 RTT 估计一次

应用每次把数据拷到用户态时都会调用这个函数。删减后的主体(Linux v6.12 net/ipv4/tcp_input.c,省略了跟踪点与时间戳刷新):

/* Linux v6.12 net/ipv4/tcp_input.c, tcp_rcv_space_adjust(),有删减 */
    time = tcp_stamp_us_delta(tp->tcp_mstamp, tp->rcvq_space.time);
    if (time < (tp->rcv_rtt_est.rtt_us >> 3) || tp->rcv_rtt_est.rtt_us == 0)
        return;

    /* Number of bytes copied to user in last RTT */
    copied = tp->copied_seq - tp->rcvq_space.seq;
    if (copied <= tp->rcvq_space.space)
        goto new_measure;

    if (READ_ONCE(sock_net(sk)->ipv4.sysctl_tcp_moderate_rcvbuf) &&
        !(sk->sk_userlocks & SOCK_RCVBUF_LOCK)) {
        u64 rcvwin, grow;
        int rcvbuf;

        rcvwin = ((u64)copied << 1) + 16 * tp->advmss;

        grow = rcvwin * (copied - tp->rcvq_space.space);
        do_div(grow, tp->rcvq_space.space);
        rcvwin += (grow << 1);

        rcvbuf = min_t(u64, tcp_space_from_win(sk, rcvwin),
                   READ_ONCE(sock_net(sk)->ipv4.sysctl_tcp_rmem[2]));
        if (rcvbuf > sk->sk_rcvbuf) {
            WRITE_ONCE(sk->sk_rcvbuf, rcvbuf);
            WRITE_ONCE(tp->window_clamp,
                   tcp_win_from_space(sk, rcvbuf));
        }
    }
    tp->rcvq_space.space = copied;

逐项对应:

  1. 测量:rcv_rtt_est.rtt_us 是接收端估计的 RTT(以 8 倍定点存放,所以右移 3 位)。距上次测量不足一个 RTT 就返回。copied 是这一个 RTT 内应用读走的字节数,也就是接收端实际观测到的”每 RTT 吞吐”,相当于按字节计的 \(K\)。
  2. 2 倍因子:rcvwin = 2 * copied + 16 * advmss。源码注释的解释是 “To cope with packet losses, we need a 2x factor”,外加 16 个 MSS 的余量。这正是第二节模拟里 SR 需要 \(W \approx 2K\) 的原因:丢包后乱序数据要在接收缓冲里多待一个 RTT。
  3. 增长补偿:如果这个 RTT 比上个 RTT 读得多(发送方还在慢启动),就按增长比例再放大,注释里说慢启动时需要 4 倍。
  4. 只往大调:新值大于当前 sk_rcvbuf 才更新,上限是 tcp_rmem[2];window_clamp 随之调整,决定了 __tcp_select_window() 最多能通告多大。

由此可见,自动调优只在应用确实读得快时才放大窗口;应用读得慢,copied 就小,窗口不会变大,这符合流控”保护接收方”的本意。这个函数本身不会把缓冲调小;系统处于 TCP 内存压力下时,__tcp_select_window() 会调用 tcp_adjust_rcv_ssthresh() 压低可通告的窗口。

八、模型的边界、争论与开放问题

第二节模型没有覆盖的东西

第二节的推导和模拟依赖四个假设,每一个在真实 TCP 上都不成立:

所以表中的数字只说明机制差异(窗口阻塞、GBN 的整窗重发),不能拿来预测某条 TCP 连接的吞吐。

争论:SACK 应不应该允许反悔

RFC 2018 把 SACK 定为建议性的,接收方可以丢弃已经 SACK 的数据,代价是发送方必须为此保留整段缓冲,并在超时后忽略 SACK 信息。Ekiz、Rahman 与 Amer(CCR 2011)在分析 CAIDA 流量、寻找反悔实例时发现,起初看似频繁的反悔,细查之下其实是 SACK 生成的实现错误(论文归纳了七类,并用 TBIT 在 29 个 TCP 协议栈上逐一测试);他们认为反悔在实践中很少甚至从未发生,主张把 SACK 改成”永久性”的,即接收方 MUST NOT 反悔。另一方是 RFC 2018 第 8 节本身的立场:反悔”不被鼓励,但在接收方缓冲耗尽时可以使用”,把它保留为接收方的资源保护手段。TCP 的规范至今没有改变这一点。

开放问题

九、工程检查表

现象 先查什么 依据
长肥管道上吞吐远低于带宽,网络无丢包 接收端通告窗口是否小于 BDP;握手时是否协商了窗口缩放;应用是否设置了 SO_RCVBUF 从而关闭了自动调优、并被 rmem_max 截断 第一、四、七节
有少量丢包时吞吐掉得很厉害 SACK 是否启用(net.ipv4.tcp_sack);接收缓冲是否只按 BDP 配置 第二节模拟:\(p = 1\%\) 时 SR 在 \(W = K\) 只有 0.717
请求—响应延迟固定多出约 40 ms 是否”写—写—读”;Nagle 是否开启;对端延迟 ACK 第五节实测约 44 ms
接收方持续通告窗口 0 应用是否在读;Recv-Q 是否堆积 第四节,RFC 9293 第 3.8.6.1 节
gRPC/HTTP/2 单流吞吐上不去,TCP 层窗口很大 HTTP/2 流级与连接级窗口是否远小于 BDP 第六节,RFC 9113 第 5.2.3 节
QUIC 连接在大文件下载时周期性停顿 是否在等 DATA_BLOCKED 才发额度;initial_max_data 是否过小 第六节,RFC 9000 第 4.2 节
自定义可靠 UDP 协议偶发数据错位 序号位数与窗口:GBN 需 \(W \le 2^n - 1\),SR 需 \(W \le 2^{n-1}\) 第三节

排查 TCP 连接时,ss -tmi 能同时看到 cwnd、rcv_space、wscale 和套接字内存;站内 TCP 流量控制:滑动窗口的工程细节 与 TCP 调优实战:内核参数与 socket 选项完全指南 有更多命令层面的操作。

十、参考资料

规范与文档

源码(Linux v6.12)

核心论文

其他论文


系列导航: - 上一篇:TCP 拥塞控制:从 Jacobson 的 AIMD 到 CUBIC 与 BBR - 下一篇:限流算法:令牌桶、漏桶与 GCRA 的等价性和工程实现

相关阅读: - 【网络工程】TCP 可靠传输:序列号、确认与重传机制 - 【网络工程】TCP 流量控制:滑动窗口的工程细节 - 【网络工程】HTTP/2 完整解剖:流、帧、HPACK 与 Server Push - 【网络工程】可靠 UDP 框架:KCP、ENet 与 QUIC 的设计对比

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2025-07-22 · network

【网络工程】TCP 流量控制:滑动窗口的工程细节

深入剖析 TCP 滑动窗口的工程实现——发送窗口、接收窗口与拥塞窗口的三角关系,窗口缩放的必要性,零窗口与 Silly Window Syndrome 的防治,以及 Wireshark 中的窗口分析方法与缓冲区调优实战。

2026-05-06 · algorithms / network

路由算法:距离向量、链路状态与路径向量的收敛与稳定性

在同一 8 节点拓扑上模拟距离向量、链路状态、路径向量在链路故障和目的地失效后的收敛轮数与消息数,对照 RFC 2328、RFC 4271 与 Labovitz、Griffin 等论文,解释 LSA 泛洪、MRAI、路由震荡抑制、策略不收敛和快速重路由各自解决什么、代价是什么。

2026-05-07 · algorithms / network

主动队列管理:RED → CoDel → FQ-CoDel

瓶颈队列何时丢包、丢谁的包:对照 RFC 与 Linux v6.12 源码核对 RED、CoDel、FQ-CoDel、PIE 的规则与默认值,用包级离散事件模拟比较四种队列的延迟、吞吐与短流完成时间,并梳理 FQ 与 L4S DualQ 之争。

2026-06-04 · algorithms / network

Bellman-Ford 与路由协议:负环、SPFA 与距离向量收敛

从 Bellman-Ford 的松弛不变式和负环提取出发,用可复现 C 程序与距离向量模拟器解释 RIP 的 count-to-infinity、split horizon 的边界,以及 Babel、EIGRP、BGP、OSPF 对环路问题的不同取舍。


By .