UDP 与 QUIC
环境:Windows 11 x64,GCC 13.1.0(
D:/Qt/Tools/mingw1310_64/bin/gcc.exe,-O2 -lws2_32),流量全部打在 127.0.0.1 与本机的组播/广播地址上,报文用 Npcap(C:\Windows\System32\Npcap\wpcap.dll)抓,内核侧只读查询用netsh/route print/netstat。承袭《滑动窗口与拥塞控制》的口径:回环 MSS=65495、本机 TIME_WAIT≈119–120 秒、TCP 动态端口 1024 起共 13977 个;Npcap 在回环上会漏抓大数据段,所以每场实验都要报”抓到的字节数 vs 应用侧字节数”,比例型结论只在两侧都完整时才可下。本机同时还有 4–5 个 agent 在 6 个核上跑,耗时给 3 次重复的中位数(区间),比值与计数比绝对值可靠。本节刻意把结论压在计数、错误码、字节数和 setsockopt 的返回值上。
一、UDP 的三条硬边界:65507 字节、静默丢包、错误可选
边界一:一个数据报最大 65507 字节。 重编自 udp_max.c 的探针按尺寸表逐个 sendto(127.0.0.1,接收端 SO_RCVBUF=16 MB):
--- 允许分片(默认 IP_DONTFRAGMENT=0)--- --- 设置 IP_DONTFRAGMENT=1 ---
sendto( 1) = 1 err=0 sendto( 1) = 1 err=0
sendto( 1472) = 1472 err=0 sendto( 1472) = 1472 err=0
sendto( 4096) = 4096 err=0 sendto( 4096) = 4096 err=0
sendto( 8972) = 8972 err=0 sendto( 8972) = 8972 err=0
sendto( 8973) = 8973 err=0 sendto( 8973) = 8973 err=0
sendto( 65507) = 65507 err=0 sendto( 65507) = 65507 err=0
sendto( 65508) = -1 err=10040 sendto( 65508) = -1 err=10040
sendto( 65536) = -1 err=10040 sendto( 65536) = -1 err=10040
sendto(100000) = -1 err=10040 sendto(100000) = -1 err=10040
WSAEMSGSIZE=10040 WSAENOBUFS=10055 WSAEACCES=10013
65507 = 65535(IP 总长上限) − 20(IP 头) − 8(UDP 头),这个上限与链路 MTU 无关,是字段宽度决定的。接收端一次 recvfrom 收到 12 个数据报(两轮 ×6 档),载荷依次 1 / 1472 / 4096 / 8972 / 8973 / 65507,共 178042 字节,用时 0.6 ms —— 一个 recvfrom 对应一个数据报,这就是 UDP 与 TCP 最本质的区别(粘包问题见第 8 节)。
上一棒在同一台机器用 Npcap 抓的那场(network3/frag_cap.txt,udp port 19496)显示:回环上根本没有分片发生 —— 每条都是 frag=0 flags=---,最大 totlen=65535 frame=65535 wirelen=65539。这解释了上面那张表为什么”DF 开了也一样”:回环的 MTU 大到 65507 装得下,DF 无用武之地。想看出 DF 的差别必须上真实链路(未实测,见第五节)。
边界二:接收缓冲满了直接丢,而且不告诉任何人。 同一段源码,20000 个 8192 字节数据报连发(发送端不控速):
接收端 SO_RCVBUF(getsockopt 读回值) | 发送端 | 接收端读到 | 丢失 | 接收端耗时 |
|---|---|---|---|---|
| 请求 8192 → 读回 8192 | 连发 20000/20000 … 中途 err=0(167.9 ms) | 18160 个 / 148766720 字节 | 1840(9.2%) | 1478.8 ms |
| 请求 65536 → 读回 65536 | 连发 20000/20000 … err=0(208.9 ms) | 18401 个 / 150740992 字节 | 1599(8.0%) | 1579.8 ms |
| 请求 16777216 → 读回 16777216 | 连发 20000/20000 … err=0(169.3 ms) | 20000 个 / 163840000 字节 | 0 | 476.2 ms |
三行的发送侧完全一样:20000 次 sendto 全部返回成功、一个错误码都没有;163840000 字节里有 15073280 字节(=1840×8192)在接收端内核里蒸发。148766720 + 15073280 = 163840000,字节数能对上,所以这不是抓包漏,是真丢。上一棒用同一份源码的旧二进制跑出的量级一致(SO_RCVBUF=65536 时 5000 个丢 123、20000 个丢 633),可以当作重复实验看。
这里有一条必须记住的取证教训:我第一次复跑这场时,请求 8192 却读回 16777216,20000 个一个不丢,几乎要写下”Windows 忽略 UDP 的 SO_RCVBUF”。真实原因是目录里 udp_max.exe(00:38)比 udp_max.c(00:47)旧,第 4 个参数在它那里不生效;我用 Qt 的 gcc 把源码重编成 umx.exe 之后,读回值立刻变成 8192,丢包也随之出现。口径和第 5 节”抓包先核对抓到的字节数”是同一条:改内核参数之前,先用 getsockopt 把值读回来。
边界三:UDP 的”错误”是 ICMP 转述的,可选、且平台相关。 把 conn.exe 打到没人监听的 127.0.0.1:19699:
connect(UDP -> 127.0.0.1:19699,无人监听) r=0 err=0 ← connect 对数据报 socket 只是设默认目的地址
send#1 ret=1 t=0.00ms ← 发出去,此刻还不知道出事
recv#2 ret=-1 err=10054 等了 0.02ms (select=1) WSAECONNRESET=10054
再 send 一次 ret=1 err=10054 ← 每次 send 都会成功,每次 recv 都翻出 10054
recv#3 ret=-1 err=10054 等了 0.01ms
== WSAIoctl(SIO_UDP_CONNRESET,FALSE) ==
send ret=1 → recv ret=-999 err=0 等了 1008.90ms ← 错误不再端出来,select 一路超时
== 未连接的 UDP 打到同一个死端口 ==
sendto ret=1 err=0 t=0.00ms
recvfrom ret=-1 err=0 等了 0.65ms ← 有可读事件但取不出数据,last error 还是 0
recvfrom 第二次 ret=-999 err=0
三条实用结论:① 已连接(connect())的数据报 socket 才会把对端的”端口不可达”变成 recv 上的 WSAECONNRESET(10054),Windows 默认开启,可以用 WSAIoctl(SIO_UDP_CONNRESET, FALSE) 关掉——很多”UDP 客户端莫名 10054”就是这条;② 未连接的 socket 用 recvfrom 拿不到这个错误(本机表现为 ret=-1 且 WSAGetLastError() 仍是 0,读数可疑,只记录现象不当结论);③ 发端的 send 返回值永远不反映网络结果,不能拿 send>0 当”送达”的证据——这是 UDP 排障的第一课(更多在第 11 节)。
顺带一个”UDP 真的在跑”的现实样本:本机解析器到 223.6.6.6 的 DNS 抓包(udp port 53,12 条,读只见流量)里,去程 chksum=14c9/5f55/b3f9,回程全部 chksum=0000,响应载荷 125–372 字节、一律 frag=0。IPv4 的 UDP 校验和允许为 0(发送端可以不算),这条在 RFC 里是文字,在抓包眼里是每次都能看见的常态。DNS/QUIC 之外不再打扰外网,本节不引用那份日志里的往返毫秒数:本机 198.18.0.0/16 上挂着代理网卡(见第四节),那 0.x ms 不代表真实外网 RTT。
二、把 UDP 变可靠:一套自写 ARQ 的取证
协议只有 15 字节头:magic(2) flags(1) seq(4) cumack(4) len(2),数据报载荷 512 字节(上线 527),发送端滑动窗口 16、累计 ACK、超时回退 N 重发(go-back-N);接收端 seen[] 位图去重,且 base 只在缺口补齐时才前进:
while(base < n && seen[base]) base++; /* 累计 ACK 的真实语义:乱序可以收,不能跳过洞 */
if(dropk && (rd % dropk)==0){ dropped++; continue; } /* 每第 10 个到达的数据报丢一次,模拟丢包 */
丢包档固定为”每 10 个到达丢 1 个”、目标 300 个数据报,RTO 取三档,每档跑 3 次(发送端与接收端同时取数,两边计数交叉核对):
| RTO | 发出数据报 | 收到 ACK | RTO 触发 | 冗余 | 用时(3 次,中位/区间) | 接收端:到达/重复/被丢/回 ACK |
|---|---|---|---|---|---|---|
| 30 ms | 541 | 32 | 16 | 80.3% | 962.6 / 950.4 / 917.9 → 950.4(917.9–962.6) | 541 / 187 / 54 / 32 |
| 100 ms | 495 | 26 | 13 | 65.0% | 1958.3 / 1969.7 / 1968.8 → 1968.8(1958.3–1969.7) | 495 / 146 / 49 / 26 |
| 300 ms | 495 | 26 | 13 | 65.0% | 4534.4 / 4554.0 / 4524.8 → 4534.4(4524.8–4554.0) | 495 / 146 / 49 / 26 |
三档都跑到了 最终 base=300(数据全部齐),也就是协议是对的,代价全在冗余和时间上。四件事值得背下来:
- RTO 只改时间轴,不改重传量。 100 ms 与 300 ms 两档的
发出/重复/回 ACK三栏一字不差,只有用时按 2.30 倍放大(4534.4/1968.8,理论 3 倍,差的那部分是固定开销)。上一棒盘上的arqR100.txt与arqR300.txt两份日志完全相同,我当时怀疑是复制粘贴,这次两侧一起复跑才确认:在 go-back-N + 固定丢包图案下这是性质而不是 bug。(同一段sR100/sR300的接收端计数是到达=495 重复=146 丢弃=49 回累计ACK=26,和我这次的结果对得上,可以当作第 4、5 次重复样本。) - 三次重复里计数一丁点都没抖,抖的只有用时(RTO=30 三次都是 541/32/16)。所以这类实验要报比值和计数,别报绝对耗时——和第 5 节的口径一致。
- 零丢包也会重传。把丢包关掉(
每 0 个丢 1 个)后,RTO=30 ms 仍触发 11 次超时、发出 472 个、冗余 57.3%,用时 571.7 ms。原因写在接收端代码里:它”攒够 4 个新数据报,或静默 2 ms,才回一条累计 ACK”,而发送端的 30 ms 定时器比这条 ACK 策略还激进,于是空窗期到点就重发。结论:RTO 必须 > RTT + 对端 ACK/批处理策略的延迟,否则你测到的”丢包重传”是自己造的。 - 对端停读会放大重传。接收端开局
Sleep(200ms)不读、无模拟丢包:发出=508 收ACK=19 RTO=13 用时=572.9ms 冗余=69.3%(对照不停读的 472/11/57.3%)。而把接收端SO_RCVBUF压到 1024:发出=579 RTO=18 冗余=93.0%,接收端到达=501——579 个里发出、只到达 501,中间 78 个是被内核缓冲丢掉且谁都没报错,正是第一节边界二的形状。
这套形状解释了 QUIC 为什么要把可靠层搬到用户态:上面 RTO=30/100/300 三档的对比,在 TCP 里你只能等内核自己决定(第 5 节里那三档是 netsh 读回来的固定值:初始 RTO 1000 ms、最小 RTO 300 ms,改它要动全局),而在用户态里改一个参数就行;同理还有”累计 ACK 攒 4 个还是攒 8 个""窗口 16 还是 64”这类策略,TCP 都写死在协议栈里。
三、组播:membership 才是投递开关
udp2.exe mc 19690 239.1.1.1(接收端 SO_REUSEADDR + bind(0.0.0.0:19690),发送端把出接口设成 127.0.0.1):
bind(0.0.0.0:19690) r=0 err=0
IP_ADD_MEMBERSHIP(239.1.1.1 via 127.0.0.1) r=0 err=0
IP_MULTICAST_IF=127.0.0.1 r=0 err=0
IP_MULTICAST_TTL=1 r=0 IP_MULTICAST_LOOP=1 r=0 err=0
发出 20/20 个组播数据报到 239.1.1.1:19690
收到 4 字节 [m0] 源=127.0.0.1:53405
收到 4 字节 [m1] 源=127.0.0.1:53405
组播接收端收到 20/20 个(本机回环 1 次)
IP_DROP_MEMBERSHIP r=0
退组后再发 5 个,本机 socket 收到 0 个
Npcap 那 5 秒窗口里抓到 25 条,与”20 个 + 退组后 5 个”精确相等,所以这一场的抓包是完整的(不像第 5 节那场只有 54.7%):
[ 0] 127.0.0.1 -> 239.1.1.1 ver=4 id=12d8 flags=--- frag=0 ttl=1 proto=17 totlen=32 UDP 53405->19690 len=12 chksum=060e payload=4 [frame=36 wirelen=36]
[ 2004262] 127.0.0.1 -> 239.1.1.1 ver=4 id=12f0 ... ttl=1 ... payload=4 ← 退组后的 5 个,仍然上线,只是本机没人收
captured 25 packets
要点:① 组播的投递由 membership(IP_ADD_MEMBERSHIP)决定,与端口绑定无关:IP_DROP_MEMBERSHIP r=0 之后包照样发得出去、照样上线(抓包有),但本机的 socket 一个都收不到。排查”组播收不到”时,先看成员表再看防火墙。② 目的地址是 239.1.1.1(管理范围本地组,见第 3 节地址分类),ttl=1 全 25 条无一例外 —— IP_MULTICAST_TTL=1 的效果是”不许离开本机/本链路”,这也是为什么抓包里看不到它出去。③ IP_MULTICAST_LOOP=1 让发送者自己也能收到,这是”一台机器上测组播”能做起来的唯一原因。④ IP_MULTICAST_IF 决定用哪块网卡出去,多网卡机器上组播”发不出/收不到”绝大多数是这一条设错。
一条工具坑,写下来省两小时:dump.exe 的第三个参数会让它立刻退出(real 0m0.126s,打印 captured 0 packets)。我第一次复核组播时看到 0 条,差点得出”组播不上回环链路”的结论;把参数改成 dump.exe loopback "udp port 19690"(只两参,固定约 5 秒窗口)后 25/25 全在。抓包计数为 0 时,先怀疑抓包窗口,别怀疑网络。
四、广播:一个包上线,应用侧读到两次
udp2.exe bcast 19691(接收端 bind(0.0.0.0:19691))与 Npcap 同一窗口的对照:
未开 SO_BROADCAST:sendto(255.255.255.255) ret=-1 err=10013 (WSAEACCES=10013)
setsockopt(SO_BROADCAST) r=0 err=0
开 SO_BROADCAST 后 sendto 成功 10/10
sendto(127.255.255.255 子网定向广播) ret=4 err=0
收到源=198.18.0.1:61849
广播/定向广播:本机接收端收到 21 个
[ 0] 198.18.0.1 -> 255.255.255.255 ttl=128 UDP 61849->19691 len=12 payload=4 ← 共 10 条
[ 283] 127.0.0.1 -> 127.255.255.255 ttl=128 UDP 61849->19691 len=12 payload=4 ← 共 1 条
captured 11 packets
发 11 个、线上 11 条、应用侧读到 21 个 —— 这是第 5 节那条纪律的反向案例:那边是抓包漏、这边是投递比线上多。原因是 255.255.255.255 会在每一个已连接的接口上各投一次,本机 netsh interface show interface 有 7 个接口、其中 198.18.0.1(代理用的网卡)与 192.168.103.24(Realtek PCIe)都是”已连接”,route print 里也确有两条默认路由(网关 198.18.0.2 与 192.168.103.254)。于是 10 个受限广播变成 20 次投递,加上 127.255.255.255 的 1 次 = 21。写探测脚本时要记住这点:用广播做”一次请求、N 个应答”的计数会翻倍,去重要靠 magic + 序号,不是靠”收到几条”。
另外三条对照:
- 源地址由路由决定:目的
255.255.255.255的包用的源是198.18.0.1,目的127.255.255.255的包用的是127.0.0.1。同一条 socket、两次sendto,源地址不同。 - 广播没有”跳数”概念:TTL 是 128(和单播一样),而第三节的组播是
ttl=1。要让流量不出本机,组播靠 TTL,广播靠的是”根本没有这个开关”(SO_BROADCAST只是许可,不是范围限制)。 127.255.255.255不需要SO_BROADCAST。 新写的探针sk.exe bcs 19810在没开SO_BROADCAST时就发它:sendto(127.255.255.255:19810) ret=1 err=0,开了之后再发还是 1;同场对照单播127.0.0.1也是ret=1 err=0。Windows 不把 127.255.255.255 当广播,它是回环网段里的普通地址(投递结果见上面那 1 条),所以别指望用它测广播行为。
五、QUIC 与 0-RTT:本节未在本机实测,原因写清楚
先给能证实的部分——本机确实没有 QUIC 服务端可抓:
ls C:\Windows\System32\msquic.dll → No such file or directory
ls C:\Windows\System32\quic.dll → No such file or directory
netstat -ano -p UDP → 监听端口有 123/500/4500/5050/5353 等,没有任何 :443 或 :44361
因此以下四项本节一律不给数字,只讲原理,避免被当作本机实测引用:
- QUIC 的握手往返数与 0-RTT:
0-RTT指客户端在拿到服务端证书之前,就把第一批应用数据用上一次会话学到的早密钥(early secrets)加密发出,与ClientHello同批到达;省下的正好是 TLS 1.3 那一个 RTT(原理对照见第 9 节)。风险是重放:0-RTT 数据不保证前向顺序与唯一性,任何有副作用的请求(下单、扣款)都必须由服务端拒绝或幂等化。本机没有 QUIC 服务端,也没有第二台机器可自建,无法观察 Initial 包、retry_token、迁移连接 ID 的任何字节。 - QUIC 相对 TCP+TLS 的真实收益:取决于丢包率、带宽时延积、握手次数与多路复用的队头阻塞程度。跨机链路在本机测不了——同网段都没有第二台机器,对外只允许少量只读请求(本节只用了 DNS 一类的观察),不做压测、不打外部服务。
- QUIC 的丢包恢复与拥塞控制表现:它是用户态可换的拥塞控制器(cubic/bbr 都在实现里可切),对比口径应当是第 5 节那套
cwnd/RTT/重传;但本机回环连 TCP 的 cwnd 都不参与决定速率(那边实测SndLimTransCwnd=0),QUIC 更测不出东西。 - 真实链路上的 IP 分片与 DF:第一节的表说明回环上看不出
IP_DONTFRAGMENT的差别(MTU 太大)。QUIC 为了避免分片会把 UDP 载荷压到 PMTU 以下(v1 要求初始至少 1232 字节的 UDP 载荷),这条只能靠实现文档,本机无法验证 PMUD 行为。
能给的一条本机依据是”为什么 QUIC 选 UDP”:第二节的三档 RTO 实验说明,可靠传输的性能瓶颈常常在策略参数(超时节拍、ACK 攒几个、窗口多大)而不是算法有没有;这些在 TCP 里是内核全局(netsh int tcp show supplemental 那几行),在 UDP 上就是一个变量。另外第一节已经实测:数据报 socket 连”端口不可达”这种错误都要靠 ICMP 转述、还能被 SIO_UDP_CONNRESET 关掉——反过来说,UDP 给用户态留下的自由度正是 QUIC 需要的:它自己定义连接 ID、自己做拥塞判定、自己决定何时算超时,不必等内核替它回答。
六、把实测摊开成一张选路判据
上面四节的数字凑在一起,其实已经把”什么时候该用 UDP、什么时候不该”写完了:
| 你要的性质 | 本机证据 | 该怎么做 |
|---|---|---|
| 保留消息边界(一条就是一条) | 第一节:12 个数据报的载荷依次 1/1472/4096/8972/8973/65507,一次 recvfrom 对应一个,178042 字节全数对上 | UDP 天然满足;TCP 侧的对照实验在第 8 节:三次 send(10) 被一条 recv(1024) 读成 30 字节 |
| 不能容忍丢数据 | 第一节:SO_RCVBUF=8192 时 丢 9.2%(1840/20000),而发送端 20000 次 sendto 全部返回成功 | 别用裸 UDP;要么换 TCP(第 4、5 节),要么自己补序号+ACK+超时 |
| 想自己定重传节拍 | 第二节:RTO 30/100/300 ms 三档,重传计数不变、用时 950.4→4534.4 ms | 用户态一个变量搞定;TCP 里那是 netsh 的全局参数 |
| 一对多、且允许丢 | 第三节:membership 决定投递,ttl=1 不出本机,IP_MULTICAST_LOOP=1 才能同机自测 | 组播(配 IP_ADD_MEMBERSHIP),并把”重复投递”当噪声(第四节:线上 11 条、应用侧 21 次) |
| 极小报文、一问一答 | 第一节 DNS 抓包:查询 32–44 字节、响应载荷 125–372 字节、一律 frag=0 | UDP;但要接受”回程可能不带校验和”(chksum=0000) |
| 想知道”对端不存在” | 第一节:只有已连接的数据报 socket 才在 recv 上给出 10054,还能被 SIO_UDP_CONNRESET 关掉 | connect() + recv(),并且不要关 CONNRESET;未连接 socket 的 recvfrom 在本机连错误码都给不出 |
反过来,这四种信号说明你其实不该上 UDP:① 业务要求”要么全收到要么全不收”的原子性且不允许自建重传;② 你需要流控(第 5 节那套接收窗口在 UDP 里一个都没有,第一节 9.2% 的丢包就是”没有流控”的样子);③ 链路上有会静默丢 UDP 的中间设备(本机无第二台机器,未实测);④ 你只是想要”低延迟”却没测过延迟到底花在哪 —— 第 11 节的分解口径先走一遍。
七、小结
- UDP 的一条硬上限是 65507 字节:
sendto(65508)直接err=10040 WSAEMSGSIZE;IP_DONTFRAGMENT开与关在本机一字不差,因为回环 MTU 大到不分片(抓包frag=0、最大totlen=65535)。 - UDP 丢包是静默的:20000 个 8192 字节数据报打向
SO_RCVBUF=8192的接收端,发送端 20000/20000 全部成功,接收端只读到 18160 个(丢 9.2%);SO_RCVBUF抬到 16 MB 才一个不丢。设完缓冲区一定要 getsockopt 读回来核对——我第一次被旧二进制骗出过”Windows 忽略 SO_RCVBUF”的错误结论。 send>0不等于送达:端口不可达只在已连接的数据报 socket 上以recv返回WSAECONNRESET(10054)的形式出现,且可被WSAIoctl(SIO_UDP_CONNRESET, FALSE)关掉(关掉后recv一路超时 1008.9 ms)。- 自写可靠 UDP 的四要素实测有效:序号 + 去重位图 + 缺口补齐才前进的累计 ACK + 超时回退重发。RTO 30/100/300 ms 三档的重传计数与冗余率(80.3%/65.0%/65.0%)不随 RTO 变化,只有用时变(950.4/1968.8/4534.4 ms 中位);RTO=30 时即使零丢包也要重发 472/300(57.3% 冗余),因为接收端”攒 4 个或静默 2 ms 才 ACK”。
- 组播看 membership:20 发 20 收、
ttl=1全 25 条无例外,IP_DROP_MEMBERSHIP之后再发 5 个本机收到 0 个;IP_MULTICAST_IF决定出接口,IP_MULTICAST_LOOP=1才是同机自测的前提。 - 广播会重复投递:发 11 个、抓包 11 条、应用侧读 21 个,因为本机有两个已连接接口;
255.255.255.255需要SO_BROADCAST(否则10013 WSAEACCES),而127.255.255.255不需要、也没被当广播。 - QUIC / 0-RTT / 真实链路分片:未在本机实测(
msquic.dll不存在、无:443UDP 监听、没有第二台机器),只保留原理与”为什么选 UDP”的本机依据,不给任何数字。