现在的网络服务里人人都知道TCP面试也总被问TCP三次握手四次挥手但真到了某些场景你会发现UDP才是唯一的选择。我指的不是那种宏大叙事就是非常具体的问题游戏操作指令延迟、实时音视频流、传感器遥测数据凡是错过这一帧无所谓、迟来这一帧就完蛋的场景TCP那套可靠重传机制反而成了累赘。Linux系统编程里UDP编程其实比很多人想象的更轻巧它没有连接的概念用一个socket既能发也能收C/S模型的结构也一目了然服务端bind端口收包客户端直接往目标IP和端口丢数据。这篇文章我从socket API讲到完整C/S模型实战最后把超时、缓冲区、丢包这些实战中必然踩的坑一并梳理清楚适合已经把TCP基本流程走通、想补上UDP这块短板的人。1. 先搞清楚UDP的脾气它到底适合干什么1.1 UDP和TCP到底差在哪很多人学完TCP再碰UDP最大的困惑是没有连接我怎么知道对方在线这个问题本身就说明你还在用TCP的思维理解UDP。UDP全称是用户数据报协议它做的事情只有一件把数据包装成数据报丢出去。至于数据能不能到、按什么顺序到、到了之后对方在不在它统统不管。用生活化的方式理解TCP像打电话先拨号、等待接通、确认对方听到、然后一句话一句话说说到一半断了还要重拨重讲UDP像寄明信片你写好地址扔进邮筒寄丢了你不知道对方收没收到你也不知道。但明信片便宜、快、不占线路寄一万张也不会有哪张卡在半路耽误后面几张。TCP为了可靠付出的代价是握手开销和排队等待UDP把这些全省了换来的是低延迟和简单到极致的实现。在Linux编程层面两者的差异更加直观TCP的socket要先listen再accept每个客户端对应一个连接fdread/write在一个稳定的数据流上进行UDP的socket创建完bind一下就能直接收数据发数据时目标地址写在sendto的参数里同一个socket可以和任意多个客户端通信。服务端不需要为每个客户端维护连接状态这是UDP在高并发场景下最核心的优势。1.2 什么场景非UDP不可聊到适用场景我见过最遗憾的情况是有人拿着UDP实现文件传输结果应用层重传、排序、流量控制写了一堆最后性能和TCP差不多代码量翻了三倍。这是典型的技术选型失误。UDP真正该出现的地方是那些丢包可容忍、延迟不可容忍的实时场景。首当其冲是音视频通话和直播。一个音频包晚到200毫秒比丢掉更让人崩溃TCP遇丢包重传会造成音频卡顿和堆积UDP丢就丢了下一包接着放听感反而连续。其次是游戏同步操作指令、位置坐标这类高频小包你希望越快到达越好中间丢掉几帧玩家根本感知不到。然后是物联网和监控场景设备每隔几秒上报一次温度或电量少一两条没关系但上报延迟会导致告警失灵。还有一个容易被忽视的场景是DNS查询。你现在访问任何网站第一步域名解析用的就是UDP。DNS查询本身请求响应都很小一条UDP报文就能装下如果每次解析都走TCP握手DNS服务器要崩溃。所以UDP不是TCP的替代品它是在快和稳不可兼得时选择快的那条路。1.3 C/S模型在UDP里的姿态C/S模型所有人都熟悉一个服务端多个客户端。在TCP里C/S模型是服务端主动监听、客户端挨个连接的星型结构到了UDP里这个模型被简化成了一个更松散的姿态——服务端只知道自己的IP和端口然后被动地收包收完按需回包客户端知道服务端的地址发起通信后原地等待或者继续发下一个包。重点在于UDP服务端不需要也不可能区分谁是谁的连接。recvfrom函数返回时你能拿到对方的IP和端口这些信息放在struct sockaddr_in结构体里。下次要给这个客户端回消息直接把结构体原封不动塞给sendto就行。这个地址跟着数据包走的特性让UDP服务端天然支持无状态通信也让你可以用最简单的单线程循环同时服务成千上万个客户端。不过也要提醒一句UDP的C/S模型里如果服务端先发出数据比如广播服务或消息推送而客户端没有先行通信客户端需要预先bind一个固定端口否则服务端不知道该把数据发给谁。这个细节在后面的实战代码里会体现出来。2. 动手前的准备工作API清单与关键参数2.1 socket不面向连接的打开方式Linux里创建UDP socket的代码只有一行但这行里的参数值得掰开讲清楚int sockfd socket(AF_INET, SOCK_DGRAM, 0);第一个参数AF_INET表示使用IPv4地址第二个参数SOCK_DGRAM是关键DGRAM就是数据报对应UDP协议不要和TCP的SOCK_STREAM搞混第三个协议参数填0即可因为SOCK_DGRAM已经唯一确定了UDP协议。如果你创建socket时用了SOCK_STREAM后面调用sendto会得到一个Transport endpoint is not connected之类的错误因为这个socket类型根本不认识无连接的发送方式。创建出的socket默认是阻塞模式。所谓阻塞是指你在客户端调用recvfrom等待服务端回包时如果数据一直没来程序会停在这一行不往下走。这种特性在调试阶段非常友好但在生产环境里你必须配合超时机制使用否则任何一个异常的网络抖动都会让你的进程挂死这节我放到后面详细展开。还有一个socket的隐藏机制容易踩坑UDP socket虽然是无连接的但每次收发数据时内核会自动为它分配一个临时端口只要你不显式bind客户端socket在用完close掉之后端口就释放了。服务端必须bind固定端口客户端则完全没有这个必要。2.2 bind、sendto、recvfromUDP三剑客服务端收数据的第一步是把socket和某个本机地址绑定起来这就是bind函数的工作。它接收三个参数socket描述符、指向sockaddr结构体的指针、结构体长度。这里有个典型的语法坑——sockaddr_in本机地址结构体与sockaddr的强转关系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); bind(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr));INADDR_ANY等于0.0.0.0意思是监听本机上所有网卡的指定端口。如果你只想让特定IP访问可以换成inet_addr(192.168.1.100)。bind之后socket就变成了一个收件箱任何发往本机该端口的数据报都会进入这个socket的接收队列。真正收发数据的操作是sendto和recvfrom它们的签名长得很像只是方向相反ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);recvfrom比recv多出的src_addr参数是用来接收发送方地址的。这个参数必须提前分配好内存初始化好长度否则拿不到对方的IP和端口服务端也就无法回包。实际开发中我习惯每次recvfrom之前都把addrlen重置成sizeof(struct sockaddr_in)否则前一次接收后内核会修改这个长度后续调用可能出现诡异问题。sendto函数里那个len参数是多少就发送多少UDP会把这整块数据当作一个数据报发出。服务端原样返回这段数据时sendto才能完整地把报文还给客户端。如果len大于网络能承载的报文大小数据会被分片传输对端可能需要重组这涉及MTU问题后面聊。2.3 字节序问题别让小端坑了你刚从TCPUDP双修的新手多半在字节序上栽过跟头。网络传输使用的是大端字节序高位字节在前而你的机器可能是小端低位字节在前。当你把端口号8888直接塞进sin_port时在x86机器上这串字节在小端内存里是0xB8 0x22通过网络发出去后对方读到的是0x22 0xB8端口号变成了8864收发的双方就各说各话了。解决办法是统一的转换函数htons把主机字节序转成网络字节序多用于端口htonl用于32位长整数多用于IP地址接收端则用ntohs和ntohl转回主机字节序。这组函数在Linux平台几乎是零成本的编译后就是一条字节交换指令。IPv4地址比较特殊它本质是一个32位整数比如192.168.1.100对应的十六进制是0xC0A80164存储时网络字节序要求先传192再传168再传1再传100所以设置地址时要用inet_addr或inet_aton来保证字节序正确。调试UDP程序时如果你发现发送方明明是8888端口接收方打印出来是8864不需要怀疑什么协议错误先查字节序转换。这也是我见过最多的一类低级错误排查时间通常超过十分钟。3. 完整实战回显服务器与客户端3.1 服务端代码详解理论说了一堆不如直接上路。我写了一个最典型的UDP回显服务器客户端发过来什么服务端原样发回去。麻雀虽小五脏俱全它的代码骨架和真实业务的服务端几乎一致——收包、解析地址、处理、回包。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_PORT 8888 #define BUFFER_SIZE 4096 int main() { int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { perror(socket create failed); exit(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(SERVER_PORT); if (bind(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind failed); close(sockfd); exit(1); } printf(UDP echo server running on port %d\n, SERVER_PORT); struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); char buffer[BUFFER_SIZE]; while (1) { memset(buffer, 0, BUFFER_SIZE); client_len sizeof(client_addr); ssize_t recv_len recvfrom(sockfd, buffer, BUFFER_SIZE, 0, (struct sockaddr *)client_addr, client_len); if (recv_len 0) { perror(recvfrom error); continue; } buffer[recv_len] \0; printf(recv %zd bytes from %s:%d: %s\n, recv_len, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), buffer); ssize_t send_len sendto(sockfd, buffer, recv_len, 0, (struct sockaddr *)client_addr, client_len); if (send_len 0) { perror(sendto error); } } close(sockfd); return 0; }这段代码里有两个细节值得单独说明。一是recvfrom的第二个参数buffer缓冲区大小设成了4096。UDP单包最大65507字节理论上你可以设一个更大的全局数组但我故意用4096因为实际业务里一个UDP包超过4096的情况很少大多是几百字节的控制消息而且缓冲区开得越大越容易让人忽视包长校验真收到一个超大包时你可能措手不及。二是recvfrom返回后我在buffer的末尾手动加了\0这是为了printf能正确打印字符串但注意它只是调试辅助真实网络中数据可能是二进制不能假定遇到\0就是结尾。写服务端时最容易被忽略的是while循环对错误处理的影响。我的代码里recvfrom出错后用了continue而不是直接退出这是服务端程序稳定运行的关键——一个网络抖动导致recvfrom返回错误服务端不应该就此崩溃。如果你写的服务端一收错包就exit线上一个错误包就能干掉整个进程。3.2 客户端代码详解客户端的写法和服务端不同它不需要bind只需要知道服务端的地址。我把客户端写成了交互模式用户从终端输入字符串回车发送收到回显后打印出来输入quit退出。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_PORT 8888 #define BUFFER_SIZE 4096 int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, Usage: %s server_ip\n, argv[0]); exit(1); } int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { perror(socket create failed); exit(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 inet_addr(argv[1]); server_addr.sin_port htons(SERVER_PORT); char send_buf[BUFFER_SIZE]; char recv_buf[BUFFER_SIZE]; socklen_t addr_len sizeof(server_addr); while (1) { printf( ); fflush(stdout); if (fgets(send_buf, BUFFER_SIZE, stdin) NULL) { break; } send_buf[strcspn(send_buf, \n)] \0; if (strcmp(send_buf, quit) 0) { break; } ssize_t n sendto(sockfd, send_buf, strlen(send_buf), 0, (struct sockaddr *)server_addr, addr_len); if (n 0) { perror(sendto error); continue; } memset(recv_buf, 0, BUFFER_SIZE); n recvfrom(sockfd, recv_buf, BUFFER_SIZE, 0, (struct sockaddr *)server_addr, addr_len); if (n 0) { perror(recvfrom error); continue; } recv_buf[n] \0; printf(server echo: %s\n, recv_buf); } close(sockfd); return 0; }客户端有几个隐藏知识点。一个是inet_addr这个函数它把点分十进制的IP字符串转换成网络字节序整数失败时返回INADDR_NONE也就是-1所以严谨的代码需要检查返回值。另一个是sendto和recvfrom不使用connect也能工作内核根据你传入的server_addr决定把数据发去哪里如果客户端不调用bind操作系统会在第一次sendto时自动分配一个512到65535之间的临时端口服务端回包时看的是这个临时端口。我建议初学者先跑通这个客户端和服务端的组合把服务端收包地址、客户端临时端口、回包是否成功这三个过程理解透再去做复杂的并发模型。很多人在UDP编程上的困惑就是不理解服务端怎么知道往哪回包答案就是recvfrom给的那些地址信息。3.3 编译、运行与验证两段代码分别存为udp_server.c和udp_client.c编译命令极其简单不需要额外链接任何库gcc -o udp_server udp_server.c gcc -o udp_client udp_client.c先在终端跑服务端./udp_server UDP echo server running on port 8888再开另一个终端跑客户端参数传本机回环地址./udp_client 127.0.0.1 hello server echo: hello world server echo: world回到服务端终端你会看到每次收包的记录recv 5 bytes from 127.0.0.1:38456: hello recv 5 bytes from 127.0.0.1:38456: world注意客户端的端口38456是系统自动分配的每次运行都可能不一样。如果你想验证多客户端场景可以再开一个终端跑第二个客户端服务端会正常回包且能正确区分是哪个客户端发来的。用netstat命令能看到两者都处于UDP状态netstat -unap | grep 8888这里我想强调一个测试技巧先用127.0.0.1回环地址跑通逻辑再换局域网IP测试最后再考虑跨主机场景。回环测试能排除网卡、防火墙、路由等外部干扰让你确认socket编程本身没有错误。如果回环测试都收不到数据问题一定出在代码本身。4. 进阶让UDP程序能扛住真实业务4.1 超时控制不要让客户端傻等前面写的客户端有个致命问题如果服务端宕机或网络断了recvfrom会一直阻塞在那一行客户端像死机了一样卡住。真实环境里这是绝对不能接受的。解决办法是给socket设置接收超时时间用setsockopt配合SO_RCVTIMEO选项struct timeval tv; tv.tv_sec 3; tv.tv_usec 0; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));设置之后recvfrom如果3秒内没有收到数据会返回-1同时errno被置为EAGAIN或EWOULDBLOCK。代码里要针对这个场景单独处理n recvfrom(sockfd, recv_buf, BUFFER_SIZE, 0, (struct sockaddr *)server_addr, addr_len); if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) { printf(recv timeout, no response from server\n); continue; } else { perror(recvfrom error); break; } }服务端也一样。如果服务端长时间没有收到任何客户端的数据阻塞在recvfrom上此时CtrlC都未必能立刻响应默认信号处理下进程会退出但有些复杂程序里信号被捕获后会等阻塞调用返回。设置超时后服务端可以定期醒来检查心跳、清理超时客户端、处理统计任务这是实现看起来总在做别的事的多任务循环的基础。超时设置还有一种姿势是设置SO_SNDTIMEO控制sendto阻塞。UDP的sendto通常不会长时间阻塞因为内核发送缓冲区满的时候它会等待但如果你频繁发大包也可能卡住。两边都设置超时是最稳妥的。4.2 缓冲区与丢包性能与可靠性的博弈UDP有一个和TCP大不相同的特性接收缓冲区溢出时新来的数据包会被内核直接丢弃而不是进入等待重传的队列。这对程序员来说是双刃剑——好处是永远不会让网络延迟拖垮应用坏处是当你的处理逻辑跟不上收包速度时数据静悄悄地丢失你根本查不到日志。Linux下查看和修改UDP收发缓冲区大小的命令是sysctl默认值通常不大sysctl net.core.rmem_default sysctl net.core.wmem_default如果业务确实需要更大的缓冲可以在代码里显式设置int rcvbuf_size 1024 * 1024; setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, rcvbuf_size, sizeof(rcvbuf_size));但我要泼一盆冷水靠加大缓冲区解决丢包问题治标不治本。当网络本身有大量丢包时缓冲区再大也无济于事。真正的可靠性需要在应用层建立机制——给每个包加序号接收方发现序号不连续就知道哪个包丢了关键数据需要应用层确认和重传UDP不帮你做这件事。很多初学UDP的人写到这里会问这不就是重新发明TCP吗问得好。如果你需要的是可靠性、按序交付、流量控制中的任意两项以上直接用TCP。UDP的意义在于你自己清楚哪些包允许丢、哪些包必须补。关于最大包长我要把实际生产中最常见的限制讲透IPv4的UDP载荷最多65507字节但大多数网络链路的MTU是1500字节减去20字节IPv4头和8字节UDP头一个UDP数据报超过1472字节后就会被IP层分片传输。分片有两个坏处任何一片丢失导致整个数据报被丢弃分片重组消耗路由器性能。所以线上UDP消息我建议单包控制在1400字节以内这对开发要有一个习惯——在16进制字节流里定义协议时正文长度字段用2字节就够了因为单报最大也就几千字节。4.3 多客户端与多路复用从单线程走向并发回显服务器目前是单线程顺序处理的先收A客户端的数据、回包、再收B客户端的数据。如果server给A回包时B的数据到了B的包只能在队列里排队单包处理还好万一回包前需要查数据库或者调用别的服务B的延迟就会飙升。更好的做法是引入多路复用。最简单实用的是select或poll。以前要同时监控多个描述符时select是首选现在推荐poll它没有FD_SETSIZE限制。思路是把UDP的socket描述符加进poll的数组同时加入一个定时器描述符或管道这样就能在单线程里既处理网络数据又处理定时任务。struct pollfd fds[1]; fds[0].fd sockfd; fds[0].events POLLIN; while (1) { int ret poll(fds, 1, 1000); if (ret 0 (fds[0].revents POLLIN)) { recvfrom(sockfd, buffer, BUFFER_SIZE, 0, (struct sockaddr *)client_addr, client_len); // 处理业务... } // 每1秒醒来处理超时、统计等任务 }如果并发量上到每秒钟几万包以上poll和select就不够用了此时用epoll它的事件驱动模型效率高得多。Linux还有一个专门为UDP批量收包设计的系统调用recvmmsg一次可以接收多个数据报在高吞吐场景下能显著减少系统调用开销。一般业务到不了这个量级但如果你在做媒体服务器或者高频交易网关这两个工具你得提前掌握。另一种思路是一个线程收、多个线程处理的模型接收线程只负责把数据放进队列或交给线程池。这里有个关键坑——UDP socket描述符本身是进程级的你可以在多个线程里同时在这个fd上调用recvfrom吗答案是可以但多个线程同时阻塞在同一个socket上时内核会把一个数据报分配给某一个线程其他线程继续等待业务逻辑上没问题不过如果你想保证同一个客户端的数据必须由同一个线程处理比如要保持状态机就不能裸用多线程fcfrom了需要在应用层做哈希分发。我把这个模型实现过它的难点不在socket在线程池和队列设计。5. 常见问题与排查实录5.1 数据收不全、收到乱码、完全收不到我梳理一下UDP实战里最容易遇到的三个症状和它们的排查路径。症状一客户端发出的数据服务端收不到。先看sendto的返回值如果返回值与发送长度一致说明数据已经进入内核发送队列接着在服务端机器上执行tcpdump或抓包工具看数据包是否到达了网卡数据包到网卡但recvfrom拿不到说明内核socket接收队列满了或者防火墙拦截了。这一类问题用逐层排查的思路应用层、系统层、网络层一层一层缩小范围绝大多数问题半小时内能找到答案。症状二收到的数据是乱码。基本可以断定是字节序问题或者双方用不同编码保存字符串。我遇到过一个诡异案例两个程序都是大端机器上写的跑在一台小端机器上突然乱码查了半天才发现是老代码里手写了IP地址进结构体没有走htonl之前那台机器恰好是大端所以一直没暴露。字节序的坑就是这样换一台机器才爆雷代码一眼看不出毛病。症状三数据收不全。要么是缓冲区设置太小导致截断要么是你把多个逻辑消息塞在了一个数据报里对端解析时只处理了前面一部分。UDP是消息边界的天然载体一个sendto对应一个recvfrom正常情况下不存在半包问题。如果你发现收不全十有八九是应用层协议的结束标志被数据内容本身干扰了需要给消息增加长度字段。5.2 端口冲突、地址复用与防火墙服务端重启时偶尔会遇到bind报错Address already in use。TCP和UDP都有这个现象TCP更多是因为TIME_WAITUDP则多半是上一个进程还没完全释放端口或者另一个程序恰好也在监听同一端口。解决方案是在bind之前设置SO_REUSEADDRint reuse 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse));这个选项让socket在bind时允许端口复用在UDP场景下基本是安全的。顺便提一下如果服务端有多个网口而你只绑定了一个具体IP其他网口上发到该端口的数据是不会被这个socket接到的。你需要区分INADDR_ANY和具体IP这两个绑定方式的使用场景。防火墙是另一个隐形杀手。我见过不少程序在本地跑得好好的部署到服务器上客户端就连接超时。排查时先看iptables或防火墙是否放行了UDP端口重点提醒UDP没有连接状态防火墙对UDP的追踪规则往往不如TCP完善很容易在允许入站但没有正确设置回包规则时被拦截。写服务端时给客户端回包用的源端口就是服务端bind的端口防火墙如果需要基于状态放行UDP的关联规则在iptables里叫ESTABLISHED和RELATED方向都要放。5.3 性能优化经验与学习建议UDP程序一旦跑起来你很快会发现瓶颈不在网络而在应用层处理速度。几个优化的优先顺序我建议这样排先确认是否需要批量收包减少系统调用用recvmmsg再检查数据拷贝次数避免大buffer反复memcpy然后考虑线程模型。如果你处理的包本身就是几十字节的控制消息那么单线程应对每秒几万包是绰绰有余的不必一开始就上复杂架构。还有一个常见的性能误区是盲目追求更大的收发缓冲区。缓冲区太大反而可能导致单包延迟升高——一个包收进来在缓冲区待着迟迟没被recvfrom取走对实时交互类业务是有害的。真正的实时性需要小缓冲区快速handling及时超时的组合而不是一味增大内核缓冲。内核缓冲区默认值其实匹配多数场景调参前先搞清楚瓶颈在哪。学到这里我建议你做一个稍微复杂点的练习来巩固写一个简易的在线状态探测工具多个客户端定时向服务端发心跳服务端用poll同时管理接收超时和心跳超时超过10秒没有心跳的客户端判定离线并打印日志。这个练习覆盖了本章所有进阶点超时控制、多路复用、状态管理做完之后你对UDP C/S模型的理解会比看十篇文章都深刻。我个人的体会是UDP编程最大的考验不在socket API本身而在你对自己业务需求的理解深度。你什么时候决定丢什么时候决定重传怎么设计消息的序号和确认机制这些才能体现真正的工程能力。socket函数翻来覆去就那么几个但组合出来的协议设计空间大得惊人。先用这篇文章里的代码跑通最简单的回显模型然后带着你实际的业务需求去改造它遇到的每一个问题都会让你对UDP的理解加深一层。最后分享一个调试小技巧写UDP程序时服务端每收到一个包打印一条包含对方IP、端口和包长的日志格式尽量固定。线上排查乱序、重放、丢失问题时这种日志配合数据包抓包分析工具几乎能定位出所有奇奇怪怪的现象。很多程序员觉得打日志没用等到线上数据对不上账的时候你才会怀念那几行看似多余的printf。