2、Socket创建与绑定:创建UDP Socket、绑定端口、设置非阻塞模式
Socket的创建与绑定,是所有UDP通信的起点。这一步要是没做好,后面再优化也是白搭。我见过不少项目,性能瓶颈其实就出在最开始的这几行代码上。
说白了,你创建的Socket就是应用程序和内核网络协议栈之间的「门」。门开得对不对,门框装得牢不牢,直接决定了后续数据能不能顺畅进出。
2.1 创建UDP Socket
创建UDP Socket的API很简单,就是 socket(AF_INET, SOCK_DGRAM, 0)。但简单归简单,里面有几个细节我建议你注意一下。
核心API原型:
int sockfd = socket(int domain, int type, int protocol);
- domain:地址族,IPv4用
AF_INET,IPv6用AF_INET6。我个人习惯在双栈场景下直接用AF_INET6配合IPV6_V6ONLY选项来控制。 - type:套接字类型,UDP固定用
SOCK_DGRAM(数据报套接字)。 - protocol:协议号,UDP填
0即可,内核会自动识别为IPPROTO_UDP。
嗯,这里要注意一个坑:socket() 返回的 fd 默认是 阻塞模式。很多新手拿到 fd 就直接开始 recvfrom(),结果线程卡死在那里。我后面会讲怎么改成非阻塞。
我的经验:创建Socket后,第一时间检查返回值。如果返回 -1,别急着往下走,用 perror() 或 strerror(errno) 打一下日志。我曾经在一个容器化环境里折腾了半天,最后发现是 seccomp 策略把 socket() 系统调用给拦截了。
2.2 绑定端口
绑定端口用 bind() 函数。它的作用是把Socket和本地的IP地址、端口号关联起来。对于UDP服务端来说,这一步是必须的——你不绑定,客户端往哪个端口发数据?
对于客户端呢?其实也可以不绑定,内核会自动分配一个临时端口。但我个人建议:高性能场景下,客户端也主动绑定。为什么?
- 避免内核每次动态分配端口带来的锁竞争。
- 方便防火墙策略配置——端口固定,规则好写。
- 便于监控和排障——一看端口就知道是哪个进程。
来看一个标准的绑定流程:
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); // 绑定端口8888
if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind failed");
close(sockfd);
exit(EXIT_FAILURE);
}
这里有几个关键点:
| 参数 | 说明 | 避坑指南 |
|---|---|---|
INADDR_ANY |
绑定到所有本地IP地址 | 多网卡场景下,如果只想收某个特定网卡的包,别用这个,指定具体IP |
htons() |
主机字节序转网络字节序 | 端口号必须用这个转换,我见过有人直接写 8888,在小端机器上就变成了 0x22B8 |
bind() 返回值 |
成功返回0,失败返回-1 | 最常见的失败原因是端口被占用(EADDRINUSE) |
注意:如果服务端意外崩溃,端口可能进入 TIME_WAIT 状态。此时立即重启会报 EADDRINUSE。解决方案是在 bind() 之前设置 SO_REUSEADDR 选项:
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
这个选项允许端口被立即重用,对UDP尤其重要——因为UDP没有连接状态,TIME_WAIT其实没什么意义。
2.3 设置非阻塞模式
非阻塞模式,是UDP高性能通信的基石。你想想看,如果每个 recvfrom() 调用都要等数据到了才返回,那你的程序就只能串行处理——一个包没到,后面所有包都得等着。
设置非阻塞有两种方式:
方式一:fcntl()(经典方法)
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
这种方式的好处是兼容性好,POSIX标准接口,所有Unix-like系统都支持。我早期做嵌入式开发时一直用这个。
方式二:ioctl()(另一种选择)
int on = 1;
ioctl(sockfd, FIONBIO, &on);
这种方式更简洁,一行搞定。但注意,ioctl() 不是所有平台的行为都完全一致。我个人更推荐 fcntl(),因为它的语义更清晰——先获取当前标志,再追加非阻塞位,不会意外覆盖其他标志。
设置非阻塞后的行为变化:
recvfrom():如果没有数据可读,立即返回 -1,errno设置为EAGAIN或EWOULDBLOCK。sendto():如果发送缓冲区满,立即返回 -1,同样设置EAGAIN。- 你需要在代码中显式处理这些「资源暂不可用」的情况,通常配合
epoll或select来等待可读/可写事件。
我曾经在一个高并发网关项目里,就是因为忘了设置非阻塞,导致一个慢客户端把整个事件循环线程卡住了——所有其他客户端的包全部超时。排查了半天才发现,是 recvfrom() 在阻塞等待那个慢客户端的下一个包。从那以后,我所有UDP Socket创建后第一件事就是设非阻塞。
2.4 完整示例:创建 + 绑定 + 非阻塞
把上面三步串起来,就是一个完整的UDP服务端初始化流程:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
int sockfd;
struct sockaddr_in addr;
int opt = 1;
// 1. 创建UDP Socket
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
// 2. 设置端口重用(可选,但推荐)
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 绑定端口
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8888);
if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
close(sockfd);
exit(EXIT_FAILURE);
}
// 4. 设置非阻塞模式
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
printf("UDP server started on port 8888 (non-blocking)\n");
// 接下来就可以配合 epoll 或 select 进行事件驱动处理了
// ...
close(sockfd);
return 0;
}
这段代码看起来简单,但每一步都有它的道理。我建议你把这段代码当成模板,每次写UDP服务端时直接复用,然后根据具体场景微调。
2.5 知识体系:Socket创建与绑定核心流程
下面这张图,把整个流程串起来了。你可以把它贴在工位旁边,写代码时扫一眼,不容易漏步骤。
这张图里,setsockopt() 这一步是可选的,但我强烈建议你加上。尤其是 SO_REUSEADDR,在UDP场景下几乎没什么副作用,却能省掉你很多重启服务时的麻烦。
一个小技巧:如果你在Linux上做高性能UDP服务,可以考虑使用 SO_REUSEPORT(Linux 3.9+)。这个选项允许多个进程/线程绑定到同一个端口,内核会自动做负载均衡。我在一个游戏服务器项目里用过,收包能力直接翻倍。不过要注意,不是所有系统都支持这个选项。
好了,Socket创建、绑定、非阻塞设置,这三板斧抡好了,你的UDP通信就有了一个稳固的起点。接下来就可以考虑怎么高效地收发数据了——不过那是后面章节的事,咱们先把地基打牢。