4. UDP通信模型:UDP特点、sendto()/recvfrom()、UDP广播与组播

聊到UDP,很多人的第一反应是「不可靠」。没错,它确实不保证数据能到达,也不保证顺序。但我想说——UDP不是TCP的简化版,它们是两种完全不同的设计哲学。

我早年做一个实时音视频项目,团队里有人坚持要用TCP,觉得可靠。结果呢?网络一抖,视频卡成PPT。后来换成UDP,配合前向纠错,效果反而好得多。说白了,UDP是为「快」而生的,它把可靠性交给了应用层自己决定。

4.1 UDP的核心特点

UDP全称是用户数据报协议。它有几个关键特征,我一个个说。

  • 无连接:发送数据前不需要握手,直接扔。节省了建立连接的时间。
  • 不可靠:数据可能丢、可能乱序、可能重复。UDP不负责重传。
  • 面向报文:应用层给UDP一个完整报文,UDP原封不动地发出去。不会像TCP那样拆成小段。
  • 无拥塞控制:网络再堵,UDP照样发。这也是实时应用选它的原因。
  • 头部开销小:UDP头部只有8字节,TCP是20字节起步。

核心理解:UDP的「不可靠」不是缺陷,而是一种设计取舍。它把控制权交给你,让你根据业务场景决定怎么做。

我遇到过不少新手,一上来就问「UDP怎么保证可靠?」。我的回答是:如果你需要可靠,用TCP;如果你需要低延迟,用UDP,然后在应用层自己加确认和重传。比如QUIC协议就是这么干的。

4.2 sendto() 和 recvfrom() 详解

UDP的收发函数和TCP不太一样。TCP用 send() 和 recv(),因为连接已经建立好了。UDP没有连接的概念,所以每次发送都要指定目标地址,接收时也要知道数据从哪来的。

先看 sendto() 的原型:

#include <sys/types.h>
#include <sys/socket.h>

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

参数说明:

  • sockfd:套接字描述符
  • buf:要发送的数据缓冲区
  • len:数据长度
  • flags:一般填0,特殊场景用 MSG_DONTWAIT 等
  • dest_addr:目标地址结构体
  • addrlen:地址结构体长度

再看 recvfrom():

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

注意 src_addr 和 addrlen 是输出参数。调用后,你能知道数据是谁发来的。这在服务器端特别有用——你可以根据来源地址做不同的处理。

我的习惯:写UDP服务端时,我会把 recvfrom() 的 src_addr 保存下来,后续回复时直接用。省得每次都要查表。

来个简单的例子,一个UDP回显服务器:

// UDP回显服务器
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main() {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return 1;
    }

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(8888);

    if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind");
        return 1;
    }

    char buf[1024];
    struct sockaddr_in client_addr;
    socklen_t addr_len = sizeof(client_addr);

    while (1) {
        int n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0,
                         (struct sockaddr*)&client_addr, &addr_len);
        if (n > 0) {
            buf[n] = '\0';
            printf("收到来自 %s:%d 的消息: %s\n",
                   inet_ntoa(client_addr.sin_addr),
                   ntohs(client_addr.sin_port),
                   buf);
            // 原样返回
            sendto(sockfd, buf, n, 0,
                   (struct sockaddr*)&client_addr, addr_len);
        }
    }

    close(sockfd);
    return 0;
}

这段代码很简单,但有几个点我想提醒你:

  • recvfrom() 的最后一个参数是 socklen_t *,调用前一定要初始化长度。我见过有人忘了初始化,结果 recvfrom() 一直返回 -1。
  • sendto() 的 addrlen 是传值,不是传指针。别搞混了。
  • UDP的 recvfrom() 一次只能读一个数据报。如果缓冲区不够大,多余的数据会被丢弃。

避坑指南:我曾经在一个项目中,UDP缓冲区设得太小,导致大报文被截断。客户端收到乱码,排查了半天才发现是缓冲区问题。建议UDP缓冲区至少设成65535字节,这是UDP报文的最大长度。

4.3 UDP广播

广播是UDP特有的能力。TCP没有广播,因为TCP是点对点的。广播允许你向同一个网络内的所有主机发送数据。

广播地址通常是 255.255.255.255(受限广播)或者子网广播地址(如 192.168.1.255)。

要发送广播,需要做两件事:

  1. 设置套接字选项 SO_BROADCAST
  2. 目标地址填广播地址

代码示例:

int broadcast = 1;
setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast));

struct sockaddr_in broadcast_addr;
memset(&broadcast_addr, 0, sizeof(broadcast_addr));
broadcast_addr.sin_family = AF_INET;
broadcast_addr.sin_addr.s_addr = inet_addr("192.168.1.255");
broadcast_addr.sin_port = htons(9999);

char *msg = "Hello, everyone!";
sendto(sockfd, msg, strlen(msg), 0,
       (struct sockaddr*)&broadcast_addr, sizeof(broadcast_addr));

接收端不需要特殊设置,只要绑定到对应端口,就能收到广播消息。

实际应用:广播常用于服务发现。比如你打开一个局域网游戏,客户端发个广播「有人吗?」,服务器收到后回复自己的地址。我做过一个设备发现协议,就是用UDP广播实现的,简单高效。

但广播有个问题——它会打扰到所有主机。即使主机不关心这个广播,网卡也要把数据包传给内核处理。所以广播不适合大规模网络。

4.4 UDP组播

组播(多播)解决了广播的痛点。它允许你只向「订阅」了某个组的主机发送数据。其他主机完全不受影响。

组播地址范围是 224.0.0.0 到 239.255.255.255。其中 224.0.0.1 是所有主机的组地址,224.0.0.2 是所有路由器的组地址。

发送组播和发送普通UDP差不多,只是目标地址换成组播地址:

struct sockaddr_in mcast_addr;
memset(&mcast_addr, 0, sizeof(mcast_addr));
mcast_addr.sin_family = AF_INET;
mcast_addr.sin_addr.s_addr = inet_addr("224.0.0.100");
mcast_addr.sin_port = htons(8888);

char *msg = "组播消息";
sendto(sockfd, msg, strlen(msg), 0,
       (struct sockaddr*)&mcast_addr, sizeof(mcast_addr));

接收组播需要加入组。用 setsockopt() 设置 IP_ADD_MEMBERSHIP:

struct ip_mreq mreq;
mreq.imr_multiaddr.s_addr = inet_addr("224.0.0.100");
mreq.imr_interface.s_addr = htonl(INADDR_ANY);

setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));

退出组播时用 IP_DROP_MEMBERSHIP。

经验之谈:组播在视频会议、股票行情推送等场景用得很多。我做过一个实时行情系统,用组播把数据推给所有订阅者。相比广播,组播节省了大量带宽,而且不会影响无关主机。

不过组播也有坑:

  • 很多路由器默认关闭IGMP(组播管理协议),跨网段组播可能不通
  • WiFi环境下组播性能较差,因为无线网卡处理组播的方式比较特殊
  • 组播是UDP,所以丢包问题依然存在

4.5 知识体系总览

下面这张图总结了UDP通信模型的核心内容:

UDP通信模型知识体系 UDP通信模型 核心特点 无连接 不可靠 面向报文 核心API sendto() recvfrom() 通信模式 单播 广播/组播 适用场景:实时音视频、游戏、DNS查询、服务发现、流媒体 选择UDP,就是选择「快」和「灵活」,把可靠性交给应用层

4.6 总结与建议

UDP看似简单,但用好它并不容易。我总结几条经验:

  • 不要试图让UDP变得像TCP。如果你需要重传、保序、拥塞控制,直接用TCP。UDP的优势在于低延迟和灵活性。
  • 注意MTU问题。以太网MTU是1500字节,UDP数据报超过这个值会被分片。分片会导致性能下降,而且只要一片丢了,整个报文就废了。我建议应用层报文控制在1400字节以内。
  • 广播和组播要谨慎使用。广播会打扰所有主机,组播需要网络设备支持。生产环境用之前,先确认网络拓扑。
  • UDP缓冲区要设大。Linux默认的UDP接收缓冲区可能只有几十KB,高并发场景下很容易丢包。用 setsockopt() 调大 SO_RCVBUF。

最后提醒:我曾经在生产环境遇到过UDP端口被占满的问题。原因是某个客户端疯狂发数据,服务端来不及处理,缓冲区满了就丢包。后来加了应用层流控才解决。记住,UDP没有拥塞控制,你得自己管好流量。

好了,UDP通信模型就聊到这儿。广播和组播的代码你最好自己敲一遍,光看是记不住的。遇到问题别慌,先检查 setsockopt() 有没有设置对,地址有没有填错——我当年就是这么过来的。

可可精品资料库