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创建与绑定核心流程

下面这张图,把整个流程串起来了。你可以把它贴在工位旁边,写代码时扫一眼,不容易漏步骤。

UDP Socket创建与绑定核心流程 UDP Socket 创建与绑定核心流程 1. socket() 创建 2. setsockopt() 选项 3. bind() 绑定端口 4. fcntl() 非阻塞 ✅ Socket 就绪,等待事件 关键参数 • domain: AF_INET / AF_INET6 • type: SOCK_DGRAM • protocol: 0 (IPPROTO_UDP) • SO_REUSEADDR: 端口重用 • O_NONBLOCK: 非阻塞标志 • bind() 前必须 htons() ⚠️ 常见错误 • 忘记设置非阻塞 → 线程卡死 • 端口被占用 → EADDRINUSE

这张图里,setsockopt() 这一步是可选的,但我强烈建议你加上。尤其是 SO_REUSEADDR,在UDP场景下几乎没什么副作用,却能省掉你很多重启服务时的麻烦。

一个小技巧:如果你在Linux上做高性能UDP服务,可以考虑使用 SO_REUSEPORT(Linux 3.9+)。这个选项允许多个进程/线程绑定到同一个端口,内核会自动做负载均衡。我在一个游戏服务器项目里用过,收包能力直接翻倍。不过要注意,不是所有系统都支持这个选项。

好了,Socket创建、绑定、非阻塞设置,这三板斧抡好了,你的UDP通信就有了一个稳固的起点。接下来就可以考虑怎么高效地收发数据了——不过那是后面章节的事,咱们先把地基打牢。