1. 从一个实际需求说起:为什么要手写UDP客户端
很多人第一次接触网络编程,都是从TCP开始的。三次握手、可靠传输、面向连接,这些概念听起来就让人安心。但真到了实际项目里,你会发现UDP出现的频率远比想象中高。游戏同步、音视频推流、设备发现、日志上报、DNS查询,这些场景背后几乎都是UDP在扛。
我最早写UDP客户端是为了做一个局域网设备发现的小工具。当时想得很简单:广播一个包出去,谁在线谁回我。结果第一版代码跑起来,要么收不到回应,要么程序卡死在recvfrom上不动。后来才明白,UDP的“无连接”不是省事,而是把很多TCP帮你处理的问题,全部甩给了应用层自己解决。
所以这篇内容,我想把C++写UDP客户端这件事从头到尾讲透。不是贴一段能跑的代码就完事,而是把为什么这么写、参数怎么定、坑在哪里都摊开说。适合已经会一点C++、想入门网络编程的朋友,也适合写过TCP但没认真碰过UDP的开发者。看完你至少能做到三件事:独立写出一个健壮的UDP客户端、知道怎么处理丢包和超时、能判断什么场景该用UDP而不是TCP。
UDP客户端和TCP客户端最大的区别在于:TCP的connect是真的建立连接,UDP的connect只是给socket绑定了一个默认对端地址,内核并不会发任何包出去。这个认知差,是后面很多行为差异的根源。
2. 核心原理拆解:UDP客户端到底在做什么
2.1 无连接不等于没有状态
UDP协议本身是无连接的,每个数据报独立发送,彼此之间没有顺序保证,也不保证到达。但“无连接”说的是协议层,不代表你的程序不需要维护状态。一个实用的UDP客户端,通常要自己维护这些东西:目标地址、超时计时、重传次数、序列号、接收缓冲区。
打个比方,TCP像打电话,拨通了双方建立通道,说话有先后顺序,对方没听清会自动重说。UDP像寄明信片,你写好地址投进邮筒就完事了,到不到、什么时候到、顺序对不对,全靠你自己想办法。所以写UDP客户端,本质上是在应用层重新实现一部分TCP帮你做的事。
2.2 socket、bind、sendto、recvfrom 四件套
UDP客户端的核心API就四个:
socket(AF_INET, SOCK_DGRAM, 0):创建一个UDP套接字,SOCK_DGRAM就是数据报的意思。sendto():发送数据到指定地址,每次都要带目标地址。recvfrom():接收数据,同时能拿到发送方的地址。close():关闭套接字释放资源。
客户端一般不需要显式bind。操作系统会在第一次sendto时自动分配一个临时端口。这一点和TCP客户端一样。但如果你希望固定源端口(比如某些服务端要校验来源),那就得手动bind。
2.3 阻塞与非阻塞的选择逻辑
默认创建的socket是阻塞的。阻塞模式下,recvfrom会一直卡着等数据,直到有包来或者出错。这在简单场景够用,但一旦你要做超时控制、要同时处理多个任务,阻塞就成了灾难。
非阻塞模式通过fcntl设置O_NONBLOCK,recvfrom没数据时立刻返回-1并置errno为EAGAIN或EWOULDBLOCK。配合select、poll或epoll,就能实现“等一会儿没数据就干别的”。
我的建议是:只要你的客户端需要超时重传,就用非阻塞加IO多路复用。纯阻塞加SO_RCVTIMEO虽然也能做超时,但控制粒度粗,遇到信号中断还会出幺蛾子。
2.4 数据报边界:UDP最容易被忽略的特性
TCP是字节流,你发三次,对方可能一次收全,也可能分五次收。UDP不一样,一个sendto对应一个recvfrom,边界清晰。你发100字节,对方要么收到完整的100字节,要么啥都收不到,不会收到50字节。
这个特性带来两个后果。好处是消息天然分帧,不用自己搞粘包处理。坏处是单包大小有限制。理论上UDP包最大65507字节(65535减去IP头和UDP头),但实际网络里,超过MTU(通常1500字节)就会被分片,任何一片丢了整个包就废了。所以工程上一般把单包控制在1400字节以内,留出安全余量。
3. 动手实现:一个可用的UDP客户端完整流程
3.1 环境准备与头文件
Linux下写UDP客户端,需要这些头文件:
#include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <fcntl.h> #include <errno.h> #include <cstring> #include <string> #include <iostream>编译时不需要额外链接库,socket相关函数都在libc里。Windows下略有不同,需要winsock2.h并链接ws2_32,还要WSAStartup初始化。这里以Linux为主,Windows的差异我会在问题排查部分提。
3.2 创建socket与地址结构填充
int sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (sockfd < 0) { std::cerr << "socket create failed: " << strerror(errno) << std::endl; return -1; } sockaddr_in serverAddr{}; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8888); inet_pton(AF_INET, "192.168.1.100", &serverAddr.sin_addr);这里有几个细节值得说。htons把主机字节序转成网络字节序,端口必须转,否则大小端机器之间会错乱。inet_pton比老旧的inet_addr更安全,能正确处理错误输入。地址结构一定要清零初始化,sockaddr_in里有填充字段,不初始化可能带垃圾值。
3.3 设置超时的两种方式对比
方式一,用socket选项:
struct timeval tv; tv.tv_sec = 2; tv.tv_usec = 0; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));方式二,用非阻塞加select:
int flags = fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); fd_set readSet; FD_ZERO(&readSet); FD_SET(sockfd, &readSet); struct timeval tv{2, 0}; int ret = select(sockfd + 1, &readSet, nullptr, nullptr, &tv);| 对比项 | SO_RCVTIMEO | 非阻塞+select |
|---|---|---|
| 代码复杂度 | 低 | 中 |
| 超时精度 | 受系统调度影响 | 较精确 |
| 多socket管理 | 不支持 | 天然支持 |
| 信号中断处理 | 需额外判断 | select返回EINTR可重试 |
| 适用场景 | 简单单socket | 复杂多任务 |
我实测下来,如果只是发一个包等一个回应,SO_RCVTIMEO够用。但只要涉及重传、多目标、并发,直接上select,后面扩展省心。
3.4 发送与接收的完整代码骨架
const char* msg = "hello udp"; ssize_t sent = sendto(sockfd, msg, strlen(msg), 0, (sockaddr*)&serverAddr, sizeof(serverAddr)); if (sent < 0) { std::cerr << "sendto failed: " << strerror(errno) << std::endl; } char buffer[1500]; sockaddr_in fromAddr{}; socklen_t fromLen = sizeof(fromAddr); ssize_t recvd = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&fromAddr, &fromLen); if (recvd > 0) { buffer[recvd] = '\0'; std::cout << "recv: " << buffer << std::endl; }注意buffer留了一个字节给结束符,recvfrom返回的是实际收到的字节数,不保证是字符串,所以手动补\0。fromLen是值结果参数,传入时是缓冲区大小,返回时是实际地址长度。
3.5 加入重传与超时控制的完整逻辑
一个健壮的客户端不能发一次就完事。下面是我常用的重传骨架:
int maxRetry = 3; for (int i = 0; i < maxRetry; ++i) { sendto(sockfd, msg, strlen(msg), 0, (sockaddr*)&serverAddr, sizeof(serverAddr)); fd_set readSet; FD_ZERO(&readSet); FD_SET(sockfd, &readSet); struct timeval tv{1, 0}; int ret = select(sockfd + 1, &readSet, nullptr, nullptr, &tv); if (ret > 0) { ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&fromAddr, &fromLen); if (n > 0) { // 收到回应,处理并退出重传循环 break; } } else if (ret == 0) { std::cout << "timeout, retry " << i + 1 << std::endl; } else { if (errno == EINTR) continue; // 被信号打断,重试 std::cerr << "select error: " << strerror(errno) << std::endl; break; } }超时时间设1秒、重试3次,是我在局域网环境下的经验值。广域网建议超时2到3秒,重试2次就够,再多用户体验就崩了。重试间隔最好加一点随机抖动,避免多个客户端同时重传造成拥塞。
4. 进阶细节:让客户端真正能打
4.1 处理ICMP端口不可达
UDP有个坑:如果目标端口没人监听,对方主机会回一个ICMP端口不可达消息。但这个错误不一定会反馈到你的recvfrom上。在已连接(调用了connect)的UDP socket上,下一次send或recv可能返回ECONNREFUSED。在未连接的socket上,很多系统直接忽略。
所以别指望靠错误码判断服务端是否在线。正确做法是应用层自己做应答确认:发请求,等回应,超时就认为不可达。这也是为什么UDP协议设计里,可靠性必须由应用层保证。
4.2 广播与组播的地址写法
广播要把目标地址设成255.255.255.255或子网广播地址,并且打开广播选项:
int broadcastEnable = 1; setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcastEnable, sizeof(broadcastEnable));组播则是加入一个组:
struct ip_mreq mreq; inet_pton(AF_INET, "239.0.0.1", &mreq.imr_multiaddr); mreq.imr_interface.s_addr = htonl(INADDR_ANY); setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));组播地址范围是224.0.0.0到239.255.255.255。用组播做设备发现比广播更可控,因为可以跨网段路由(需要网络设备支持)。
4.3 缓冲区大小的取舍
SO_RCVBUF和SO_SNDBUF可以调整内核缓冲区。默认值通常几十KB,高吞吐场景下不够用:
int bufSize = 1024 * 1024; setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &bufSize, sizeof(bufSize));但要注意,设置的值内核会翻倍(用于内部记账),而且有上限,受/proc/sys/net/core/rmem_max限制。盲目调大不一定有效,得结合实际丢包率测试。我一般先看netstat -su里的接收错误统计,再决定要不要调。
4.4 用connect简化收发
UDP socket也可以调用connect,效果是绑定默认对端。之后可以用send和recv代替sendto和recvfrom,内核还会帮你过滤掉其他来源的包。这在只跟一个服务端通信时很方便,代码更干净,还能收到ICMP错误。
connect(sockfd, (sockaddr*)&serverAddr, sizeof(serverAddr)); send(sockfd, msg, strlen(msg), 0); recv(sockfd, buffer, sizeof(buffer), 0);代价是失去了接收任意来源的能力。如果你的客户端需要同时跟多个服务端说话,就别connect。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| sendto返回-1 | 地址非法/权限不足 | 看errno | 检查IP端口,广播需SO_BROADCAST |
| recvfrom一直阻塞 | 没设超时/服务端没回 | 加日志 | 设SO_RCVTIMEO或select |
| 收到自己发的包 | 广播回环 | 检查来源地址 | 比对自身IP,过滤 |
| 大包收不全 | 超过MTU被分片丢失 | 抓包看分片 | 单包控制在1400字节内 |
| 端口被占用 | 上次没正常关闭 | netstat查 | 设SO_REUSEADDR |
| select返回EINTR | 被信号打断 | 看errno | 循环重试 |
| Windows下编译失败 | 没初始化winsock | 看链接错误 | WSAStartup+链接ws2_32 |
5.2 抓包是排查UDP问题的第一手段
UDP没有连接状态,出问题时你根本不知道包发出去没有、对方回没回。这时候tcpdump就是救命稻草:
tcpdump -i any -n udp port 8888 -vv看三个东西:包有没有发出去、对方有没有回、回的地址端口对不对。我遇到过服务端回了,但客户端因为绑定了错误网卡收不到的情况,抓包一看就明白了。
5.3 几个我踩过的坑
坑一:结构体直接发送的字节序问题。很多人喜欢把结构体直接sendto出去,但结构体里的多字节整数是主机字节序,跨平台就乱套。正确做法是每个字段单独用htonl/htons转换,或者用序列化库。
坑二:忘记处理部分发送。UDP的sendto要么全发要么失败,一般不会部分发送。但如果包特别大被分片,底层可能出问题。所以还是控制包大小最稳。
坑三:多线程共享socket。UDP socket本身线程安全,但多个线程同时recvfrom会导致包被随机分给某个线程。要么加锁,要么每个线程一个socket。
坑四:close后立即重用端口。虽然UDP没有TIME_WAIT,但快速重启时端口可能还被占用。设SO_REUSEADDR能缓解。
提示:调试阶段把每次sendto和recvfrom的返回值、errno、来源地址都打出来,能省掉大量猜测时间。上线前再关掉日志。
5.4 性能优化的几个方向
如果客户端要发大量包,单线程sendto会成为瓶颈。可以考虑:批量发送(一次系统调用发多个包,用sendmmsg)、多线程分片发送、增大sndbuf。接收侧同理,recvmmsg能一次收多个包,减少系统调用开销。
但优化前先测量。我见过太多人上来就搞多线程,结果瓶颈其实在服务端。用perf或简单的计时打点,先找到真正的瓶颈再动手。
6. 写在最后的一点个人体会
UDP客户端看着简单,四个API就能跑起来,但真正写好、写稳,需要你对网络行为有直觉。我现在的习惯是:任何UDP代码,第一版一定带完整的日志和抓包验证,确认收发路径通了,再去做超时重传和性能优化。顺序反了,后面排查会非常痛苦。
另外,别把UDP当成“不可靠所以随便写”的借口。恰恰因为它不可靠,应用层的每一行代码都要为可靠性负责。什么时候该重传、重传几次、超时多久、包多大,这些参数没有标准答案,得结合你的网络环境和业务容忍度去调。多测、多抓包、多看errno,比看十篇教程都管用。