计算机网络 18:接收方读得慢会怎样,接收窗口、背压、零窗口与缓冲区
服务端暂停读取 socket,客户端为什么还能发送一段时间,随后才停下来?恢复读取后,传输又为什么能够继续?应用调用、两端内存缓冲与 TCP 通告窗口之间存在多个不同的进度,不能用“对端不读,所以本次写立即阻塞”概括。
第 15 篇解释了字节序号和确认号。本篇把窗口放回同一序号空间,沿着“停读、缓冲积累、零窗口、恢复读取”的路径观察背压。窗口是接收限制,不是链路带宽;拥塞窗口与网络瓶颈的关系留给第 19 篇。
ACK 推进与应用读取是两回事
TCP 可以先把接收字节放入内核缓冲并确认,应用稍后才读取。ACK 前进说明传输层连续接收进度,并不代表应用已经处理这些字节。应用暂停 recv() 后,只要接收端仍有可以接纳数据的空间,传输不必立刻停止。
RFC 9293 §3.8.6把通告窗口定义为接收端准备接收的序号范围。接收端在报文中给出的窗口值,应与同一报文的确认号一起解释;只比较两次窗口字段大小,容易把窗口宽度变化误认为右边界后退。
设一个无回绕算例中,接收端原先通告 ACK=1001、窗口=4096,右边界为 5097。随后接收 2048 字节,应用仍未消费,通告 ACK=3049、窗口=2048,右边界仍为 5097。这是已经允许的空间被数据占用,不是原先承诺的右边界向左移动。
1 | |
可用 Python 标准整数核对边界与缩放,下面的数值均为题设:
1 | |
以上是说明边界的手算。真实实现还涉及内存记账、窗口更新策略与粒度,不能要求每读取一字节,抓包里的窗口就精确增加一字节。
窗口字段需要握手中的比例
TCP 首部窗口字段为 16 位。双方协商 Window Scale 后,后续窗口字段还需要按该方向的移位数还原。RFC 7323 §2.2–2.3规定双方都在 SYN 段交换选项才启用缩放,各方向的比例独立,并在连接期间固定。
假设 B 在握手中给出移位数 3,后续 B→A 普通 ACK 的原始窗口字段为 1000,则 B 通告的窗口为 1000 << 3 = 8000 字节。这个窗口约束 A→B 数据,不能错用 A 在握手里通告的比例。B 的 SYN-ACK 自身窗口字段不缩放;即使它携带 Window Scale,也不能立即把这张报文的窗口乘以八。
若只截取传输中间一段、缺少握手,就可能无法从原始窗口字段确定完整窗口。零仍然是一个特殊且明确的值:零左移任何合法位数仍是零。要完整解释恢复后的非零窗口,仍应保留协商证据。
背压先经过缓冲,再到达发送调用
应用调用 sendall() 时,数据经本地 socket 进入发送路径。即使对端暂时通告零窗口,本地发送缓冲也可能继续接纳应用写入。因此“零窗口”和“调用尚未返回”不是同一时刻必然出现的两个同义描述。
Linux send(2)说明发送缓冲不足时阻塞调用可能等待;发送成功本身不保证最终交付。Python sendall 文档说明它持续发送直到全部完成或发生错误,异常时无法由该接口得知究竟发送了多少。应用必须把调用返回、对端收到和对端处理完成分开验收。
当接收应用持续不消费,接收限制可能进一步阻止发送端排出数据,本地可用发送缓冲随之耗尽,背压才表现为发送调用等待。另一方面,调用耗时也可能来自拥塞、丢失或调度。只记录一次长耗时,不能认定零窗口就是原因。
实验因此需要同时记录:接收应用确实停读、线上出现零窗口、发送操作已经进入但尚未完成,以及恢复读取后的窗口和字节进度。每个观察点回答不同问题,缺一项就应保留相应不确定性。
内存缓冲不是线上容量
Linux socket(7)说明,设置 SO_RCVBUF 时内核会为记账开销翻倍,getsockopt 返回对应值;同时存在最小值和系统上限。程序应记录请求值和实际读取值,不能只把设置参数当成已经生效的全部容量。
即使读回一个确定值,它也不等于某个时刻通告窗口。已接收未消费的数据、协议实现的记账和预留、何时通告更新,都会影响两者关系。扩大接收缓冲不等于扩大物理链路带宽,也不保证应用处理变快。
Linux TCP sysctl 文档还说明,显式设置某个 socket 的 SO_RCVBUF 会关闭该 socket 的接收缓冲自动调优。因此,用固定小缓冲制造零窗口,可以观察流控与应用停读的关系,却不能声称测到了默认自动调优算法的增长过程。
零窗口不等于连接已经关闭
第 16 篇中,EOF、半关闭和复位有各自的含义。零窗口则表示当前接收限制,不携带“应用已经永久结束这个方向”的同一语义。接收方释放空间后,可以重新通告非零窗口,传输继续。
如果重新开放窗口的通知丢失,两端不能永久互相等待。RFC 9293 §3.8.6.1要求支持零窗口探测,使发送端仍有机会取得对端当前窗口。它解决窗口更新可靠性问题,不能直接当作网络拥塞丢包或 TCP keepalive。
规范允许接收窗口长时间保持关闭,并要求在对端持续响应探测时允许连接保持,同时保留实现的资源管理约束。应用自身的截止时间也仍可能终止操作,所以不能承诺“只要回应探测,任何程序都永远不会断开”。
实验:等到实际零窗口,再恢复读取
实验使用专用 Linux 虚拟机里的两个自建网络命名空间 A、B,由 veth 直接相连。没有修改宿主默认路由,也没有连接生产服务。程序、完整原始帧及运行说明分别保存在 实验程序、运行记录、环境与边界。程序仅依赖 Python 标准库与已安装的 ip、ss。
在专用 Linux 虚拟机内下载脚本后执行:
1 | |
程序需要创建命名空间和 AF_PACKET 捕获的权限;不应在生产机器上运行。macOS 直接运行会在创建网络资源前退出。每次运行使用随机命名空间名称,结束时清理本次创建的资源;端点和屏障等待设有 12 秒超时,捕获上限为 30 秒、4096 帧。环境不具备这些权限时,可以执行前面的窗口手算,但不能据此宣称完成真实 TCP 实验。
B 在监听前设置小接收缓冲,接受连接后等待控制管道,不调用 recv()。A 设置小发送缓冲,进入一次 262144 字节的 sendall()。捕获进程保留完整握手;它实际观察到 B 的零窗口后,才通知父进程检查发送端状态。父进程记录两端 ss -tinm,再释放 B 的读取屏障。
这种顺序不依赖“睡眠若干秒后窗口大概已经满了”。不过,“发送端已经报告进入调用、尚无返回报告”仍是程序事件证据,不能据此定位线程当时的内核栈或 CPU 调度状态。队列快照补充了本地内核中尚未发送和尚未读取的数据量。
本次记录
2026-09-20 的实际环境为 Linux 6.18.10-200.fc43.aarch64、Python 3.14.3、ss 来自 iproute2 6.14.0。这些是本次运行版本,不是对其他机器的前提判断。
| 观察点 | 本次实际值 | 可以支持的判断 |
|---|---|---|
| B 接收缓冲 | 请求 4096,监听及接受后的 socket 均读回 8192 | 设置和读回不同,不能拿 8192 当线上窗口 |
| A 发送缓冲 | 请求 8192,读回 16384 | 本地存在独立发送缓冲 |
| 握手 | A SYN:窗口 64240、WS=10;B SYN-ACK:窗口 2896、WS=0 | 双方协商缩放,SYN 自身字段不缩放 |
| 零窗口时 | B ACK=875103797、窗口 0 | 当前 B 的接收通告关闭 |
| 停读快照 | A Send-Q=13032、notsent=13032;B Recv-Q=4344 | 两端均有尚未推进到下一阶段的数据 |
| 恢复读取后 | B 同一 ACK、窗口 2896 | 本次窗口重新开放 |
原始记录中帧下标从零开始。第 10 号帧为零窗口,第 11 号帧恢复为 2896。B 的移位数为零,因此这个方向无需放大;A 后续原始窗口 63 则还原为 63 << 10 = 64512,约束相反方向的数据。不能把 A 的移位数用到 B 的通告上。
A 的发送调用在零窗口检查前已进入,在两端队列快照前后都没有返回报告。恢复 B 读取后,A 报告发送返回,B 随后完成全部字节接收。B 对实际 recv() 返回的数据逐字节比较,而不只是比较长度;实际 262144 字节的 SHA-256 为:
1 | |
A 返回时间为单调时钟 7574003984 ns,B 完成接收校验时间为 7574229860 ns。两者分开的事实再次说明,发送调用返回不是接收应用完成处理的凭据。这些用户态时间含进程调度开销,只用来核对本次事件顺序,不作为线路时延或吞吐基准。
记录中还有一次重复数据段
零窗口之前,第 8、9 号帧包含相同 SEQ=875102349、长度 1448 的数据;A 的快照同时记录 bytes_retrans:1448、dsack_dups:1,B 的内存统计带有 d1。这些观察没有从记录中删除。
实验没有主动注入丢包,也没有采集内核跟踪点,因此不能给这个重复段指定唯一原因。它发生在第 10 号零窗口通告之前,不能作为已经验证零窗口探测的证据。完整 217 帧中没有 RST,但“没有 RST”同样不等于所有异常原因都已排除。
验证边界
本次验证了自建 Linux 命名空间中,接收应用停读、实际零窗口、未完成的发送调用与队列积累,以及恢复读取后窗口开放和全部字节一致。命名空间已清理,运行实验的虚拟机已停止。该证据仅覆盖这个内核、缓冲设置、负载和本地路径。
显式设置 SO_RCVBUF 的实验没有验证接收缓冲自动调优。零窗口出现后很快恢复读取,没有验证 persist 探测、长期零窗口资源管理,也没有测公网吞吐或生产应用的背压传播。
捕获位置是 B 的 veth,最大捕获 TCP 载荷为 2896 字节,可能受分段或聚合卸载影响,不能当成物理线路上的帧形态。脚本解码器只处理本实验受控的无 VLAN、无分片 IPv4 TCP 报文;它提取窗口缩放选项,不验证校验和,也不完整解释其他 TCP 选项。原始帧与应用字节校验分别保存,不能用其中一个替代另一个。
练习
第一题:ACK 从 1001 变成 3049,窗口从 4096 变成 2048。接收端是否缩回了原先的窗口右边界?
校验:没有。两次右边界都是 5097。窗口宽度减小与右边界后退需要区分。
第二题:B 握手通告移位数 3,A 通告移位数 7。B 后续报文原始窗口为 1000,应该乘八还是乘 128?
校验:乘八。B 的窗口通告使用 B 在握手中给出的比例,并限制相反方向 A→B 的数据。若该报文自身带 SYN,则不缩放。
第三题:服务端停读之后,客户端一个小 sendall() 很快返回,能否证明本次没有背压风险?
校验:不能。数据可能暂存在本地或远端内核缓冲,后续更大写入才遇到限制。还需检查接收端实际读取进度、窗口与发送队列。
参考资料
- RFC 9293 §3.8.6:窗口管理、零窗口探测与更新。
- RFC 7323 §2:Window Scale 协商与使用。
- Linux socket(7)与send(2):缓冲设置及发送接口。
- Linux IP sysctl:tcp_rmem 与接收自动调优。
- Python socket:sendall 返回及异常边界。






