3、UDP Socket编程实战:UDP服务端创建、UDP客户端创建、数据收发(sendto/recvfrom)、实战:简易聊天室

聊完TCP,咱们来看看UDP。说实话,很多人觉得UDP比TCP简单,不就是发个数据包嘛。嗯,从API数量上看确实少,但真要写出一个能上生产环境的UDP服务,坑一点都不少。

我记得刚带团队那会儿,有个同事写了个UDP日志收集器,测试环境跑得挺好,一上线就丢包。查了半天,发现是接收缓冲区没调,默认64KB根本扛不住业务峰值。从那以后,我养成了一个习惯——任何UDP服务上线前,先确认内核缓冲区大小。

3.1 UDP与TCP的核心差异

先看一张对比表,把UDP和TCP的区别理清楚。你面试时如果被问到「为什么用UDP不用TCP」,这张表就是你的答题框架。

对比维度 TCP UDP
连接性 面向连接(三次握手) 无连接
可靠性 可靠传输、重传、有序 不可靠,可能丢包、乱序
数据边界 字节流,无边界 报文边界,一次recvfrom对应一次sendto
传输效率 较低(头部20字节+控制开销) 较高(头部8字节)
应用场景 文件传输、网页、邮件 音视频、DNS、游戏、日志

说白了,UDP就是「发了就不管」。你想想看,视频通话时偶尔卡一帧,用户能接受;但要是为了等丢包重传导致画面卡死,那才叫灾难。这就是UDP的生存空间。

3.2 UDP服务端创建流程

UDP服务端的创建比TCP简单得多——不需要listen,不需要accept。核心就三步:创建socket、bind端口、循环recvfrom。

我个人的习惯是,在bind之前先设置socket为非阻塞,然后单独用一个线程做recvfrom循环。这样主线程还能做其他事情,比如监控或者动态配置加载。

核心API速览

  • socket(AF_INET, SOCK_DGRAM, 0) — 创建UDP套接字
  • bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) — 绑定端口
  • recvfrom(sockfd, buf, len, 0, (struct sockaddr*)&cliaddr, &clilen) — 接收数据,同时获取客户端地址
  • sendto(sockfd, buf, len, 0, (struct sockaddr*)&cliaddr, clilen) — 发送数据到指定客户端
// UDP服务端核心代码片段
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("socket");
    exit(1);
}

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

if (bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0) {
    perror("bind");
    close(sockfd);
    exit(1);
}

char buf[1024];
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);

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

我曾经踩过的坑:recvfrom的最后一个参数clilen必须初始化为sizeof(struct sockaddr_in)。有一次我忘了初始化,结果recvfrom一直返回-1,查了半天才发现是长度参数为0,内核根本写不进去地址。

3.3 UDP客户端创建流程

UDP客户端更简单——甚至可以不bind。你直接sendto,内核会自动分配一个临时端口。但如果你需要服务端主动给你发消息(比如聊天室场景),那就必须bind一个固定端口。

我建议客户端也bind,原因有两个:

  • 端口可预测:方便调试和防火墙配置
  • 接收响应:服务端回复时,需要知道往哪个端口发
// UDP客户端核心代码
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);

char *msg = "Hello, UDP Server!";
sendto(sockfd, msg, strlen(msg), 0,
       (struct sockaddr*)&servaddr, sizeof(servaddr));

char buf[1024];
struct sockaddr_in fromaddr;
socklen_t fromlen = sizeof(fromaddr);
int n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0,
                 (struct sockaddr*)&fromaddr, &fromlen);
if (n > 0) {
    buf[n] = '\0';
    printf("服务端回复: %s\n", buf);
}
close(sockfd);

小技巧:UDP的sendto和recvfrom默认是阻塞的。如果你不想让recvfrom一直等,可以用setsockopt设置超时,或者用fcntl设置为非阻塞。我个人偏好非阻塞+epoll/poll的方式,这样能统一管理多个socket。

3.4 数据收发核心机制

UDP的数据收发有一个关键特性——报文边界。你sendto一次,对端recvfrom一次,数据就是完整的一包。不会像TCP那样出现粘包问题。

但这也意味着:如果recvfrom的缓冲区小于发送的数据包,多余的数据会被直接丢弃。嗯,这就是UDP的「截断」问题。

场景 行为 建议
缓冲区 >= 数据包 正常接收 缓冲区设为MTU的整数倍(如1472)
缓冲区 < 数据包 数据截断,剩余丢弃 应用层加长度头,分片重组
多个数据包到达 一次recvfrom只取一个 循环recvfrom直到EAGAIN

你想想看,如果服务端缓冲区设成1024字节,客户端发了个1500字节的包,那服务端只能收到前1024字节,后面476字节就丢了。而且UDP不会通知你丢了——这就是不可靠的体现。

3.5 实战:简易聊天室

好,理论说完了,咱们动手写个简易聊天室。这个聊天室的设计思路是:

  • 服务端作为消息中转站,接收所有客户端的消息,然后广播给所有人
  • 客户端可以发送消息,也能实时接收其他用户的消息
  • 使用UDP,不保证消息顺序,但追求低延迟

下面这张图展示了聊天室的整体架构:

UDP简易聊天室架构图 UDP 聊天服务端 端口 8888 · 消息广播 客户端 A 192.168.1.10:40001 客户端 B 192.168.1.11:40002 客户端 C 192.168.1.12:40003 工作流程: 1. 客户端A发送消息到服务端(8888端口) 2. 服务端记录客户端A的地址,并广播给所有已注册客户端 3. 客户端B和C收到消息,显示在界面上

服务端的核心逻辑是维护一个客户端列表。每当收到新消息,就把发送者地址加入列表,然后遍历列表给所有人发一遍。注意,这里有个细节——不要给自己发,否则客户端会收到自己发的消息回声。

// 聊天室服务端核心逻辑(伪代码)
std::vector<struct sockaddr_in> clients;

while (1) {
    char buf[2048];
    struct sockaddr_in cliaddr;
    socklen_t clilen = sizeof(cliaddr);
    int n = recvfrom(sockfd, buf, sizeof(buf), 0,
                     (struct sockaddr*)&cliaddr, &clilen);
    if (n <= 0) continue;

    buf[n] = '\0';

    // 新客户端注册
    bool exists = false;
    for (auto &c : clients) {
        if (c.sin_addr.s_addr == cliaddr.sin_addr.s_addr &&
            c.sin_port == cliaddr.sin_port) {
            exists = true;
            break;
        }
    }
    if (!exists) {
        clients.push_back(cliaddr);
        printf("新客户端加入: %s:%d\n",
               inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port));
    }

    // 广播消息(排除发送者自己)
    for (auto &c : clients) {
        if (c.sin_addr.s_addr == cliaddr.sin_addr.s_addr &&
            c.sin_port == cliaddr.sin_port) {
            continue;
        }
        sendto(sockfd, buf, n, 0,
               (struct sockaddr*)&c, sizeof(c));
    }
}

我曾经踩过的坑:广播时如果客户端已经断开(比如异常退出),sendto不会报错——UDP没有连接检测机制。结果就是服务端一直往一个不存在的地址发数据,浪费带宽。解决方案是加心跳机制:客户端每隔5秒发一个心跳包,服务端如果连续3次没收到,就从列表里移除。

客户端的实现相对简单:一个线程负责从标准输入读取消息并sendto,另一个线程负责recvfrom并打印。这里要注意的是,recvfrom线程需要设置为分离状态,避免主线程退出时资源泄漏。

// 客户端接收线程
void *recv_thread(void *arg) {
    int sockfd = *(int *)arg;
    char buf[2048];
    struct sockaddr_in fromaddr;
    socklen_t fromlen = sizeof(fromaddr);

    while (1) {
        int n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0,
                         (struct sockaddr*)&fromaddr, &fromlen);
        if (n > 0) {
            buf[n] = '\0';
            printf("\n[%s:%d] %s\n> ",
                   inet_ntoa(fromaddr.sin_addr),
                   ntohs(fromaddr.sin_port),
                   buf);
            fflush(stdout);
        }
    }
    return NULL;
}

小技巧:客户端的recvfrom和printf可能会产生输出交错。我习惯在打印消息前加一个换行符,然后打印完再输出提示符"> ",这样界面看起来整洁一些。另外,记得用fflush(stdout)强制刷新,否则消息可能积在缓冲区里不显示。

3.6 本章小结

UDP编程的核心就四个API:socket、bind、sendto、recvfrom。但真正写出健壮的服务,需要考虑缓冲区大小、心跳机制、客户端管理、消息边界等问题。

我个人觉得,UDP比TCP更考验工程师的「防御性编程」能力——因为协议层不帮你做任何保障,所有可靠性都得自己在应用层实现。但反过来,这也给了你最大的灵活性。你想想看,TCP的重传策略是内核写死的,而UDP上你可以自己决定:丢包了是重传、还是忽略、还是用FEC前向纠错?

这就是UDP的魅力——简单,但不简陋。