bigywater.tech
网络协议 · 55

TCP_NODELAY、TCP_CORK 与小包合并

想减少小包有三条路:TCP_NODELAY 让小包马上发、Nagle 帮你合并、或者像 Linux 的 TCP_CORK 那样先塞住再一起发。这篇在 macOS 上把这些手段逐个实测:选项读回是什么、分两次 write 到底能不能被合并、NOPUSH 这个软木塞到底在什么时候放气。

400 vs 200
200 条消息分两次 write 与一次 sendmsg 在线上的数据段个数
0 → 301 字节
NOPUSH 拔塞后仍无数据发出,再写 1 字节才以一个段发出
4 与 4096
TCP_NODELAY、TCP_NOPUSH 开启后 getsockopt 读回的值,都不是 1

选项与读回

实测 · 本机执行输出
  新连接上 getsockopt(TCP_NODELAY) = 0(客户端)、0(服务端 accept 出来的)
  客户端 setsockopt 后读回 = 4
  在监听套接字上先设 TCP_NODELAY=1,accept 出来的连接读回 = 4(非 0 = 已置位,即被继承;macOS 读回的不是 1 而是内部标志位的值)
  socket 模块里有 TCP_CORK: False;有 TCP_NOPUSH: False(值 4);有 MSG_MORE: False
  TCP_NOPUSH 默认 = 0;设成 1 后读回 4096

TCP_NODELAY 默认关;macOS 读回的不是 1 而是标志位值 4,监听套接字上设置会被 accept 的连接继承。Python 的 socket 模块这里没有 TCP_CORK 和 MSG_MORE。

合并要靠应用

实测 · 本机执行输出
  先 write 头、再 write 体    Nagle 开    :数据段 400 个(200 条消息)
  先 write 头、再 write 体    TCP_NODELAY:数据段 400 个(200 条消息)
  拼成一个缓冲区一次 send         Nagle 开    :数据段 200 个(200 条消息)
  拼成一个缓冲区一次 send         TCP_NODELAY:数据段 200 个(200 条消息)
  sendmsg([头, 体]) 一次     Nagle 开    :数据段 200 个(200 条消息)
  sendmsg([头, 体]) 一次     TCP_NODELAY:数据段 200 个(200 条消息)
  分两次 write 的写法,不论 Nagle 开还是 TCP_NODELAY,每条消息都是两个包:回环上第一个包的 ACK 几十微秒就回来了,Nagle 没有机会把第二次 write 合并进去;只有应用自己一次发出(拼接或 sendmsg)才是一条消息一个包

头和体分两次 write,不论 Nagle 开还是 TCP_NODELAY,回环上每条消息都是两个包。拼成一个缓冲区或用 sendmsg 一次交给内核,才是一条消息一个包。

NOPUSH 的拔塞

实测 · 本机执行输出
  Nagle 开(默认) + NOPUSH:
    写 3×100 字节(NOPUSH 开)后 0.5 秒:线上累计数据段 0 个,接收方累计读到 0 字节
    清除 NOPUSH 后 0.5 秒:线上累计数据段 0 个,接收方累计读到 0 字节
    再写 1 字节后 0.5 秒:线上累计数据段 1 个,接收方累计读到 301 字节
    shutdown(SHUT_WR) 后 0.5 秒:线上累计数据段 1 个,接收方累计读到 301 字节
    各数据段载荷:[301]
  TCP_NODELAY + NOPUSH:
    写 3×100 字节(NOPUSH 开)后 0.5 秒:线上累计数据段 0 个,接收方累计读到 0 字节
    清除 NOPUSH 后 0.5 秒:线上累计数据段 0 个,接收方累计读到 0 字节
    再写 1 字节后 0.5 秒:线上累计数据段 1 个,接收方累计读到 301 字节
    shutdown(SHUT_WR) 后 0.5 秒:线上累计数据段 1 个,接收方累计读到 301 字节
    各数据段载荷:[301]

开着 NOPUSH,小数据一个包都不发;在这台 macOS 上清除选项并不会把攒着的数据冲出去,下一次写入才会连同新数据一起发出。

满段照发

实测 · 本机执行输出
  接收方在 1.5 秒内读到 32664 字节(共写 40000);线上数据段载荷:[16332, 16332]
  40000 字节 = 2 个满段 (32664 字节) + 零头 7336 字节;零头没有发出
  清除 NOPUSH 后 0.5 秒,接收方没有再读到数据

NOPUSH 开着写 40000 字节,满 MSS 的两个段正常发出,只有 7336 字节的尾巴被扣住。

分步演示

NODELAY 与 NOPUSH
本步的对象

    本文用到的工具与边界

    • 本机 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;分两次 write 的对照只测了 Nagle 开 / TCP_NODELAY 两种,每条消息间隔 1 ms
    • 没有验证:Linux 的 TCP_CORK 与 MSG_MORE(本机没有);NOPUSH 在 en0 真实网络上的行为;NOPUSH 在没有后续写入时是否有超时自动发出(只等了 0.5 秒);writev 与 sendmsg 在内核里的差别;FreeBSD 与 macOS 在清除 NOPUSH 时是否冲出数据的差异原因
    京ICP备2026067502号-1京公网安备11010502063487号