接收程序一直读取,发送程序也一直有数据,为什么一条 TCP 连接仍不能任意提高发送速度?另一条连接加入后,第一条连接的吞吐为什么会变化?第 18 篇的接收窗口只能解释接收端限制,不能单独解释网络中的共享瓶颈。

本篇先把窗口与速率分开,再用固定限速出口比较一条和两条连接。前置是第 01 篇的 RTT 与带宽时延积、第 17 篇的重传依据,以及第 18 篇的流控。队列管理与 ECN 的对照实验留到第 20 篇。

两个窗口约束同一发送方向

接收端通告 rwnd,发送端维护 cwnd。前者表达接收限制,后者用于约束向网络注入的数据。RFC 5681 §2–3.1区分这两个量;在讨论普通新数据发送、暂不展开恢复期间额外规则时,需要同时考虑二者。

设当前 cwnd=12000 Brwnd=8000 B,已经发出但尚未确认的数据为 6000 B。忽略回绕、SACK 恢复、分段和发送节奏等细节,可用于新增数据的余量为 max(0, min(12000, 8000)-6000)=2000 B。这是一道窗口记账题,不是完整内核发送算法。

窗口为八千字节,不意味着每秒八千字节。同样的窗口经过不同 RTT 才得到确认,单位时间推进的字节数会不同;应用供数、分段、排队、重传与调度也可能限制进度。第 01 篇的带宽时延积解释了为什么高容量路径还需要足够的在途数据,但足够的窗口只是条件之一。

这也给出一个反例:当应用每秒只提供少量数据时,窗口很大仍可能得到很低的吞吐。不能从“吞吐低”直接推出 cwnd 太小,更不能只增大接收缓冲便宣称完成优化。

增长不是定时器每秒加一个固定数

慢启动根据新确认的数据推进窗口,拥塞避免采用更保守的增长。RFC 5681 的基本规则在 cwnd 小于 ssthresh 时使用慢启动,大于时使用拥塞避免;相等时允许任选其一。慢启动的“每 RTT 近似倍增”需要持续供数、足够的确认和相应增长规则,不是每条连接无条件呈现的曲线。

以“每个新确认的满段使窗口增加一段”的教学假设为例,开始窗口为四段,四段全部被逐段确认后,下一轮窗口可到八段。若应用只发出一段,或者确认方式、实现增长规则不同,就不能从四段自动跳到八段。这里的四段是题设,不是当前所有系统的初始窗口默认值。

拥塞避免也不意味着不再发生拥塞。发送方仍在试探可用容量,增长与减小形成反馈过程。RFC 5681 描述每 RTT 约增加一个满段的基本原则;不同算法不必采用同一条增长函数,不能把 Reno 的图形当作 TCP 的统一性能画像。

Linux v6.18 的 tcp_cong.c提供一个实现参照:tcp_reno_cong_avoid() 先检查是否受拥塞窗口限制,再调用慢启动或加性增长逻辑;tcp_cong_avoid_ai() 累积已确认段的计数。它不是一个不管是否发送数据都周期递增窗口的后台计数器。

这个上游源码版本用于解释实现结构,不能替代实验机器的运行时证据。一次采样看到窗口变小,也不足以唯一判定经历了哪条恢复分支;第 17 篇关于丢失检测与重传归因的边界仍然成立。

公平首先需要说明比较什么

两条连接经过同一个出口,意味着它们竞争同一资源,不意味着任一短时间片都恰好各占一半。比较需要固定算法、路径、负载、起跑条件和统计区间,再保留多次运行的差异。

本篇采用接收应用在共同区间内实际取得的字节数计算 goodput。这个计数不把重传的重复线上字节再次算作应用收益,也不把发送调用接受的字节当成对端已经读取的字节。分母使用明确的共同区间长度,单位写为十进制 Mbit/s。

Jain、Chiu、Hawe 的原始研究给出分配公平性的量化指标。设各流同一区间的非负 goodput 为 x_i,且并非全部为零:

1
J = (sum(x_i))² / (n × sum(x_i²))

两流同为 5 时,J=1;分别为 9、1 时,J=25/41,约 0.610。若两流同为 0.1,J 仍为 1,所以公平指数高不等于总吞吐高。全零时公式分母为零,应标成未定义,不能补成“完全公平”。这些数字是手算例子,不是下面实验的实测结果。

1
2
3
4
from fractions import Fraction
assert Fraction((5 + 5) ** 2, 2 * (5 * 5 + 5 * 5)) == 1
assert Fraction((9 + 1) ** 2, 2 * (9 * 9 + 1 * 1)) == Fraction(25, 41)
assert max(0, min(12000, 8000) - 6000) == 2000

测量前固定瓶颈与计数口径

实验中的限速器采用 Linux TBF。tc-tbf(8)区分 rateburstlimit:分别控制令牌补充速率、令牌桶容量和等待发送的队列容量。没有另挂内部 qdisc 时,内部队列按 bfifo 工作。本篇固定队列策略,后续才比较队列管理。

burst 允许短时间突发,所以配置 5 Mbit/s 不等于每个毫秒都精确输出相同比特数。配置命令成功也不等于瓶颈已被实验负载经过;需要保存接口位置、路由以及 tc -s -d 的前后统计。

Linux tcp(7)提供逐 socket 的 TCP_CONGESTION 选项。实验先确认可用算法,再显式选择 Reno 并读回,避免依赖机器默认配置。只运行 Reno 不能证明 CUBIC 或其他算法有相同分配结果。

ss(8)的内部 TCP 信息可以补充窗口与 RTT,但不能与应用计数混为一谈。RTT 显示单位为毫秒,cwnd 的段数需结合 MSS 才能估计字节窗口。原始采样还应保留时间戳:每隔约 200 ms 观察一次,不等于完整记录这段时间里的所有状态转移。

实验:一条与两条 Reno 连接共享出口

实验程序 lab.py 在专用 Linux 虚拟机里创建 A、R、B 三个命名空间。A 为发送端,B 为接收端,只有自建 R 启用 IPv4 转发。两条连接经过同一个 R/right 出口;没有给两条流分别设置独立的限速器。

1
2
A 10.19.1.2 ── R 10.19.1.1 / 10.19.2.1 ── B 10.19.2.2
right:TBF 5mbit,burst 16kb,limit 64kb

在具备 iptcss 和命名空间权限的专用 Linux 虚拟机中,下载脚本后运行:

1
2
python3 lab.py --help
sudo python3 lab.py > run.jsonl

脚本只依赖 Python 标准库及已安装工具。非 Linux 或缺少所需权限时会拒绝运行;不能用静态检查或帮助命令替代真实实验。每轮使用新的随机命名空间和连接,单流、双流交替各运行三次,结束只清理自己创建的进程和网络资源。

每轮先接受全部连接,再由控制管道启动接收端与发送端。起跑经过协调,但不声称不同进程在同一纳秒开始发送。接收程序连续读取 15 秒,前 3 秒预热;两流都使用同一单调时钟区间 [起点+3秒, 起点+15秒) 计数。每个读取块按 recv() 返回后的时间整体归入或排除,不把它解释成精确线路到达时刻。

发送程序不断提供固定内容,接收端对实际返回的每个块检查内容,随后计数,不在内存里积累全部流。测量结束时接收端关闭,发送端可能还有尚未交付的缓冲数据;因此这是有限时间内的 goodput 测量,不是完整文件传输验收。源代码保留了发送端本地接受字节数,但计算 goodput 不使用它。

六轮实际结果

2026-09-20 在 Linux 6.18.10-200.fc43.aarch64、Python 3.14.3、iproute2 6.14.0 上运行。可用及允许算法均读回 reno cubic,各发送 socket 选择后均读回 reno。完整数据见 原始运行记录,环境与计数边界见 运行说明

轮次 流数 每流 goodput(Mbit/s) Jain 指数 TBF dropped
1 1 4.780331 1 33
1 2 1.849579、2.917237 0.952231 59
2 1 4.774539 1 33
2 2 2.683627、2.086085 0.984548 60
3 1 4.772608 1 31
3 2 2.745408、2.025269 0.977721 49

第一轮双流实际计数字节分别为 2774368、4375856。各自乘八、除以共同的 12 秒,再除以一百万,即得表中的十进制 Mbit/s。单流 Jain=1 是公式在 n=1 时的结果,不构成两方公平性证据。

三轮双流总 goodput 均约为 4.77 Mbit/s,但分配并不完全相等,且较快的一方会变化。单流也约为 4.77–4.78 Mbit/s。本次增加连接数量没有把应用总收益翻倍,只改变了同一个瓶颈下的分配。这是本次短时观测,不能推出所有环境下增加并发都无益。

TBF 统计范围覆盖预热和测量结束附近的流量,goodput 则只计后 12 秒,两者不是同一时间窗口。dropped 是 qdisc 丢弃计数;overlimits 是整形限制相关计数,不能当成丢包数。结束快照仍有队列积压,例如第一轮单流为 47000 字节、15 包,不能把最后一张快照描述成队列已经排空。

限速器计数与应用载荷计数还包含不同开销。不能仅以 5 减去表中 goodput,便把差值全部叫作重传损耗。首部、重传和测量边界都需要分别考虑。

窗口快照支持什么

每轮保存 75 份原始 ss -tinm,带采样开始与结束时刻。统计区间内共有 540 个按流展开的条目,均包含 snd_wnd;这些样本中,对端窗口都大于 cwnd × MSS。例如一个样本 cwnd=40MSS=1448 B,对应 57920 B,而 snd_wnd=500736 B

这里使用 snd_wnd 观察对端给本发送方向的窗口,不能用反方向的 rcv_wnd 代替。样本支持“在所记录时刻,接收窗口大于拥塞窗口”,不支持“整个传输从未受接收端或发送应用限制”。两次采样之间的短暂状态仍然可能缺失。

单流统计区间中的 cwnd 样本范围为 22 至 44 段;双流三轮分别为 3 至 40、4 至 39、4 至 39 段。窗口没有在全部采样点上保持同一个数。原始 RTT 样本也随运行变化,但本篇没有无负载对照,不能把某个 RTT 直接全部归因于某个队列的等待时间。

本篇没有用这些离散样本拼出一条声称完整的慢启动或锯齿曲线,也没有将每次窗口下降唯一归因为某一种丢失恢复。观察全部阶段需要更细的事件证据;算法机制的解释与本次实测的覆盖范围必须分开。

验证边界

六轮实际运行均完成,接收内容检查通过,18 个自建命名空间已清理,虚拟机已停止。本次没有主动伪造丢包事件,但固定队列在负载下确实记录了丢弃。测量终点主动关闭接收端使发送端记录 BrokenPipeError,这是脚本结束方式的一部分,不是已证明的瓶颈故障。

接收块统一检查为预定字符内容,可以核对计入的字节内容;它没有应用记录序号,也没有证明统计终点之前发送端接受的全部字节最终交付。用户态时间戳、分块归属、调度、veth 卸载及短运行时间都限制了外推。

本次未比较其他拥塞算法、不同 RTT 或不同队列管理策略,未捕获握手以验收 ECN 协商,也未证明长期公平性或公网性能。下一篇将在固定负载下改变排队策略,并补充负载下时延的对照。

练习

第一题:cwnd=12000 Brwnd=8000 B,尚未确认的数据增至 9000 B,按本篇简化记账还能增加多少新数据?

校验:余量截断为零。不能把负数解释成应该发送负字节;真实实现如何处理已经在途的数据,还取决于恢复和窗口更新等状态。

第二题:两组分配分别为 (5,5)(0.1,0.1),公平指数是否能区分二者的总吞吐?

校验:不能,两者都为 1。必须同时报告每流、总 goodput 和统计区间。

第三题:某次 ss 显示较大的 cwnd,接收应用吞吐仍低,能否认定网络没有瓶颈?

校验:不能。单点窗口不是完整发送进度,仍需检查接收限制、应用供数、RTT、重传、队列和实际字节计数。

参考资料