bigywater.tech
网络协议 · 62

TCP 队头阻塞:一个包丢了,后面的数据都在等

TCP 保证按序交付,这个保证有代价:一个包丢了,后面已经到达的数据也只能在接收缓冲区里干等。这篇用 netlab 造丢包,逐条测消息从发出到被应用读到的延迟,看队头阻塞到底拖慢了多少、一次丢包会连累几条消息、拆成多条连接又能缓解多少。

28.8%
TCP 单连接里延迟超过 80 ms 的消息占比(UDP 为 0%,但丢 3%)
972 ms
序号 70 补上的那一刻,后面 20 条消息在同一时刻被一起交给应用
9 条
一次队头阻塞事件拖住的消息数的中位数(最大 34)

对比 UDP 与 TCP

实测 · 本机执行输出

UDP 不重传,延迟全在 50~53 ms,代价是丢 3% 的消息;TCP 一条不丢,但 28.8% 的消息延迟超过 80 ms,p99 229.8 ms。

分布

实测 · 本机执行输出
    配置                    收到     丢失     p50(ms)   p90(ms)   p99(ms)   最大(ms)   延迟 > 80 ms 的消息
    UDP(无重传)                1452      48       50.3      50.8      51.4       52.7       0(0.0%)
    TCP 1 条连接               1500       0       50.5     141.0     229.8      280.3     432(28.8%)
    TCP 4 条连接轮流发            1500       0       50.4      71.0     191.3      350.4     131(8.7%)
    TCP 16 条连接轮流发           1500       0       50.3      50.9     311.1      469.8      82(5.5%)
  理论:单程 50 ms;TCP 丢一个包后要等快速重传(约 1~2 个 RTT 以上)或 RTO(lo0 下限 ≈ 230 ms)才能补上,所有排在它后面的消息都被迫等待

连接拆得越多,被拖住的消息比例越低(28.8% → 8.7% → 5.5%),16 条连接的尾部延迟却更高。

一口气释放

实测 · 本机执行输出
    序号   发送时刻   应用收到时刻   延迟
      68        680            731       51 ms
      69        690            740       50 ms
      70        700            972      272 ms
      71        710            972      262 ms
      72        720            972      252 ms
      73        730            972      242 ms
      74        740            972      232 ms
      75        750            972      222 ms
      76        760            972      212 ms
      77        770            972      202 ms
      78        780            972      192 ms
      79        791            972      181 ms
      80        801            972      171 ms
      81        811            972      161 ms
  从序号 70 起这一段里延迟 > 80 ms 的消息有 20 条,它们被应用收到的时刻集中在 972~972 ms 的 0 ms 之内——被丢的那个包补上之后,一口气释放

序号 70 丢了,后面 19 条早已到达的消息一直在内核缓冲区里等,972 ms 补上之后同时交给应用。

每次拖住多少

实测 · 本机执行输出
  3 次共 38 次队头阻塞事件;每次拖住的消息数:中位 9,最大 34;每次事件里的最大延迟:中位 161 ms,最大 280 ms

38 次事件,中位每次拖住 9 条消息,最大 34 条;事件里的最大延迟中位 161 ms。

分步演示

队头阻塞
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。全部在 lo0 上,netlab 单程 50 ms、双向丢包 3%;发送和接收在同一个进程的线程里,计时用同一个时钟;每种配置 3 次共 1500 条,丢包是随机的,数字每次运行会不同
    • 没有验证:16 条连接尾部延迟更高的原因(只是推测);不同丢包率和不同消息速率下的结果;QUIC / HTTP/3 在同样条件下的表现(本系列后面有 QUIC 一篇);TCP 在接收方应用层“不按序读”是否有办法(TCP 没有这个选项,没有试);SACK 对队头阻塞时长的影响
    京ICP备2026067502号-1京公网安备11010502063487号