C/C++ IO多路复用(select/poll/epoll)
一、先理解核心概念:IO 多路复用
IO 多路复用(I/O Multiplexing)是一种让单个进程 / 线程可以同时监听多个文件描述符(File Descriptor,FD)的技术,当其中某些 FD 就绪(可读 / 可写 / 异常)时,系统会通知进程去处理对应的 IO 操作。
通俗比喻:你是一个餐厅老板(进程),有多个餐桌(FD)。
- 非多路复用:你站在 1 号桌等客人点餐,等完再去 2 号桌,效率极低;
- 多路复用:你在前台(内核)盯着所有餐桌,哪个餐桌的客人举手(FD 就绪),你再过去服务,效率大幅提升。
它主要解决 “高并发场景下,单线程 / 进程处理大量 IO 请求” 的问题,是 Nginx、Redis 等高性能中间件的核心底层技术之一。
二、select/poll/epoll 对比
三者都是 IO 多路复用的实现,epoll 是 Linux 下的优化版本,select/poll 是跨平台的(但效率较低)。
| 特性 | select | poll | epoll(Linux 2.6+) |
|---|---|---|---|
| FD 数量限制 | 受限于FD_SETSIZE(默认 1024) | 无硬限制(受系统资源) | 无硬限制(仅受内存) |
| 数据结构 | 位图(fd_set) | 数组(pollfd 结构体) | 红黑树 + 就绪链表 |
| 效率 | 轮询所有 FD(O (n)) | 轮询所有 FD(O (n)) | 只处理就绪 FD(O (1)) |
| 重复拷贝 | 每次调用拷贝所有 FD 到内核 | 每次调用拷贝所有 FD 到内核 | 仅初始化时拷贝,后续复用 |
| 触发方式 | 水平触发(LT) | 水平触发(LT) | 水平触发(LT)/ 边缘触发(ET) |
关键术语解释:
- 水平触发(LT):只要 FD 处于就绪状态,就会一直通知(比如数据没读完,会反复提醒),容错性高,新手友好;
- 边缘触发(ET):仅当 FD 状态从 “未就绪” 变为 “就绪” 时通知一次,需要一次性读完 / 写完数据,效率更高,但编程要求更严格。
三、实际使用示例
1. select 示例(跨平台,以 Linux 为例)
c
运行
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define MAX_FD 1024
int main() {
// 1. 创建监听socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket error");
exit(1);
}
// 2. 绑定端口
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind error");
exit(1);
}
// 3. 开始监听
if (listen(listen_fd, 5) < 0) {
perror("listen error");
exit(1);
}
fd_set read_fds, tmp_fds;
FD_ZERO(&read_fds); // 清空集合
FD_SET(listen_fd, &read_fds); // 将监听FD加入集合
int max_fd = listen_fd; // 记录最大FD(select需要)
while (1) {
tmp_fds = read_fds; // select会修改集合,所以每次要拷贝
// 4. 调用select,监听读事件
int nfds = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);
if (nfds < 0) {
perror("select error");
exit(1);
}
// 5. 遍历所有FD,检查哪个就绪
for (int fd = 0; fd <= max_fd; fd++) {
if (!FD_ISSET(fd, &tmp_fds)) continue; // 该FD未就绪,跳过
// 6. 监听FD就绪:有新连接
if (fd == listen_fd) {
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_len);
if (conn_fd < 0) {
perror("accept error");
continue;
}
printf("new client: %s:%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
FD_SET(conn_fd, &read_fds); // 新连接FD加入监听集合
if (conn_fd > max_fd) max_fd = conn_fd; // 更新最大FD
}
// 7. 普通FD就绪:有数据可读
else {
char buf[1024] = {0};
int n = read(fd, buf, sizeof(buf)-1);
if (n <= 0) { // 客户端断开或读错误
close(fd);
FD_CLR(fd, &read_fds); // 从集合中移除
printf("client %d closed\n", fd);
} else {
printf("recv from %d: %s\n", fd, buf);
write(fd, buf, n); // 回显数据
}
}
}
}
close(listen_fd);
return 0;
}
代码说明:
fd_set是 select 的核心数据结构(位图),FD_ZERO/SET/ISSET/CLR是操作位图的宏;select第一个参数是最大FD+1,后面三个参数分别监听 “读 / 写 / 异常” 事件,最后一个参数是超时时间(NULL 表示阻塞);- 每次调用 select 前要拷贝
read_fds到tmp_fds,因为 select 会修改传入的集合,只保留就绪的 FD。
2. poll 示例(解决 select 的 FD 数量限制)
c
运行
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define MAX_POLL_FD 10240 // 无硬限制,可设更大
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); exit(1); }
struct sockaddr_in serv_addr = {0};
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) { perror("bind"); exit(1); }
if (listen(listen_fd, 5) < 0) { perror("listen"); exit(1); }
// 1. 定义pollfd数组(核心区别:替代select的位图)
struct pollfd fds[MAX_POLL_FD] = {0};
fds[listen_fd].fd = listen_fd;
fds[listen_fd].events = POLLIN; // 监听读事件
int max_index = listen_fd; // 记录最大索引
while (1) {
// 2. 调用poll,监听事件
int nfds = poll(fds, max_index + 1, -1); // -1表示阻塞
if (nfds < 0) { perror("poll"); exit(1); }
// 3. 遍历检查就绪FD
for (int i = 0; i <= max_index; i++) {
if (fds[i].revents == 0) continue; // 无事件
// 4. 监听FD就绪:新连接
if (i == listen_fd) {
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_len);
if (conn_fd < 0) { perror("accept"); continue; }
printf("new client: %s:%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
fds[conn_fd].fd = conn_fd;
fds[conn_fd].events = POLLIN; // 监听读事件
if (conn_fd > max_index) max_index = conn_fd;
}
// 5. 普通FD就绪:读数据
else if (fds[i].revents & POLLIN) {
char buf[1024] = {0};
int n = read(i, buf, sizeof(buf)-1);
if (n <= 0) {
close(i);
fds[i].fd = -1; // 标记为无效
printf("client %d closed\n", i);
} else {
printf("recv from %d: %s\n", i, buf);
write(i, buf, n);
}
}
}
}
close(listen_fd);
return 0;
}
代码说明:
pollfd结构体包含fd(文件描述符)、events(要监听的事件)、revents(实际发生的事件);- poll 没有 FD 数量的硬限制(仅受数组大小和系统资源),无需像 select 那样传
max_fd+1,而是传数组长度; - 核心事件:
POLLIN(可读)、POLLOUT(可写)、POLLERR(异常)。
3. epoll 示例(Linux 高性能首选)
c
运行
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#define PORT 8888
#define MAX_EVENTS 10240 // 每次最多处理的就绪事件数
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); exit(1); }
// 端口复用(避免重启报错)
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in serv_addr = {0};
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) { perror("bind"); exit(1); }
if (listen(listen_fd, 5) < 0) { perror("listen"); exit(1); }
// 1. 创建epoll实例(核心:epoll_fd是句柄)
int epoll_fd = epoll_create1(0); // 参数0即可,旧版epoll_create需传大小(无实际意义)
if (epoll_fd < 0) { perror("epoll_create1"); exit(1); }
// 2. 将监听FD加入epoll实例
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN; // 监听读事件(LT模式,默认)
ev.data.fd = listen_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) {
perror("epoll_ctl add listen_fd");
exit(1);
}
while (1) {
// 3. 等待事件就绪(核心:只返回就绪的FD,无需轮询所有)
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); // -1阻塞
if (nfds < 0) { perror("epoll_wait"); exit(1); }
// 4. 遍历就绪事件
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
// 5. 新连接
if (fd == listen_fd) {
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_len);
if (conn_fd < 0) { perror("accept"); continue; }
printf("new client: %s:%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
// 6. 将新连接FD加入epoll(ET模式示例:EPOLLIN | EPOLLET)
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = conn_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev) < 0) {
perror("epoll_ctl add conn_fd");
close(conn_fd);
}
}
// 7. 数据可读
else if (events[i].events & EPOLLIN) {
char buf[1024] = {0};
int n = read(fd, buf, sizeof(buf)-1);
if (n <= 0) {
// 客户端断开,从epoll移除
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
printf("client %d closed\n", fd);
} else {
printf("recv from %d: %s\n", fd, buf);
write(fd, buf, n);
}
}
}
}
close(listen_fd);
close(epoll_fd);
return 0;
}
代码说明:
epoll_create1:创建 epoll 实例,返回的epoll_fd是操作句柄;epoll_ctl:控制 epoll 实例(EPOLL_CTL_ADD添加 FD、EPOLL_CTL_DEL删除 FD、EPOLL_CTL_MOD修改事件);epoll_wait:等待就绪事件,返回就绪的事件数,events数组存放就绪的事件;- ET 模式需注意:必须一次性读完数据(可循环 read 直到返回
EAGAIN),否则后续不会再通知。
总结
- IO 多路复用核心:单线程 / 进程监听多个 FD,仅处理就绪的 IO,提升高并发场景下的效率;
- select/poll:跨平台但效率低(轮询所有 FD),select 有 FD 数量限制,poll 无硬限制;
- epoll:Linux 专属高性能方案(O (1) 效率),支持 LT/ET 触发,是生产环境(如 Nginx)的首选。
四、epoll_wait 函数基础定义
epoll_wait 是 epoll 机制中获取就绪事件的核心函数,它会阻塞等待 epoll 实例中的文件描述符(FD)就绪,然后将就绪事件返回到用户态的数组中。
1. 函数原型(头文件:<sys/epoll.h>)
c
运行
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
2. 逐个解析参数(重点)
| 参数名 | 类型 | 含义与使用注意事项 |
|---|---|---|
epfd | int | epoll 实例的句柄(由 epoll_create1() 返回),必须是有效的、未关闭的句柄 |
events | struct epoll_event* | 输出参数:指向用户提前分配的 epoll_event 数组,内核会把就绪事件填充到这个数组中 |
maxevents | int | 指定 events 数组的最大长度(即本次最多能接收的就绪事件数),必须 > 0 |
timeout | int | 超时时间(单位:毫秒),有三种取值:① -1:永久阻塞,直到有事件就绪才返回;② 0:非阻塞,立即返回(不管有无事件);③ >0:阻塞指定毫秒数,超时后返回 0 |
3. 返回值解析(必须重点处理)
| 返回值 | 含义与处理方式 |
|---|---|
> 0 | 成功,返回就绪的事件数量(≤ maxevents),只需遍历前 N 个事件即可 |
0 | 超时(仅当 timeout > 0 时可能出现),无任何事件就绪,可重试调用 |
-1 | 失败,需检查 errno:① EINTR:被信号中断(非真正错误,建议重新调用);② EBADF:epfd 不是有效的 epoll 句柄;③ EINVAL:epfd 不是 epoll 句柄,或 maxevents ≤ 0 |
五、epoll_wait 使用关键注意事项
-
遍历范围只到 nfds:不要遍历整个
events数组(比如for (int i=0; i<MAX_EVENTS; i++)),只需遍历前nfds个元素 —— 因为只有这部分是内核填充的就绪事件,这是 epoll 比 select/poll 高效的核心原因。 -
处理 EINTR 中断:当程序收到信号(如
Ctrl+C、定时器信号)时,epoll_wait会返回-1且errno=EINTR,这不是错误,只需重新调用epoll_wait即可,不要直接退出程序。 -
timeout 的合理选择:
- 服务端常驻程序:用
-1(永久阻塞),减少空轮询; - 需要定期执行其他逻辑(如清理超时连接):用
>0(比如 1000ms),超时后执行清理逻辑再重试; - 非阻塞测试:用
0,立即返回当前就绪事件。
- 服务端常驻程序:用
-
events 数组大小设置:无需和监听的 FD 数量一致,建议设为 1024/4096 等(根据业务并发量),内核会自动适配,即使有更多 FD 就绪,
epoll_wait也只会返回maxevents个,剩余的会在下一次调用时返回。 -
必须处理 EPOLLERR/EPOLLHUP:这两个事件无需手动监听,内核会自动返回,若不处理会导致 FD 泄露 —— 比如客户端断开后,FD 仍在 epoll 中,占用资源。
总结
epoll_wait是获取 epoll 就绪事件的核心,参数需注意maxevents>0、timeout的三种取值,返回值要区分 “成功 / 超时 / 错误”;- 遍历就绪事件时仅处理前
nfds个,是 epoll 高效的关键,且必须处理EINTR中断; - 务必处理
EPOLLERR/EPOLLHUP事件,避免 FD 泄露,同时根据业务场景选择合适的timeout。
更多推荐

所有评论(0)