UDP循环收发实战:recvfrom/sendto的系统级避坑指南

📅 发布时间:2026/10/19 9:16:25
UDP循环收发实战:recvfrom/sendto的系统级避坑指南
1. 这不是“写个UDP程序”那么简单为什么循环recvfrom/sendto是网络编程的分水岭你打开任何一本讲网络编程的书UDP章节里总会出现这么两行代码recvfrom()和sendto()。初学者照着抄完跑通了“发一个收一个”的demo就以为自己掌握了UDP。我带过不少刚入行的开发者他们第一次独立写一个实时音视频转发模块时几乎全栽在同一个地方——程序跑着跑着就卡死、丢包率飙升、CPU飙到90%、日志里全是“Resource temporarily unavailable”……最后发现问题根本不在协议理解而在于那几行看似简单的循环逻辑。UDP循环读写本质是把操作系统内核的网络栈、用户态缓冲区、CPU调度策略、信号处理机制全部串起来的一根“高压线”。它不像TCP有连接状态帮你兜底也不像HTTP有框架自动管理生命周期。recvfrom()每次调用你都在和内核做一次“协商”我要从哪个socket、哪个缓冲区、读多少字节、把源地址存在哪sendto()则是在问我要往哪个IP端口、发多少字节、用哪个本地接口发出去。这中间没有“默认值”只有你亲手填满的每一个参数。而一旦放进while(1)循环这些微小的选择就会被放大成系统级的性能瓶颈或稳定性事故。这个标题里的“循环读取”和“循环发送”绝不是语法层面的for/while嵌套。它背后对应的是三类真实场景第一类是高吞吐服务端比如某高校实验室做的轻量级IoT设备网关要同时处理2000个传感器的UDP心跳包每秒收发超5万报文第二类是低延迟客户端比如某跨平台音视频Demo中采集线程必须在3ms内完成一帧音频的封装、加密、发送否则就会出现卡顿第三类是资源受限嵌入式环境比如某工业现场的边缘计算节点内存只有64MB连glibc都得裁剪recvfrom()的阻塞模式会直接让整个控制线程挂起。所以这篇文章不讲“怎么写hello world”而是带你拆开recvfrom()和sendto()的每一次系统调用看清楚内核返回的errno意味着什么、MSG_DONTWAIT标志到底省了多少上下文切换、struct sockaddr_in里那个sin_port字段为什么必须用htons()转换、sendto()失败时重试的边界条件该怎么设。我会用实测数据告诉你在千兆网卡上单线程UDP循环收发的理论极限是多少也会分享一个血泪教训——某次我把recvfrom()的缓冲区设成1500字节刚好是MTU结果在IPv6环境下因为扩展头导致实际报文超长所有数据都被静默丢弃排查了三天才定位到问题。如果你正在写一个需要稳定运行半年以上的UDP服务或者你的程序已经在线上出现了偶发性丢包、CPU异常升高、连接超时等问题那么接下来的内容就是你该立刻停下来细读的部分。这不是理论课这是我在过去十年里在十几个不同行业的项目中用服务器日志、Wireshark抓包、strace跟踪、perf火焰图反复验证过的实战经验。2. 循环结构设计阻塞、非阻塞与I/O多路复用的生死抉择很多人以为UDP循环就是while(1) { recvfrom(); sendto(); }然后纠结“要不要加sleep”。这种理解停留在脚本层面完全没触碰到网络编程的核心矛盾如何在“及时响应”和“不过度消耗资源”之间找到平衡点。我见过太多项目初期用阻塞模式开发测试环境一切正常一上生产就出问题——因为测试时只模拟了10个客户端而线上是5000个设备同时发包recvfrom()在队列空时无限等待整个线程就卡死了。2.1 阻塞模式最简单也最危险阻塞模式下recvfrom()会一直挂起当前线程直到有数据到达或发生错误。它的代码最简洁int sockfd socket(AF_INET, SOCK_DGRAM, 0); // ... bind() ... char buf[1024]; struct sockaddr_in src_addr; socklen_t addr_len sizeof(src_addr); while (1) { ssize_t n recvfrom(sockfd, buf, sizeof(buf)-1, 0, (struct sockaddr*)src_addr, addr_len); if (n 0) { buf[n] \0; printf(Received: %s from %s:%d\n, buf, inet_ntoa(src_addr.sin_addr), ntohs(src_addr.sin_port)); // 处理并sendto() } }表面看很干净但隐患极深。第一个问题是线程独占这个线程除了收包什么都干不了。如果业务逻辑需要定时上报状态、检查配置更新、写日志全得另起线程线程间通信又引入锁和竞态。第二个问题是超时失控当网络抖动导致某个包迟迟不来recvfrom()就永远卡住整个服务不可用。第三个问题是资源浪费在低流量时段线程频繁唤醒-挂起上下文切换开销远超数据处理本身。我参与过一个智能电表集抄系统最初用的就是纯阻塞模式。上线后发现凌晨2点用电低谷期服务器CPU使用率反而比白天还高5%。用perf record -e sched:sched_switch抓下来一看全是线程在recvfrom和futex_wait之间疯狂切换——因为每秒只有不到1个包但线程每毫秒都在被调度器唤醒检查一次纯粹是“用CPU换等待”。2.2 非阻塞模式主动权交还给程序员非阻塞模式通过fcntl(sockfd, F_SETFL, O_NONBLOCK)设置recvfrom()不再挂起而是立即返回。有数据就返回字节数没数据就返回-1并把errno设为EAGAIN或EWOULDBLOCK。这看起来更可控但代价是程序员必须自己实现“等待”逻辑。fcntl(sockfd, F_SETFL, O_NONBLOCK); while (1) { ssize_t n recvfrom(sockfd, buf, sizeof(buf)-1, 0, (struct sockaddr*)src_addr, addr_len); if (n 0) { // 正常处理 } else if (n -1) { if (errno EAGAIN || errno EWOULDBLOCK) { // 缓冲区空可以做其他事比如检查定时器 usleep(1000); // 等1ms再试 } else { // 真正的错误如socket关闭 perror(recvfrom); break; } } }这里的关键陷阱在于usleep()的时长选择。我试过100μs、1ms、10ms三种间隔100μs导致CPU占用率70%因为太频繁地轮询10ms又导致平均延迟增加8ms在实时性要求高的场景如远程控制指令无法接受最终选定1ms是经过200小时压力测试后在CPU占用15%和P99延迟3ms之间找到的平衡点。但要注意usleep()本身也有精度问题在某些内核版本下实际休眠时间可能是设定值的2倍所以更稳妥的做法是用clock_gettime(CLOCK_MONOTONIC, ts)记录时间戳手动计算是否该继续轮询。非阻塞模式真正的价值不在于“不卡住”而在于把I/O等待变成了可插拔的业务逻辑。比如在音视频Demo中我可以这样设计收到RTP包 → 解码 → 推入播放队列如果recvfrom()返回EAGAIN → 检查播放队列是否为空为空则生成静音帧填充同时检查采集线程是否有新帧 → 有则编码发送这样网络I/O、音视频编解码、播放渲染就跑在同一个线程里避免了线程切换开销也消除了多线程同步的复杂度。2.3 I/O多路复用高并发场景的终极方案当单机需要处理成百上千个UDP socket比如做NAT穿透、STUN服务器、游戏大厅服非阻塞轮询就力不从心了。此时必须上select()、poll()或epoll()。它们的核心思想是让内核替你监控多个fd的状态只在有事件发生时才通知你。以epoll为例Linux首选int epfd epoll_create1(0); struct epoll_event ev, events[64]; ev.events EPOLLIN; ev.data.fd sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, ev); while (1) { int nfds epoll_wait(epfd, events, 64, 1000); // 最多等1秒 for (int i 0; i nfds; i) { if (events[i].data.fd sockfd events[i].events EPOLLIN) { // socket有数据可读 ssize_t n recvfrom(sockfd, buf, sizeof(buf)-1, MSG_DONTWAIT, (struct sockaddr*)src_addr, addr_len); // 处理... } } // 在这里可以安全地执行其他任务比如清理过期连接、写监控指标 }注意两个细节第一epoll_wait()的timeout设为1000ms不是0也不是-1。设为0是纯轮询设为-1是永久阻塞1000ms既能保证及时响应大部分UDP包处理在毫秒级又给了业务逻辑执行的时间窗口。第二recvfrom()里加了MSG_DONTWAIT标志这是双重保险——即使socket被意外设回阻塞模式也不会卡死。我做过对比测试在单核虚拟机上用epoll管理1000个UDP socket每秒处理5万报文CPU占用率稳定在35%而用非阻塞轮询同样数量的socketCPU直接飙到95%因为recvfrom()失败次数太多usleep()调用过于频繁。epoll的优势在于内核用红黑树管理fd时间复杂度是O(log n)而轮询是O(n)。提示select()和poll()有fd数量限制通常是1024且每次调用都要把整个fd集合从用户态拷贝到内核态效率低于epoll。除非你必须兼容老系统否则无脑选epoll。2.4 混合模式生产环境的黄金组合现实中几乎没有项目只用一种模式。我们采用的是分层混合策略接入层用epoll监听主socket处理海量设备的注册、心跳、指令下发。每个设备分配一个唯一ID报文里携带ID用哈希表快速索引。业务层对高优先级指令如紧急停机单独开一个非阻塞socket用usleep(100)轮询确保P99延迟1ms。日志层用阻塞socket向syslog服务器发日志因为日志丢失可接受但不能阻塞主流程所以放在独立线程里。这种设计的好处是各层互不影响。某天我们遇到一个bugsyslog服务器宕机阻塞socket一直卡在sendto()但主业务线程完全不受影响只是日志停止上报运维能第一时间收到告警。3. recvfrom()核心参数解析那些被忽略的“魔鬼细节”recvfrom()的函数原型是ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);。表面上就6个参数但每个都藏着坑。我见过太多人因为其中一个参数设错导致程序在特定网络环境下必现bug。3.1 缓冲区大小len别迷信1500MTU不是铁律几乎所有教程都说“UDP最大报文是65535字节但实际用1500就够了因为以太网MTU是1500”。这话前半句对后半句大错特错。MTUMaximum Transmission Unit指的是链路层最大传输单元对以太网确实是1500字节但这只是IP层看到的“净荷”大小。而recvfrom()的len参数是告诉内核“你最多可以把多少字节的数据拷贝到我的buf里”。关键点来了len必须大于等于你期望接收的最大报文长度否则多余部分会被内核静默丢弃。比如你的设备发了一个1472字节的UDP payload1500-20-81472减去IP头20字节、UDP头8字节但你recvfrom()的len只设了1000那么后472字节就没了recvfrom()返回1000errno还是0你根本不知道数据被截断了。更隐蔽的问题在IPv6。IPv6基础头是40字节如果还有扩展头如路由头、分段头实际IP头可能达60字节以上。这时同样的payloadIPv6报文总长可能超过1500触发链路层分片。而很多交换机对分片报文处理不一致有的直接丢弃有的重组失败。我们曾在一个电力专网项目中遇到IPv4下一切正常切到IPv6后丢包率突然升到30%。用Wireshark抓包发现大量报文被标记为IPv6 Fragment而下游设备不支持重组。解决方案是在应用层强制把UDP payload控制在1280字节以内IPv6最小MTU并在recvfrom()时把len设为1320留40字节给IPv6头。我的经验是len设为65536是最稳妥的但要考虑内存成本。如果内存紧张至少设为min(65536, 期望最大报文长度 60)。对于IoT设备通常1280足够对于音视频建议4096起步。3.2 地址结构src_addr和addrlen为什么必须初始化addrlensrc_addr用来接收发送方的IP和端口addrlen是它的长度。很多新手会这样写struct sockaddr_in src_addr; socklen_t addrlen; recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)src_addr, addrlen);编译能过但运行时大概率崩溃。因为addrlen是未初始化的野指针内核会往一个随机地址写入地址长度轻则覆盖栈上其他变量重则段错误。正确做法是struct sockaddr_in src_addr; socklen_t addrlen sizeof(src_addr); // 必须初始化 recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)src_addr, addrlen);这里还有个易错点addrlen在调用前是输入参数告诉内核src_addr有多大调用后是输出参数内核写入实际写入的地址长度。所以如果你用struct sockaddr_storage来兼容IPv4/IPv6addrlen必须初始化为sizeof(struct sockaddr_storage)而不是sizeof(struct sockaddr_in)。3.3 flags参数MSG_PEEK和MSG_TRUNC的实战价值flags参数常被设为0但两个标志位在调试和容错时极其有用MSG_PEEK“偷看一眼不拿走”。调用后数据还在内核缓冲区下次recvfrom()还能读到。这在协议解析时救命。比如你的UDP报文前4字节是包类型后跟变长数据。你可以先recvfrom(..., MSG_PEEK)读4字节根据类型决定后续如何处理再recvfrom()读完整包。避免了“读一半发现类型不对还得把数据拼回去”的麻烦。MSG_TRUNC“告诉我真实长度哪怕我buf不够”。当len小于实际报文长度时recvfrom()返回len但设置了MSG_TRUNC后它会返回实际报文总长度包括被截断的部分。这样你就能知道“哦这个包本来是2000字节我buf太小了得扩容”。我们在一个固件升级服务中用到了这个客户端发升级包服务端先recvfrom(..., MSG_TRUNC)获取总长度再malloc对应内存最后recvfrom()读全。这两个标志可以组合使用MSG_PEEK | MSG_TRUNC既能预览头部又能获知全长是高级协议处理的标配。注意MSG_TRUNC只在AF_INET和AF_INET6下有效Unix domain socket不支持。3.4 错误码详解从errno读懂网络真相recvfrom()失败时errno是唯一的线索。下面这些错误码我按出现频率排序并给出应对策略errno含义常见原因应对措施EAGAIN/EWOULDBLOCK非阻塞模式下缓冲区空正常现象不是错误继续轮询或等待epoll事件EINTR系统调用被信号中断收到SIGCHLD、SIGHUP等必须重试recvfrom()未完成ENOBUFS内核缓冲区满流量突增来不及处理降低发送速率检查业务逻辑是否阻塞EFAULTbuf或src_addr地址非法指针越界、未初始化用valgrind检查内存访问EINVALaddrlen值非法小于sizeof(sockaddr_in)检查addrlen初始化特别强调EINTR这是C语言系统调用的经典陷阱。当你signal(SIGCHLD, sigchld_handler)捕获子进程退出信号时recvfrom()可能正在执行信号一来就中断返回-1errnoEINTR。如果不重试这个包就永远丢失了。正确写法是ssize_t n; do { n recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)src_addr, addrlen); } while (n -1 errno EINTR); if (n 0) { // 正常处理 } else if (n -1) { // 其他错误 }这个do-while循环是所有健壮网络程序的标配。我见过三个项目因此出问题一个金融行情推送服务因EINTR未重试导致某次行情跳空时漏掉关键报价一个无人机飞控EINTR导致遥控指令丢失差点撞墙还有一个医疗设备EINTR让心跳包延迟超阈值被误判为设备离线。4. sendto()实操要点发不出去先查这五个环节如果说recvfrom()是“听”sendto()就是“说”。但UDP的“说”有个致命特点它不保证对方听到甚至不保证自己说成功了。sendto()返回成功只代表数据已进入内核发送队列不代表网卡已发出更不代表对方收到。这也是UDP编程中最容易产生幻觉的地方——看到sendto()返回正数就以为万事大吉。4.1 目标地址dest_addr端口字节序是道生死门sendto()的dest_addr参数必须是网络字节序大端。但人的直觉是主机字节序小端所以必须用htons()转换端口。这个错误极其隐蔽因为本地测试时客户端和服务端在同一台机器端口字节序错也没关系——发给自己自己能认出来。但一旦部署到真实网络就100%失败。struct sockaddr_in dest_addr; memset(dest_addr, 0, sizeof(dest_addr)); dest_addr.sin_family AF_INET; dest_addr.sin_port htons(8080); // 必须 dest_addr.sin_addr.s_addr inet_addr(192.168.1.100); // 错误写法dest_addr.sin_port 8080; // 本地字节序网络上收不到我遇到过最惨的一次一个团队花了两周调试“为什么测试环境OK生产环境收不到包”。最后发现他们在sendto()前用printf(%d, dest_addr.sin_port)打印端口看到输出是8080就以为没问题。殊不知printf打印的是整数而htons(8080)在x86上是0x1f90十进制正是8080所以打印看不出问题但网络上对方收到的是乱码端口。解决方案是用Wireshark抓包看UDP头里的目的端口是不是你期望的值。4.2 发送缓冲区SO_SNDBUF不是越大越好内核为每个socket维护一个发送缓冲区默认大小通常是212992字节约208KB。当sendto()数据量超过缓冲区剩余空间时阻塞模式会挂起非阻塞模式返回EAGAIN。很多人觉得“缓冲区越大越好”于是setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, size, sizeof(size))把缓冲区设到几MB。这是巨大误区。过大的发送缓冲区会导致内存浪费每个socket都占几MB1000个socket就是几个GB。延迟升高内核会尽量填满缓冲区再发导致小包积压。在实时音视频中这叫“缓冲区膨胀”BufferbloatP99延迟从5ms飙升到200ms。丢包更严重当网络拥塞时大缓冲区会让TCP友好型流量如HTTP被压制UDP反而更激进加剧拥塞。我们的实践是根据业务QoS需求动态调整。对于心跳包每30秒1次100字节SO_SNDBUF设为4096对于固件升级包单次几MB设为2MB并配合TCP_NODELAY类似逻辑虽然UDP没有Nagle算法但应用层可以做包合并对于音视频流保持默认值靠应用层FEC前向纠错和重传来弥补。4.3 sendto()返回值成功≠送达失败≠没发sendto()返回值有三种情况0成功写入内核缓冲区的字节数。注意它可能小于你传入的len比如你传1000字节返回800说明缓冲区只剩800字节空间剩下200字节没写进去。这时必须重试否则数据丢失。0合法但罕见。表示发送了0字节通常发生在目标地址是INADDR_ANY或::时。一般应视为错误处理。-1失败查errno。最常见的错误errno是EAGAIN/EWOULDBLOCK非阻塞模式下缓冲区满需重试。EACCES权限不足比如普通用户尝试绑定特权端口1024。EHOSTUNREACH目标主机不可达路由表里没有路径。EMSGSIZE报文太大超过路径MTU。此时应分片或改用TCP。提示sendto()失败时不要盲目重试。比如EHOSTUNREACH重试100次也没用应该记录日志并通知上层业务“目标不可达”由业务决定是降级还是告警。4.4 源地址绑定bind为什么有时必须显式bindUDP socket创建后默认是“未绑定”状态内核会自动选择一个临时端口ephemeral port。大多数客户端不需要关心这个sendto()时内核自动分配源端口。但以下场景必须bind()服务端必须绑定知名端口如53 DNS、67 DHCP否则客户端不知道往哪发。需要固定源端口比如防火墙策略要求只允许特定端口出站。多宿主主机一台机器有多个IP如eth0: 192.168.1.100, eth1: 10.0.0.100sendto()时内核按路由表选源IP但你可能想强制从eth1发。这时bind()到10.0.0.100:0即可。bind()还有一个隐藏作用启用端口复用。默认情况下bind()会报Address already in use。但加上SO_REUSEADDR选项多个进程就能绑定同一端口需配合SO_REUSEPORT在新内核。这在热升级时至关重要——新进程启动绑定同一端口旧进程优雅退出流量零中断。4.5 性能优化减少系统调用次数的硬核技巧sendto()是系统调用每次调用都有上下文切换开销。在高吞吐场景这是瓶颈。我们用了三个技巧批量发送把多个小包合并成一个大包发送。比如10个心跳包每个64字节合并成640字节的大包一次sendto()搞定。代价是增加了单包延迟但整体吞吐翻倍。sendmmsg()系统调用Linux 3.0支持一次调用发送多个消息。struct mmsghdr msgs[10];填10个msg_hdr和msg_lensendmmsg(sockfd, msgs, 10, 0)。实测在万兆网卡上比10次sendto()快40%。零拷贝发送用AF_XDP或DPDK绕过内核协议栈直接操作网卡DMA。这是终极方案但复杂度高只用于超低延迟场景如高频交易。对于我们大多数项目批量发送sendmmsg()是性价比最高的组合。在IoT网关项目中把100个设备的心跳包按目标IP分组每组用sendmmsg()发QPS从8000提升到12000CPU占用下降12%。5. 循环逻辑的完整实现与避坑指南从代码到线上稳定运行现在我们把前面所有知识点整合成一个生产可用的UDP循环收发模板。这不是玩具代码而是我在某工业物联网平台中实际部署的简化版。它包含了错误处理、日志、性能监控、优雅退出等所有要素。5.1 完整代码框架C语言#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/epoll.h #include sys/timerfd.h #include time.h #define MAX_EVENTS 64 #define RECV_BUF_SIZE 4096 #define SEND_BUF_SIZE 4096 typedef struct { uint32_t ip; uint16_t port; char data[SEND_BUF_SIZE]; size_t len; } udp_packet_t; // 全局状态 static int g_sockfd -1; static int g_epfd -1; static volatile sig_atomic_t g_running 1; // 信号处理 void signal_handler(int sig) { if (sig SIGINT || sig SIGTERM) { g_running 0; } } // 初始化socket int init_udp_socket(const char* ip, int port) { int sockfd socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0); if (sockfd 0) { perror(socket); return -1; } // 设置发送缓冲区 int sndbuf_size 1024 * 1024; // 1MB if (setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, sndbuf_size, sizeof(sndbuf_size)) 0) { perror(setsockopt SO_SNDBUF); close(sockfd); return -1; } // 绑定地址 struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.s_addr ip ? inet_addr(ip) : INADDR_ANY; if (bind(sockfd, (struct sockaddr*)addr, sizeof(addr)) 0) { perror(bind); close(sockfd); return -1; } return sockfd; } // 主循环 int main(int argc, char* argv[]) { if (argc 2) { fprintf(stderr, Usage: %s port\n, argv[0]); return 1; } int port atoi(argv[1]); g_sockfd init_udp_socket(NULL, port); if (g_sockfd 0) return 1; // 创建epoll g_epfd epoll_create1(0); if (g_epfd 0) { perror(epoll_create1); close(g_sockfd); return 1; } // 添加socket到epoll struct epoll_event ev; ev.events EPOLLIN; ev.data.fd g_sockfd; if (epoll_ctl(g_epfd, EPOLL_CTL_ADD, g_sockfd, ev) 0) { perror(epoll_ctl); close(g_sockfd); close(g_epfd); return 1; } // 注册信号处理 signal(SIGINT, signal_handler); signal(SIGTERM, signal_handler); char recv_buf[RECV_BUF_SIZE]; struct sockaddr_in src_addr; socklen_t src_len sizeof(src_addr); struct epoll_event events[MAX_EVENTS]; printf(UDP server started on port %d, press CtrlC to stop\n, port); while (g_running) { // 等待事件超时1秒期间可执行其他任务 int nfds epoll_wait(g_epfd, events, MAX_EVENTS, 1000); if (nfds 0) { if (errno EINTR) continue; // 被信号中断继续 perror(epoll_wait); break; } // 处理就绪事件 for (int i 0; i nfds; i) { if (events[i].data.fd g_sockfd events[i].events EPOLLIN) { // 有数据可读 ssize_t n recvfrom(g_sockfd, recv_buf, sizeof(recv_buf)-1, MSG_DONTWAIT, (struct sockaddr*)src_addr, src_len); if (n 0) { recv_buf[n] \0; printf(Received %zd bytes from %s:%d: %s\n, n, inet_ntoa(src_addr.sin_addr), ntohs(src_addr.sin_port), recv_buf); // 构造响应 char send_buf[SEND_BUF_SIZE]; snprintf(send_buf, sizeof(send_buf), ACK:%s, recv_buf); size_t send_len strlen(send_buf); // 发送响应 ssize_t sent sendto(g_sockfd, send_buf, send_len, 0, (struct sockaddr*)src_addr, src_len); if (sent ! (ssize_t)send_len) { if (sent 0) { if (errno EAGAIN || errno EWOULDBLOCK) { // 缓冲区满稍后重试实际项目中应入队列 fprintf(stderr, Send buffer full, drop packet\n); } else { perror(sendto); } } else { fprintf(stderr, Partial send: %zd/%zu\n, sent, send_len); } } } else if (n -1) { if (errno EAGAIN || errno EWOULDBLOCK) { // 正常无数据 } else if (errno EINTR) { // 被信号中断重试 i--; // 重新处理这个事件 continue; } else { perror(recvfrom); } } } } // 在这里可以执行周期性任务比如 // - 清理超时连接UDP无连接但可维护会话状态 // - 上报监控指标QPS、延迟分布 // - 检查配置热更新 } printf(Shutting down...\n); close(g_sockfd); close(g_epfd); return 0; }5.2 关键配置参数与调优建议这个模板不是“写完就跑”上线前必须根据你的硬件和网络环境调优。以下是我在不同项目中验证过的参数参数默认值推荐值说明RECV_BUF_SIZE4096IoT: 1280, 音视频: 8192匹配预期最大报文避免截断SO_SNDBUF~200KB1MB平衡内存和延迟实测1MB对QPS提升显著epoll_wait timeout1000ms100ms降低延迟但增加CPU需权衡MAX_EVENTS64128高并发时防止事件丢失MSG_DONTWAIT否是双重保险避免意外阻塞