9.3 用户数据报协议(UDP)
1. 什么是 UDP
1.1 UDP 的定位与设计哲学
用户数据报协议(User Datagram Protocol, UDP)是传输层中最为纯粹、轻量的无连接传输契约。与 TCP 竭尽全力构建复杂状态机以保障可靠流式交付的哲学截然不同,UDP 在设计上几乎仅仅是对底层 IP 协议做了一层最克制的封装:它仅仅补充了 16 位的源/目的端口号以实现宿主机进程间的多路复用与解复用,并附带了一个可选的 16 位差错校验和。
这种极致的极简主义赋予了 UDP 鲜明而不可替代的工程特性:
- 完全无连接:通信双方在传输数据前后不存在任何形式的握手协商或断开挥手。应用程序无需维护握手状态即可直接投递报文,这使得其端到端首包延迟极低,系统亦无需在内核中为每个远端对点维护繁重的连接控制块(TCB)。
- 首部极度轻量:标准 UDP 首部固定仅占 8 个字节(源端口、目的端口、长度、校验和各占 2 字节),相比于基础便占据 20 字节(且常附带额外选项)的 TCP 首部,UDP 的协议包头开销大幅缩减,因而在小包高频的物理传输中具备极高的有效载荷占比。
- 严格保留消息边界:UDP 交付给应用层的是离散、独立的数据报(Datagram)。应用程序调用一次
sendto发出多少字节,接收端在缓冲区充足的前提下调用一次recvfrom便精确取回多少字节,绝对不会发生字节流意义上的无感知拼装或切割。 - 原生不可靠语义:协议本身对数据的送达状态保持冷漠。它不维护发送确认,不执行丢包重传,不校正乱序到达,亦不剔除在网络中因重传或多路径引发的重复数据报。若业务场景需要在此类通道上建立可靠性,所有机制(如重传、ACK 累积、重排序)均须由应用层自力更生。
2. UDP 的数据交付与流转
2.1 独立报文投递与无分段特性
在 UDP 的数据处理路径中,应用层传递给内核的整块缓冲区会被完整塞入单个 UDP 报文的数据载荷区。传输层本身绝不对长报文执行主动分段切片;若该数据报注入网络层后超过了当前物理链路的 MTU,分片(Fragmentation)与重组的重担将完全下沉由 IP 层承担。
然而在工程实践中,依赖 IP 层的透明分片是极其危险的:因为同一个 UDP 数据报一旦被切分成多个 IP 分片,只要其中任何一个物理分片在路由器排队中丢失,整个 IP 数据报在目的端就无法完成重组,最终导致这整个 UDP 数据报被完整报废。因此,优秀的 UDP 应用协议(如 DNS、QUIC、RTP)通常会在设计时主动将单包最大尺寸克制在典型互联网路径的保守 MTU(如 576 字节或 1280 字节)之内,从而在应用层源头杜绝 IP 分片的发生。
3. UDP 的核心特征与物理现实
3.1 无连接带来的敏捷与广播优势
由于不存在连接状态的羁绊,UDP 具备独特的拓扑灵活性。一个处于监听状态的 UDP 套接字可以同时承接来自成千上万个不同 IP 与端口的并发数据报,并能在紧接着的响应中分别回送给不同的源地址。此外,UDP 天然支持局域网广播(Broadcast)与多播(Multicast)寻址,这是基于点对点连接绑定的 TCP 所完全无法企及的领域。
在时延极度敏感的场景下(例如在线语音通话、实时动作竞技游戏的位移同步),稍纵即逝的物理帧一旦落后于当前时钟刻度,在业务上便彻底丧失了消费价值。此时若采用 TCP,丢包引发的拥塞控制与超时重传非但无法挽回失效数据,反而会由于前向队头阻塞(Head-of-Line Blocking)将后续所有的最新实时报文活生生按死在接收缓冲区中。UDP 允许应用层直接将陈旧丢包置之度外,始终处理最新到达的即时报文。
3.2 接收端缓冲区截断机制
UDP 在读取行为上最令人震惊的陷阱,莫过于缓冲区不足引发的静默截断:
当一条承载 1000 字节载荷的 UDP 数据报到达宿主机后,若应用程序调用 recvfrom 时所分配的用户态接收缓冲区仅有 512 字节,内核网络子系统在将前 512 字节复制进用户空间后,会将其余的 488 字节立即在内核中永久丢弃。更关键的是,在常规标志位下,recvfrom 会返回成功接收的字节数(即 512),既不产生任何负数错误码,亦不会在后续的读取中留下任何尾巴供应用层追加拼装。如果不主动传入 Linux 专有的 MSG_TRUNC 标志来探查原始尺寸,应用程序往往会在毫不知情的状态下解析残缺的破损数据。因此,规划 UDP 系统接收端时,缓冲区分配必须严格以协议允许的最大数据报上限为底线。
4. C 语言中的 UDP 编程范式
在 POSIX C 套接字体系中,UDP 的交互范式呈现出与 TCP 截然不同的精炼路径:
- 创建数据报套接字端点:调用
socket(AF_INET, SOCK_DGRAM, 0)分配套接字描述符,第二参数必须明确指定为SOCK_DGRAM; - 绑定本地端口(接收方或固定端口发送方):服务端或需要暴露静态端口的对等端,调用
bind将该描述符与特定的本地 IP 和端口结合; - 无连接的数据收发:
- 发送时直接调用
sendto(fd, buf, len, 0, (struct sockaddr *)&dest_addr, addrlen),在每次调用中显式携带该数据报的专属物理目的地; - 接收时调用
recvfrom(fd, buf, buflen, 0, (struct sockaddr *)&src_addr, &addrlen),函数在阻塞直到有数据报到达后,不仅拷贝载荷,还会将发信方的源 IP 与源端口写入src_addr结构体,供应用程序判定来源或原路寻址回显;
- 发送时直接调用
- 特殊技巧:已连接的 UDP 套接字:UDP 套接字同样允许调用
connect。但该系统调用绝不在网络上发起任何 SYN 数据包,它仅仅在本地内核中将该套接字与指定的对端地址/端口完成固化绑定。绑定后,该套接字便可以直接使用常规的send与recv,更为关键的是,由于明确了对端身份,内核终于能够将路由器异步返回的 ICMP 端口不可达(Port Unreachable)错误准确路由并挂载至该套接字上,使后续的系统调用能够及时捕获到ECONNREFUSED,破除无连接套接字永远无法感知对端服务存亡的盲区。
5. 习题
UDP 没有连接、没有重传,程序要自己补的东西比 TCP 多。下面四道题按「一条数据报的边界」展开:怎么发、怎么收、收到了什么、丢了怎么知道。
正文说 UDP 保留消息边界。写一个最小的收发对:发送端用一次 sendto 发出 hello(5 字节),再发一次空的 sendto(长度为 0);接收端用 64 字节缓冲区连续 recvfrom,把每次的返回值、源端口和 errno 都打印出来(SO_RCVTIMEO 设 500 毫秒,以便在收完两条之后退出)。
要求:
- 接收端第一次、第二次
recvfrom各返回多少?第二次返回 0 是「对端关闭」还是「收到了一条空数据报」? - 把接收缓冲区改成 3 字节,重发
hello。recvfrom返回 3 还是 5?剩下两个字节到哪里去了?再读一次会得到什么? - 发送端不
bind时,getsockname在sendto前后各返回什么?把它打印出来。
验收:第 1 问是 5 和 0,且第二次不是连接关闭——UDP 没有「连接关闭」这个事件,返回 0 只表示数据报的负载长度为 0。第 2 问里缓冲区 3 字节时 recvfrom 返回 3,内容只有 hel,多出的字节直接丢弃,下一次 recvfrom 超时返回 -1、errno 为 EAGAIN,而不是读到剩下的两个字节。第 3 问中 sendto 之前 getsockname 给出端口 0、地址 0.0.0.0,之后由内核分配了一个临时端口。
写一个 udpecho PORT:绑定 127.0.0.1:PORT,每收到一条数据报就原样发回给来源地址,并在标准输出打印 N bytes from IP:PORT。再写一个客户端,依次发送 10、100、1000、2000 字节,打印每条回显的长度。
要求:
- 客户端的四次
recvfrom各返回多少?1000 字节那条会不会像 TCP 那样被拆成几次读取? - 把客户端的接收缓冲区改成 512 字节,重发 1000 字节那条。返回值是多少,回显内容还剩多少?
- 用
MSG_TRUNC标志再收一次同样的数据报,返回值变成多少?由此说明MSG_TRUNC改变了什么、没改变什么。
验收:第 1 问中四次回显长度依次是 10、100、1000、2000,1000 字节只对应一次 recvfrom(lo 的 MTU 是 65536,不会分片)。第 2 问返回缓冲区的长度 512,内容被截断,多出的部分丢弃,剩下的字节不会留到下一次读取。第 3 问带 MSG_TRUNC 时返回值变成原始数据报长度 1000,但缓冲区里仍然只有 512 字节,所以它只改变返回值、不改变搬进缓冲区的数据量。
UDP 套接字也可以 connect,它不发报文,只给套接字记下默认对端。写一个程序验证连接过的 UDP 套接字与未连接的差别:
- 用一个没人监听的端口(
127.0.0.1:23999)作目标。未连接的套接字连续sendto两次,分别打印返回值和errno;然后用recvfrom读一次(设 1 秒超时),打印结果。 - 新建一个套接字,
connect到同一个端口。打印connect的返回值。连续send两次,再recv一次,打印每一步的返回值与errno。 - 用
/proc/net/snmp的Udp:行读NoPorts计数,在第 2 步前后各读一次,说明这个计数增加了几次、每次对应哪个动作。
验收:第 1 问两次 sendto 都返回 1,recvfrom 超时返回 -1、errno 为 EAGAIN。第 2 问中 connect 返回 0(目标端口没有监听也照样成功),第一次 send 返回 1,第二次 send 返回 -1、errno 为 ECONNREFUSED,随后的 recv 也返回 -1、ECONNREFUSED,之后的 recv 才回到 EAGAIN。第 3 问的正确观察是未连接的两次 sendto 同样让 NoPorts 涨(各一次),这与「未连接就收不到 ICMP 错误」并不矛盾:报文回到了本机,只是没有已连接套接字可以挂这个错误,痕迹只留在计数器里;connect 之后那次 send 让计数再涨一,而这次错误被记到了套接字上,于是 errno 才读得到 ECONNREFUSED。
回答时要说清:为什么未连接的套接字拿不到 ECONNREFUSED,而连接过的可以?这对「服务器没启动」这类故障的诊断有什么影响?
接收缓冲区不够时 UDP 会静默丢包。写一对程序:udpsink PORT SECONDS 在指定时间内故意不读(先 sleep),之后再用 65536 字节缓冲区把队列里的数据报全部读出来并计数;udpsrc HOST PORT SIZE COUNT 连续发 COUNT 条 SIZE 字节的数据报,每条之间不睡眠。
按 SIZE 为 100、1400、4000 各跑一次 COUNT 为 5000,SO_RCVBUF 用 setsockopt 设成 4096,记录:
- 发送端
sendto有多少次返回失败?失败时errno是什么?它在什么时刻开始失败? - 接收端实际收到多少条?
/proc/net/snmp的Udp:行里InErrors与RcvbufErrors各增加了多少?两者相等吗? SIZE为 4000 时,sendto的返回值是 4000 还是 4008?为什么这里有一个容易弄错的差值?- 把
SO_RCVBUF改成 262144 后重跑 1400 字节那组。丢包数下降了多少?setsockopt设的值和getsockopt读回的值为什么不一样?
验收:第 1 问中 sendto 在本机回环上基本都成功(返回的是传进去的长度),失败出现在接收端而不是发送端——UDP 的排队发生在接收队列里。第 2 问里 InErrors 与 RcvbufErrors 的增量相等,都等于「发了 5000 条、真正收到 N 条」的差额,两个计数在这里指同一件事;InDatagrams 的增量与 recvfrom 成功返回的条数相等,说明它数的是数据报而不是 IP 分片。第 3 问里 sendto 的返回值等于负载长度 4000,而 UDP 首部里写的长度是 4008,差的是 8 字节 UDP 首部。第 4 问里 getsockopt 读回的值约为设置值的两倍,这是 Linux 把内核记账开销算进去的结果,设置值与读回值本来就不要求相等。
最后一问:既然 UDP 会这样静默丢包,应用层要怎么知道「对面收到了多少」?给出两种做法(一种靠协议自己带序号,一种靠应用层回执),各说一句适用场景。