bigywater.tech
网络协议 · 54

Nagle 算法与延迟 ACK:40 ms 的由来

Nagle 算法和延迟 ACK 单独看都合理,放在一起据说会产生几十毫秒的停顿。这篇在本机上实测:延迟 ACK 到底延不延迟、经典冲突能不能复现、真实网络上 Nagle 的代价是多少、它又省下了多少包。有一半的结论是“没复现”,我会如实写。

0.22 vs 0.20 ms
回环上 Nagle 开分两次 write 与 TCP_NODELAY 的请求到应答延迟中位数(没有差别)
403 → 201 ms
对 example.com 分两次 write 发 HTTP 请求,Nagle 开与 TCP_NODELAY 的首字节时间,差约一个 RTT
908 vs 20000
2 万次 100 字节小写入后线上的数据段个数

延迟 ACK

实测 · 本机执行输出
  net.inet.tcp.delayed_ack: 3
  sysctl: unknown oid 'net.inet.tcp.sendmoreacks'
  delayed_ack: 0 = 关(每个包都立刻 ACK);1 = 总是延迟;2 = 每两个包 ACK 一次;3 = 自适应(检测到对端是 Nagle / 交互流量时变成立刻 ACK)
  实测:客户端发 1 个 100 字节的包,服务端不回数据,量服务端什么时候发出纯 ACK(10 次)
  一个孤立的小包之后,服务端纯 ACK 的延迟(ms):[0.1, 0.1, 0.1, 0.1, 0.1, 0.0, 0.1, 0.1, 0.1, 0.1];中位 0.1 ms,最大 0.1 ms
  对照:连续发 2 个小包(TCP_NODELAY,间隔几微秒)时,抓到 20 个数据段(共 10 轮);服务端回的纯 ACK 个数 19——(数一数:几乎每个数据段都有一个纯 ACK,没有看到两个包合并成一个 ACK)

接收方不一定马上回 ACK,可以等一等好捎带数据或合并。macOS 的 net.inet.tcp.delayed_ack 是 3(自适应)。实测回环上孤立小包 0.1 ms 就被确认。

经典冲突没有复现

实测 · 本机执行输出
  Nagle 开(默认),一次 write 20 字节    :请求到应答 中位 0.22 ms / 最小 0.07 / 最大 0.45 ms
  Nagle 开(默认),分两次 write         :请求到应答 中位 0.22 ms / 最小 0.10 / 最大 0.40 ms
  TCP_NODELAY,分两次 write         :请求到应答 中位 0.20 ms / 最小 0.10 / 最大 0.47 ms
  回环上三种写法都在 0.2 ms 左右,没有出现经典的 40 ms 级停顿:这台机器的 delayed_ack=3(自适应),回环上收到小包后立刻回 ACK,Nagle 不用等;经典冲突在这里没有复现

Nagle 要等 ACK,ACK 又被延迟,就会停顿。本机回环上三种写法都是 0.2 ms 左右,没有 40 ms 级的停顿——这是“没复现”,不是“不存在”。

Nagle 的时间线

实测 · 本机执行输出
  从第一个 write 到收到应答共 0.17 ms。线上(相对第一个数据包的时间):
    +   0.00 ms  客户端→服务端  载荷 10 字节  
    +   0.05 ms  服务端→客户端  载荷  0 字节  纯 ACK
    +   0.08 ms  客户端→服务端  载荷 10 字节  
    +   0.08 ms  服务端→客户端  载荷  0 字节  纯 ACK
    +   0.11 ms  服务端→客户端  载荷 10 字节  
    +   0.13 ms  客户端→服务端  载荷  0 字节  纯 ACK
  第二个 write 的 10 字节是在第一个包的 ACK 回来之后才发出的(Nagle:有未确认的小包时,新的小数据先攒着等 ACK);回环上 ACK 几十微秒就回来,所以感觉不到

第二个小写入是在第一个包的 ACK 回来之后才发出的,这就是 Nagle;回环上 ACK 几十微秒就到。

真实网络的代价

实测 · 本机执行输出
  握手耗时(≈ 一个 RTT)中位 192 ms
  Nagle 开(默认):8 次有效,第一次 write 到响应第一批字节 中位 403 ms(最小 377 / 最大 514)
  TCP_NODELAY:8 次有效,第一次 write 到响应第一批字节 中位 201 ms(最小 189 / 最大 320)
  两者之差的中位数 202 ms,约 1.0 个握手耗时

对 example.com 把请求分两次 write,Nagle 开时第二次 write 要等一个 RTT,首字节时间从 201 ms 变成 403 ms。

en0 上的 ACK

实测 · 本机执行输出
  服务器发来 2 个数据包,其中 1 个找到了对应的本机纯 ACK:5 字节 → 0.1 ms

只抓到一个可配对的数据包,0.1 ms 回 ACK,样本太小,仅作旁证。

Nagle 的好处

实测 · 本机执行输出
  Nagle 开(默认):20000 次 write(共 2,000,000 字节)用时 0.04 秒;线上数据段 908 个,平均每段 2203 字节;纯 ACK 908 个
  TCP_NODELAY:20000 次 write(共 2,000,000 字节)用时 0.11 秒;线上数据段 20000 个,平均每段 100 字节;纯 ACK 8813 个
  Nagle 把小写入合并成大段,包数少得多;代价是交互式小消息可能被推迟。TCP_NODELAY 适合"请求-应答"和低延迟场景,Nagle 适合不关心单个包延迟的批量小写入

2 万次 100 字节小写入,Nagle 合并成 908 个数据段,TCP_NODELAY 则是 20000 个,每段 100 字节。

分步演示

Nagle 与延迟 ACK
本步的对象

    本文用到的工具与边界

    • 本机 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 上用 Python 套接字加 tcpdump,第四、五节对 example.com:80 和 en0;第四节各 8 次,握手耗时当作 RTT 的近似;用时只看相对大小
    • 没有验证:延迟 ACK 在 delayed_ack 取 0、1、2 时的行为(没有改 sysctl);服务器端(Linux)延迟 ACK 与 Nagle 的 40 ms 冲突;TCP_NOPUSH 在本篇没有涉及;en0 上本机延迟 ACK 的完整分布;Nagle 在拥塞或丢包时的行为
    京ICP备2026067502号-1京公网安备11010502063487号