SELECT 和 EPOLL
EPOLL 实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#define PORT 8080
#define MAX_EVENTS 10
#define BUFFER_SIZE 1024
// 设置非阻塞
int set_nonblocking(int fd) {
// F_GETFL 是 fcntl 函数的一个命令常量,用于获取文件描述符的状态标志(status flags)。
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
//先用 F_GETFL 获取原始标志。
// 再通过 F_SETFL 设置新的标志:将 O_NONBLOCK 加入其中,使得该文件描述符的操作不会阻塞等待。
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
int server_fd, client_fd, epoll_fd;
struct sockaddr_in addr;
struct epoll_event ev, events[MAX_EVENTS];
char buffer[BUFFER_SIZE];
// AF_INET 表示使用 IPv4 地址协议族。
// SOCK_STREAM 表示这是一个面向连接、可靠的流式套接字(即 TCP)
// 0 协议类型,设为 0 表示使用默认协议(对应 TCP)。
// 创建 socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置地址复用
int opt = 1;
//server_fd:socket 文件描述符。
//SOL_SOCKET:表示设置的是 socket 层级的选项。
//SO_REUSEADDR:具体的选项名称,表示允许重用本地地址
//&opt:指向存放选项值的指针
//sizeof(opt):选项值的大小。
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定地址和端口
// 设置地址族为 AF_INET,表示使用 IPv4 地址。
addr.sin_family = AF_INET;
// 设置 IP 地址为 INADDR_ANY,表示监听所有网络接口(即允许来自任何 IP 地址的连接)
addr.sin_addr.s_addr = INADDR_ANY;
// 设置端口号为 PORT(本例中是 8080),htons 函数用于将主机字节序的端口号转换为网络字节序(大端格式)。
addr.sin_port = htons(PORT);
// 调用 bind() 函数将 socket 文件描述符 server_fd 绑定到本地地址结构 addr
// server_fd:之前通过 socket() 创建的 socket 描述符。
// (struct sockaddr*)&addr:指向本地地址结构的指针(强制类型转换为通用 socket 地址类型)。
// sizeof(addr):地址结构体的大小
if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
close(server_fd);
exit(EXIT_FAILURE);
}
// 将 server_fd 标识的 socket 设置为监听模式,允许接受客户端连接。
// 第一个参数 server_fd 是创建的 socket 文件描述符。
//第二个参数 SOMAXCONN 表示系统允许的最大连接队列长度(包括已完成连接队列和未完成连接握手队列的总和)
if (listen(server_fd, SOMAXCONN) < 0) {
perror("listen");
close(server_fd);
exit(EXIT_FAILURE);
}
// 设置非阻塞
set_nonblocking(server_fd);
// 调用 epoll_create1 函数创建一个 epoll 文件描述符:
//参数 0 表示使用默认标志创建。
//返回值 epoll_fd 是新创建的 epoll 文件描述符。
epoll_fd = epoll_create1(0);
if (epoll_fd < 0) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
//调用 epoll_ctl 函数对 epoll 实例进行控制操作:
//epoll_fd:之前通过 epoll_create1 创建的 epoll 文件描述符。
//EPOLL_CTL_ADD:表示要添加一个新的监听事件。
//server_fd:要监听的文件描述符(这里是服务器 socket)。
//&ev:指向 epoll_event 结构体的指针,其中包含了要监听的事件类型和关联的数据(如 server_fd)。
ev.events = EPOLLIN;
ev.data.fd = server_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev) < 0) {
perror("epoll_ctl: server_fd");
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 事件循环
while (1) {
//调用 epoll_wait() 等待注册的 I/O 事件发生:
//epoll_fd:之前创建的 epoll 文件描述符。
//events:用于存储就绪事件的数组,类型为 struct epoll_event。
//MAX_EVENTS:最多返回多少个就绪事件(这里是 10)。
//-1:表示无限等待,直到有事件发生为止。
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (nfds < 0) {
perror("epoll_wait");
break;
}
for (int i = 0; i < nfds; i++) {
// 新连接
if (events[i].data.fd == server_fd) {
//调用 accept() 函数从已完成连接队列中取出一个客户端连接:
//server_fd:服务器监听 socket 文件描述符。
//第二个参数为 NULL,表示不关心客户端的地址信息。
//第三 个参数也为NULL,表示不关心地址结构体的长度。
client_fd = accept(server_fd, NULL, NULL);
if (client_fd < 0) {
perror("accept");
continue;
}
set_nonblocking(client_fd);
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = client_fd;
//调用 epoll_ctl() 函数,尝试将客户端 socket client_fd 添加进 epoll 实例:
//epoll_fd:之前创建的 epoll 文件描述符。
//EPOLL_CTL_ADD:表示要添加一个新的监听项。
//client_fd:要监听的客户端 socket。
//&ev:事件结构体,定义了监听的事件类型(如 EPOLLIN)和绑定的文件描述符。
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) < 0) {
perror("epoll_ctl: client_fd");
close(client_fd);
}
printf("Accepted new client: %d\n", client_fd);
}
// 客户端发来数据
else {
int client = events[i].data.fd;
//read() 是系统调用函数,用于从文件描述符(这里是 socket)中读取数据。
//client:客户端 socket 文件描述符。
//buffer:用于存储读取数据的缓冲区(字符数组)。
//BUFFER_SIZE:最大读取字节数(本例中为 1024)。
//返回值 n 表示实际读取的字节数:
//如果 n > 0:表示成功读取了 n 字节的数据。
//如果 n == 0:表示客户端关闭了连接(没有更多数据可读)。
//如果 n < 0:表示读取出错(例如 EAGAIN 或 EWOULDBLOCK 在非阻塞模式下)。
int n = read(client, buffer, BUFFER_SIZE);
if (n <= 0) {
printf("Client %d disconnected.\n", client);
close(client);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client, NULL);
} else {
buffer[n] = '\0';
printf("Received from client %d: %s", client, buffer);
write(client, buffer, n); // 回显
}
}
}
}
close(server_fd);
close(epoll_fd);
return 0;
}
SELECT 实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <arpa/inet.h>
#define PORT 8888
#define MAX_CLIENTS FD_SETSIZE // 通常是 1024
#define BUFFER_SIZE 1024
int main() {
int listen_fd, conn_fd, sockfd;
struct sockaddr_in server_addr, client_addr;
socklen_t addr_len;
char buffer[BUFFER_SIZE];
int client[MAX_CLIENTS]; // 客户端 fd 列表
fd_set all_fds, read_fds;
int max_fd;
int i, n;
//调用 socket 函数创建一个新的 socket。
//AF_INET 表示使用 IPv4 地址族。
//SOCK_STREAM 表示使用 TCP 协议(面向连接的可靠传输)。
//第三个参数为 0,表示使用默认协议(即 TCP)。
//返回值 listen_fd 是 socket 文件描述符,如果创建失败则返回 -1。
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket error");
exit(EXIT_FAILURE);
}
int opt = 1;
//listen_fd 要操作的 socket 文件描述符(即你之前创建的监听 socket)。
//SOL_SOCKET 表示我们要设置的是 socket 层级的选项。
//SO_REUSEADDR 选项名,表示允许重复使用本地地址(端口和 IP 地址组合)。即使该地址当前处于 TIME_WAIT 状态,也可以被重新绑定。
//&opt 指向一个整型变量的指针,值为 1(启用该选项),如果为 0 则表示禁用。
//sizeof(opt) 表示传递的选项值的大小。
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
//调用 bind() 函数将 socket 文件描述符 listen_fd 与地址结构 server_addr 进行绑定。
//listen_fd:之前创建好的 socket 描述符。
//(struct sockaddr *)&server_addr:将指向 server_addr 的指针强制转换为通用 socket 地址类型。
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind error");
exit(EXIT_FAILURE);
}
//调用 listen() 函数将 listen_fd 所代表的 socket 设置为监听状态。
//第二个参数 10 是连接队列的最大长度,即系统可以为该 socket 暂时排队的未处理连接请求数量(也称为“backlog”)。
//如果有多个客户端同时发起连接而服务器还没来得及调用 accept() 处理,系统会将这些连接放入队列中等待处理。
//队列满后的新连接请求可能会被拒绝。
if (listen(listen_fd, 10) < 0) {
perror("listen error");
exit(EXIT_FAILURE);
}
// 5. 初始化客户端数组
for (i = 0; i < MAX_CLIENTS; i++) client[i] = -1;
//使用宏 FD_ZERO() 来清空一个 fd_set 类型的集合 all_fds。
//all_fds 是 select() 多路复用机制中用于监听所有关注的文件描述符的集合。
FD_ZERO(&all_fds);
//使用 FD_SET() 将监听 socket 的文件描述符 listen_fd 添加进 all_fds 集合中。
//这样在调用 select() 的时候,就能监听这个 socket 上是否有新的连接请求到来。
FD_SET(listen_fd, &all_fds);
//max_fd 是传给 select() 函数的第一个参数,表示要监听的最大文件描述符值 +1。
//初始时只有 listen_fd,所以将 max_fd 设为它
max_fd = listen_fd;
printf("Server listening on port %d...\n", PORT);
while (1) {
read_fds = all_fds;
//select(max_fd + 1, &read_fds, NULL, NULL, NULL)
//max_fd + 1:要检查的最大文件描述符值 +1(select() 的历史遗留要求)
//&read_fds:监听可读事件的 fd 集合(这里关注是否有数据可读)
//三个 NULL:分别表示不监听可写事件、异常事件和超时时间(无限等待)
//作用:阻塞直到以下任一情况发生:
//监听 socket (listen_fd) 有新连接(可读)
//已有客户端 socket 发送了数据(可读)
int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
if (ready < 0) {
perror("select error");
continue;
}
//FD_ISSET(fd, &set) 是标准库提供的宏,用于 直接检查 指定的文件描述符 fd 是否在集合 set 中
if (FD_ISSET(listen_fd, &read_fds)) {
addr_len = sizeof(client_addr);
//从已完成连接队列中取出一个客户端连接,并返回一个新的 socket 文件描述符 (conn_fd)。
//listen_fd:监听 socket 的描述符(门卫)
//&client_addr:用于存储客户端地址信息的结构体(输出参数)
//&addr_len:输入输出参数,传入地址结构体大小,返回实际地址长度
conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len);
if (conn_fd < 0) {
perror("accept error");
continue;
}
printf("New connection from %s:%d\n", inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
for (i = 0; i < MAX_CLIENTS; i++) {
if (client[i] < 0) {
client[i] = conn_fd;
break;
}
}
if (i == MAX_CLIENTS) {
fprintf(stderr, "Too many clients\n");
close(conn_fd);
continue;
}
//FD_SET(conn_fd, &all_fds)
//作用:将新客户端 socket (conn_fd) 加入监控集合 all_fds
//新连接的 socket 必须手动加入集合才会被监控
FD_SET(conn_fd, &all_fds);
if (conn_fd > max_fd) max_fd = conn_fd;
if (--ready <= 0) continue; // 没有更多就绪的 fd
}
// 7. 检查客户端连接
for (i = 0; i < MAX_CLIENTS; i++) {
if ((sockfd = client[i]) < 0) continue;
if (FD_ISSET(sockfd, &read_fds)) {
memset(buffer, 0, sizeof(buffer));
if ((n = read(sockfd, buffer, BUFFER_SIZE)) <= 0) {
// 客户端关闭连接
if (n == 0)
printf("Client fd %d disconnected\n", sockfd);
else
perror("read error");
close(sockfd);
FD_CLR(sockfd, &all_fds);
client[i] = -1;
} else {
printf("Received from client fd %d: %s", sockfd, buffer);
write(sockfd, buffer, n);
}
if (--ready <= 0) break;
}
}
}
return 0;
}
为什么需要这段代码?setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
当你运行一个 TCP 服务器并绑定到某个端口(如 8888)时,如果你关闭服务器再立刻重启,可能会遇到如下问题:
bind error: Address already in use
这是因为 TCP 协议为了确保最后一个数据包在网络中消失,会将连接保留在 TIME_WAIT 状态一段时间(通常是几分钟)。此时虽然服务已经关闭,但地址仍被视为“正在使用”。
通过设置 SO_REUSEADDR,你可以告诉操作系统:即使这个地址还处于 TIME_WAIT 状态,也允许新的 socket 绑定到这个地址。
✅ select vs epoll 全面对比总结
| 对比项 | select | epoll |
|---|---|---|
| 出现时间 | 早期 POSIX 标准,几乎所有平台都支持 | Linux 2.6 引入,仅限 Linux 平台使用 |
| 工作机制 | 每次调用都轮询全部 FD | 基于事件驱动,FD 状态变化由内核回调通知 |
| 文件描述符限制 | FD_SETSIZE 默认 1024(可修改但不便) | 理论无上限,仅受系统资源限制 |
| 性能复杂度 | O(n):每次都遍历全部 FD,连接数越多越慢 | O(1):活跃事件直接返回,与监听 FD 数量无关 |
| 用户态与内核态交互 | 每次调用都要拷贝 FD 集合到内核 | 只在注册或修改 FD 时交互,节省大量拷贝开销 |
| 触发方式 | 仅支持水平触发(LT) | 支持水平触发(LT)和边缘触发(ET) |
| 事件返回方式 | 返回所有就绪 FD,需要应用自己遍历筛选 | 仅返回发生事件的 FD,避免无效遍历 |
| 使用接口 | 简单但不灵活:每次都设置读写集合 | 接口复杂(epoll_create/epoll_ctl/epoll_wait),但注册一次即可复用 |
| 线程安全 | 线程不安全,需要额外处理 | epoll 本身线程安全,适合多线程 |
| 跨平台性 | 跨平台兼容性好 | 仅支持 Linux |
| 适用场景 | 少量连接、调试、教学用途、兼容性要求高场合 | 高并发、高性能网络服务,如 nginx、redis、kafka 等 |
📌 补充说明
- epoll的边缘触发(ET)模式更高效,但使用时必须配合非阻塞IO,否则容易漏掉事件。
- 水平触发(LT)适合简单逻辑,不容易遗漏事件,但效率稍低。
- epoll本质上是将事件注册到内核,省去了每次拷贝和轮询;而select是每次都要"重报家门"。

浙公网安备 33010602011771号