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,不保证消息顺序,但追求低延迟
下面这张图展示了聊天室的整体架构:
服务端的核心逻辑是维护一个客户端列表。每当收到新消息,就把发送者地址加入列表,然后遍历列表给所有人发一遍。注意,这里有个细节——不要给自己发,否则客户端会收到自己发的消息回声。
// 聊天室服务端核心逻辑(伪代码)
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的魅力——简单,但不简陋。