从朴素到高效:用 C 代码带你玩转 select、poll 和 epoll
嗨,大家好!今天咱们要聊聊 Linux 下处理 I/O 事件的几个经典工具:select、poll 和 epoll。这仨就像是程序员手里的“三把刷子”,从最老实的基础款到如今的高效神器,咱们会一步步写代码试试,看看它们咋工作,顺便聊聊为啥会从一个进化到另一个。不过在动手之前,咱们得先搞明白几个基本概念,不然直接上代码可能会晕头转向。放心,我尽量讲得接地气,不整那些硬邦邦的术语。
背景知识:先把基本功打扎实
-
啥是 fd(文件描述符)?
fd 全称是“文件描述符”(file descriptor),简单说就是 Linux 里给每个打开的文件或者资源分配的一个编号。你可以把它想象成一个“门牌号”,不管是读文件、写文件,还是跟网络 socket 打交道,内核都靠这个数字来找到对应的东西。比如你打开一个文件,系统可能给你返回个 3,建个 socket,可能返回个 4。这些数字通常从 0 开始,0、1、2 默认是标准输入、输出和错误,之后的就看系统分配了。 -
fds 是啥?
fds 其实就是“文件描述符集合”(file descriptors set)的简称,也就是一堆 fd 的打包。通常在像select这种场景里,咱们得告诉内核:“嘿,你帮我盯着这几个 fd,看看哪个有动静。”所以 fds 就是把多个 fd 塞到一个篮子里,方便批量操作。 -
系统调用是啥?select、poll、epoll 是啥身份?
系统调用(system call)是用户程序跟内核打交道的一种方式。比如你想读文件、建 socket,这些底层操作得靠内核完成,用户代码没法直接干,只能通过系统调用“拜托”内核。select、poll和epoll在 Linux 里确实是系统调用,它们是内核提供的原生接口,专门用来处理 I/O 多路复用——简单说,就是让程序同时盯着好几个 fd,看哪个有数据可读、可写啥的。至于 C 标准库(libc),它其实是对这些系统调用做了层封装,方便咱们用。比如你写select,背后最终还是调的内核的sys_select。所以,咱们今天玩的这仨,都是正宗的系统调用,只不过通过 C 的函数形式露出来罢了。
搞清楚这些,咱们就可以开干了!从最简单的 select 开始,一步步试着发现问题,再逼近现代最牛的方案。
第一步:最朴素的 select
咱们先拿 select 开刀,这是 I/O 多路复用的老前辈,用法简单粗暴。假设咱们要写个服务器,监听一堆客户端 socket,看谁有数据可读。代码长这样:
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#define MAX_CLIENTS 10 // 最多支持 10 个客户端连接
int main() {
int server_fd, client_fds[MAX_CLIENTS] = {0}; // server_fd 是服务器 socket,client_fds 存客户端 fd
fd_set readfds; // 定义一个 fd_set,装咱们要监听的 fd
struct sockaddr_in address; // 服务器地址结构体
int addrlen = sizeof(address); // 地址长度
// 创建服务器 socket,AF_INET 是 IPv4,SOCK_STREAM 是 TCP
server_fd = socket(AF_INET, SOCK_STREAM, 0);
address.sin_family = AF_INET; // 用 IPv4
address.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
address.sin_port = htons(8080); // 端口 8080,htons 转成网络字节序
// 绑定 socket 到地址和端口
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
// 开始监听,队列长度设为 3
listen(server_fd, 3);
printf("Server listening on port 8080...\n"); // 提示服务器已启动
int max_fd = server_fd; // max_fd 记录当前最大 fd,用于 select
while (1) {
FD_ZERO(&readfds); // 清空 readfds,select 用之前必须清零
FD_SET(server_fd, &readfds); // 把服务器 fd 加到集合里,监听新连接
// 遍历所有客户端 fd,把活跃的加进 readfds
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] > 0) { // 如果这个 fd 有效
FD_SET(client_fds[i], &readfds); // 加到集合里
if (client_fds[i] > max_fd) max_fd = client_fds[i]; // 更新 max_fd
}
}
// 调用 select,监听 readfds 里的 fd,max_fd+1 是必须的范围
select(max_fd + 1, &readfds, NULL, NULL, NULL);
// 检查服务器 fd 是否有新连接
if (FD_ISSET(server_fd, &readfds)) {
// 有新连接,accept 接受并返回新 fd
int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == 0) { // 找个空位存新 fd
client_fds[i] = new_socket;
printf("New connection, fd = %d\n", new_socket);
break;
}
}
}
// 检查每个客户端 fd 是否有数据可读
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] > 0 && FD_ISSET(client_fds[i], &readfds)) { // 如果这个 fd 有动静
char buffer[1024] = {0}; // 缓冲区存数据
int valread = read(client_fds[i], buffer, 1024); // 读数据
if (valread == 0) { // 返回 0 说明客户端断开
close(client_fds[i]); // 关掉 fd
client_fds[i] = 0; // 清空这个位置
} else { // 有数据
printf("Received: %s\n", buffer); // 打印出来
}
}
}
}
return 0;
}
跑起来后,用 telnet localhost 8080 连上试试,敲点东西,服务器会乖乖打印。看着还行,但问题很快就冒出来了。
问题在哪?
- 效率有点拉胯:每次
select,都要把所有 fd 塞进fd_set,内核检查完还得把结果塞回来。比如你有 1024 个 fd,哪怕只有 1 个有数据,内核也得扫完整堆。 - 数量卡脖子:
fd_set大小是死的,默认 1024,想监听 2000 个?得改系统配置,太麻烦。 - 重复劳动多:每次循环都得清空
fd_set,再一个个加回去,太费劲。
第二步:稍微聪明点的 poll
select 的毛病暴露后,程序员们推出了 poll,稍微改善了点。咱们改代码试试:
#include <stdio.h>
#include <stdlib.h>
#include <poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#define MAX_CLIENTS 10 // 最多支持 10 个客户端
int main() {
int server_fd, client_fds[MAX_CLIENTS] = {0}; // server_fd 是服务器 socket,client_fds 存客户端 fd
struct pollfd fds[MAX_CLIENTS + 1]; // poll 用的事件数组,+1 是留给 server_fd
struct sockaddr_in address; // 服务器地址结构体
int addrlen = sizeof(address); // 地址长度
// 创建服务器 socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
address.sin_family = AF_INET; // 用 IPv4
address.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
address.sin_port = htons(8080); // 端口 8080
// 绑定和监听
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
listen(server_fd, 3);
printf("Server listening on port 8080...\n");
fds[0].fd = server_fd; // 第 0 个位置放服务器 fd
fds[0].events = POLLIN; // 监听可读事件
int nfds = 1; // 当前监听的 fd 数量,初始只有 server_fd
while (1) {
// 调用 poll,nfds 是数组长度,-1 表示无限等待
poll(fds, nfds, -1);
// 检查服务器 fd 是否有新连接
if (fds[0].revents & POLLIN) { // revents 是 poll 返回的事件
int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == 0) { // 找个空位存新 fd
client_fds[i] = new_socket;
fds[nfds].fd = new_socket; // 加到 poll 数组
fds[nfds].events = POLLIN; // 监听可读
nfds++; // fd 数量加 1
printf("New connection, fd = %d\n", new_socket);
break;
}
}
}
// 检查客户端 fd 是否有数据
for (int i = 1; i < nfds; i++) { // 从 1 开始,第 0 个是 server_fd
if (fds[i].revents & POLLIN) { // 如果有可读事件
char buffer[1024] = {0}; // 缓冲区
int valread = read(fds[i].fd, buffer, 1024); // 读数据
if (valread == 0) { // 客户端断开
close(fds[i].fd); // 关闭 fd
client_fds[i-1] = 0; // 清空对应位置(注意 i-1,因为 client_fds 从 0 开始)
fds[i].fd = -1; // 标记为无效,poll 会忽略
} else { // 有数据
printf("Received: %s\n", buffer);
}
}
}
}
return 0;
}
用 poll 替换后,感觉比 select 顺手了些。
有啥进步?
- 没上限限制:
poll用动态数组struct pollfd,想加多少 fd 都行,不怕 1024 的门槛。 - 状态更省心:不用每次清空重设,
fds数组里的 fd 和事件可以一直用。
但还有啥不足?
- 效率不够高:内核还是得扫完整個
fds数组。1000 个 fd 里只有 2 个活跃,也得全检查。 - 数据来回折腾:每次调用都得把数组传给内核,活跃 fd 少的时候有点浪费。
第三步:现代王者 epoll
终于到 epoll 了,这可是如今服务器开发的扛把子,直接把前两者的短板干掉。上代码:
#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#define MAX_EVENTS 10 // epoll_wait 一次最多返回 10 个事件
#define MAX_CLIENTS 10 // 最多支持 10 个客户端
int main() {
int server_fd, epoll_fd, client_fds[MAX_CLIENTS] = {0}; // server_fd 是服务器,epoll_fd 是 epoll 实例
struct epoll_event event, events[MAX_EVENTS]; // event 用于设置,events 存返回的事件
struct sockaddr_in address; // 地址结构体
int addrlen = sizeof(address);
// 创建服务器 socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080);
// 绑定和监听
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
listen(server_fd, 3);
// 创建 epoll 实例,0 表示默认 flags
epoll_fd = epoll_create1(0);
event.events = EPOLLIN; // 监听可读事件
event.data.fd = server_fd; // 绑定 server_fd
// 把 server_fd 加到 epoll 监听列表
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event);
printf("Server listening on port 8080...\n");
while (1) {
// 等待事件,MAX_EVENTS 是上限,-1 表示无限等待
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) { // 遍历返回的事件
if (events[i].data.fd == server_fd) { // 如果是服务器 fd 有新连接
int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
for (int j = 0; j < MAX_CLIENTS; j++) {
if (client_fds[j] == 0) { // 找空位存新 fd
client_fds[j] = new_socket;
event.data.fd = new_socket; // 设置新 fd
event.events = EPOLLIN; // 监听可读
// 加到 epoll 监听列表
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_socket, &event);
printf("New connection, fd = %d\n", new_socket);
break;
}
}
} else { // 客户端 fd 有数据
char buffer[1024] = {0};
int valread = read(events[i].data.fd, buffer, 1024);
if (valread == 0) { // 客户端断开
close(events[i].data.fd); // 关闭 fd
for (int j = 0; j < MAX_CLIENTS; j++) {
if (client_fds[j] == events[i].data.fd) { // 找到对应位置
client_fds[j] = 0; // 清空
break;
}
}
} else { // 有数据
printf("Received: %s\n", buffer);
}
}
}
}
return 0;
}
跑起来后,你会发现这家伙效率简直飞起!
为啥这么强?
- 只管有事的:
epoll_wait只返回活跃的 fd,10 个 fd 里 2 个有动静,就只处理这 2 个。 - 内核接手:fd 交给内核管理,用户态不用每次传一堆数据。
- 玩法多样:支持水平触发(LT)和边缘触发(ET),随你挑。
优化方向:从 demo 到现代方案
通过这仨 demo,咱们能挖出啥改进点?这些正好跟现代高性能方案接轨:
- 少干无用功:
epoll用事件通知跳过了全扫描,未来还能用回调或异步再提速。 - 省着点传数据:
epoll减少了拷贝,往后可以用零拷贝(像mmap)更省力。 - 灵活扩展:
epoll加上线程池,能轻松应对高并发。
总结
从 select 到 poll 再到 epoll,咱们从简单粗暴走到了高效优雅。通过代码摸索,不仅看清了每种方案的优劣,还能启发咱们在其他场景里找优化点。Linux 下搞 I/O,epoll 是目前的大赢家,但背后的思路值得咱们多琢磨。你有啥想法?欢迎留言聊聊!