Linux I/O 多路复用详解:select、poll 和 epoll
·
Linux I/O 多路复用详解:select、poll 和 epoll
目录
核心用途
一句话总结
select、poll、epoll 都是用来"同时监听多个文件描述符(socket),等待其中任何一个变为可读/可写状态"的系统调用。
问题场景
假设你写了一个服务器程序,需要同时处理 1000 个客户端连接:
客户端1 ──┐
客户端2 ──┤
客户端3 ──┤
... ├──> 服务器(你的程序)
客户端998──┤
客户端999──┤
客户端1000─┘
问题:你怎么知道哪个客户端发来了数据?
解决方案对比
❌ 方案1:轮询(不推荐)
// 傻瓜式做法:一个一个检查
while (1) {
for (int i = 0; i < 1000; i++) {
if (有数据(客户端[i])) {
处理数据(客户端[i]);
}
}
}
问题:太慢了!CPU 一直在空转检查。
✅ 方案2:使用 I/O 多路复用
// 聪明做法:让内核告诉你哪个有数据
while (1) {
// 等待,直到有客户端发来数据
就绪的客户端列表 = epoll_wait(所有客户端);
// 只处理有数据的客户端
for (客户端 in 就绪的客户端列表) {
处理数据(客户端);
}
}
优势:不用空转,有数据才处理。
形象比喻
想象你是一个餐厅服务员,需要照看多张桌子(文件描述符):
- select:你拿着一个固定大小的记事本(最多记录1024张桌子),每次都要从头到尾检查每张桌子是否需要服务
- poll:你换了一个可以无限扩展的记事本,但还是要从头到尾检查每张桌子
- epoll:餐厅装了呼叫系统,哪张桌子需要服务会主动通知你,你只需要去有需求的桌子
select 详解
基本概念
- 最早的 I/O 多路复用机制
- 使用固定大小的位图(fd_set)来表示文件描述符集合
- 有最大文件描述符数量限制(通常是 1024)
代码示例
fd_set readfds; // 位图,每个 bit 代表一个 fd
FD_ZERO(&readfds);
FD_SET(socket1, &readfds);
FD_SET(socket2, &readfds);
// 调用 select
int ret = select(maxfd + 1, &readfds, NULL, NULL, &timeout);
// 返回后需要遍历所有 fd
for (int i = 0; i <= maxfd; i++) {
if (FD_ISSET(i, &readfds)) {
// 处理就绪的 fd
}
}
执行流程
用户态 内核态
| |
| 1. 准备 fd_set |
| (设置要监听的 fd) |
| |
| 2. 调用 select() |
|------------------------------>|
| 拷贝 fd_set 到内核 | 3. 遍历所有 fd
| | 检查是否就绪
| | (O(n) 复杂度)
| |
|<------------------------------|
| 拷贝结果回用户态 | 4. 返回就绪数量
| |
| 5. 遍历 fd_set |
| 找出就绪的 fd |
| (O(n) 复杂度) |
缺点
- fd 数量限制:fd_set 是固定大小的位图,通常限制为 1024
- 重复拷贝:每次调用都要把 fd_set 从用户态拷贝到内核态
- 两次遍历:内核遍历一次检查,用户态遍历一次找结果
- 性能差:监听 1000 个 fd,即使只有 1 个就绪,也要遍历 1000 次
poll 详解
基本概念
- 使用 pollfd 结构数组,没有最大文件描述符数量限制
- 解决了 select 的 fd 数量限制问题
代码示例
struct pollfd fds[1000];
fds[0].fd = socket1;
fds[0].events = POLLIN; // 监听可读事件
fds[1].fd = socket2;
fds[1].events = POLLIN;
// 调用 poll
int ret = poll(fds, 1000, timeout);
// 返回后遍历找出就绪的 fd
for (int i = 0; i < 1000; i++) {
if (fds[i].revents & POLLIN) {
// 处理就绪的 fd
}
}
与 select 的区别
select:
- 使用位图 (fd_set)
- 有 1024 限制
- 位图大小固定
poll:
- 使用结构体数组 (pollfd[])
- 无数量限制
- 可以动态扩展
缺点
虽然解决了数量限制,但其他问题依然存在:
- 还是要重复拷贝整个数组
- 还是要遍历所有 fd
- 性能问题没有本质改善
epoll 详解
基本概念
- Linux 特有的高性能 I/O 多路复用机制
- 使用事件驱动模型
- 没有 fd 数量限制
工作原理
epoll 分为三个步骤:
步骤 1:创建 epoll 实例
// 创建一个 epoll 实例
int epfd = epoll_create(1000);
步骤 2:注册要监听的 fd(只需一次)
struct epoll_event ev;
ev.events = EPOLLIN; // 监听可读事件
ev.data.fd = socket1;
// 添加 fd 到 epoll(只需添加一次)
epoll_ctl(epfd, EPOLL_CTL_ADD, socket1, &ev);
epoll_ctl(epfd, EPOLL_CTL_ADD, socket2, &ev);
epoll_ctl(epfd, EPOLL_CTL_ADD, socket3, &ev);
步骤 3:等待事件(只返回就绪的 fd)
struct epoll_event events[100];
// 等待事件,只返回就绪的 fd
int nfds = epoll_wait(epfd, events, 100, timeout);
// 只需遍历就绪的 fd(不是所有 fd)
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
// 处理就绪的 fd
}
执行流程
用户态 内核态
| |
| 1. epoll_create() |
|------------------------------>| 创建 epoll 实例
| | (红黑树 + 就绪列表)
| |
| 2. epoll_ctl(ADD, fd1) |
|------------------------------>| 将 fd1 加入红黑树
| | 注册回调函数
| |
| 3. epoll_ctl(ADD, fd2) |
|------------------------------>| 将 fd2 加入红黑树
| |
| | [等待事件...]
| | fd2 数据到达
| | 回调函数将 fd2 加入就绪列表
| |
| 4. epoll_wait() |
|------------------------------>| 检查就绪列表
|<------------------------------|
| 只返回就绪的 fd (fd2) | 直接返回就绪列表
| |
| 5. 处理 fd2 |
| (只遍历就绪的 fd) |
内部数据结构
epoll 内部结构:
┌─────────────────────────────────┐
│ epoll 实例 (epfd) │
├─────────────────────────────────┤
│ │
│ 红黑树 (存储所有监听的 fd) │
│ ├── fd1 │
│ ├── fd2 │
│ ├── fd3 │
│ └── ... │
│ │
│ 就绪列表 (双向链表) │
│ ├── fd2 (有数据) │
│ └── fd5 (有数据) │
│ │
└─────────────────────────────────┘
当 fd 有事件时:
1. 内核通过回调函数
2. 将 fd 加入就绪列表
3. epoll_wait() 直接返回就绪列表
核心优势
- 事件驱动:不是轮询,而是事件通知
- 只返回就绪的:不需要遍历所有 fd
- 避免重复拷贝:fd 只注册一次
- 内核维护状态:用户态不需要维护 fd 集合
epoll 的两种模式
LT 模式(水平触发,默认)
// 只要 fd 有数据,就会一直通知
ev.events = EPOLLIN; // 默认 LT 模式
场景:
1. fd 有 100 字节数据
2. epoll_wait() 返回通知
3. 你只读了 50 字节
4. 下次 epoll_wait() 还会通知(因为还有 50 字节)
特点:不会丢失事件,编程简单
ET 模式(边缘触发)
// 只在状态变化时通知一次
ev.events = EPOLLIN | EPOLLET; // ET 模式
场景:
1. fd 有 100 字节数据
2. epoll_wait() 返回通知(第一次)
3. 你只读了 50 字节
4. 下次 epoll_wait() 不会通知(状态没变化)
5. 除非又来了新数据(状态变化)
特点:性能更高,但需要一次性读完所有数据
性能对比
对比实例
假设监听 10000 个连接,其中只有 10 个活跃:
select/poll 的操作
1. 拷贝 10000 个 fd 到内核 ❌ 慢
2. 内核遍历 10000 个 fd ❌ 慢
3. 拷贝结果回用户态 ❌ 慢
4. 用户态遍历 10000 个 fd ❌ 慢
5. 找到 10 个就绪的 fd
总操作:20000+ 次
epoll 的操作
1. fd 已经注册好(之前完成) ✅ 快
2. 内核直接返回 10 个就绪的 fd ✅ 快
3. 用户态遍历 10 个 fd ✅ 快
总操作:10 次
性能差距:2000 倍!
详细对比表
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | 位图 | 数组 | 红黑树+链表 |
| fd 限制 | 1024 | 无限制 | 无限制 |
| 拷贝次数 | 每次都拷贝 | 每次都拷贝 | 只拷贝一次 |
| 查找方式 | 遍历所有 | 遍历所有 | 事件通知 |
| 时间复杂度 | O(n) | O(n) | O(1) |
| 10000 个 fd,10 个活跃 | 遍历 10000 次 | 遍历 10000 次 | 只处理 10 个 |
| 适用场景 | 少量连接 | 中等连接 | 大量连接 |
| 跨平台 | ✅ | ✅ | ❌ (Linux) |
特性对比
| 特性 | select | poll | epoll |
|---|---|---|---|
| fd 数量限制 | 1024 | 无限制 | 无限制 |
| 数据拷贝 | 每次都拷贝 | 每次都拷贝 | 只拷贝一次 |
| 查找就绪 fd | O(n) 遍历 | O(n) 遍历 | O(1) 事件通知 |
| 性能 | 差 | 中等 | 优秀 |
| 跨平台 | 是 | 是 | 否(Linux 特有) |
实际应用
1. Nginx 使用 epoll
// Nginx 的事件循环
while (1) {
// 等待事件(可能有成千上万个连接)
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
// 只处理就绪的连接
handle_event(&events[i]);
}
}
应用场景:
浏览器1 请求网页 ──┐
浏览器2 请求图片 ──┤
浏览器3 请求视频 ──┼──> Nginx 用 epoll 监听
浏览器4 请求API ──┤
浏览器5 请求CSS ──┘
Nginx: 用 epoll 等待,哪个浏览器发来请求就处理哪个
2. Redis 使用 epoll
// Redis 的事件循环
void aeMain(aeEventLoop *eventLoop) {
while (!eventLoop->stop) {
// 使用 epoll 等待客户端请求
aeProcessEvents(eventLoop, AE_ALL_EVENTS);
}
}
应用场景:
客户端1 SET key1 ──┐
客户端2 GET key2 ──┤
客户端3 DEL key3 ──┼──> Redis 用 epoll 监听
客户端4 INCR key4 ──┤
客户端5 LPUSH key5 ─┘
Redis: 用 epoll 等待,哪个客户端发来命令就执行
3. 聊天服务器
用户A 发消息 ──┐
用户B 发消息 ──┤
用户C 发消息 ──┼──> 聊天服务器用 epoll 监听
用户D 发消息 ──┤
用户E 发消息 ──┘
服务器: 用 epoll 等待,谁发消息就转发给其他人
4. Node.js 的 libuv
Node.js 底层使用 libuv 库,在 Linux 上使用 epoll 实现事件循环:
// Node.js 代码
const server = http.createServer((req, res) => {
res.end('Hello World');
});
server.listen(3000);
// 底层使用 epoll 监听所有连接
为什么需要 I/O 多路复用?
没有 I/O 多路复用的情况
// 只能一次处理一个客户端
while (1) {
客户端 = accept(); // 等待新连接
处理(客户端); // 处理完这个客户端
关闭(客户端); // 才能处理下一个
}
问题:第二个客户端必须等第一个处理完!
使用 I/O 多路复用
// 可以同时处理多个客户端
while (1) {
就绪列表 = epoll_wait(所有客户端);
for (客户端 in 就绪列表) {
处理(客户端); // 并发处理多个
}
}
优势:所有客户端都能快速响应!
总结
核心要点
-
select、poll、epoll 的作用:让程序能够高效地同时监听多个文件描述符(socket),等待其中任何一个变为可读/可写状态
-
为什么 epoll 最快:
- 事件驱动,不是轮询
- 只返回就绪的 fd,不需要遍历所有
- 避免重复拷贝,fd 只注册一次
- 内核维护状态,用户态不需要维护 fd 集合
-
使用场景:
- select:连接数少、跨平台需求
- poll:连接数中等、需要跨平台
- epoll:高并发、大量连接、Linux 环境(推荐)
选择建议
- 如果你的服务器需要处理成千上万的并发连接,在 Linux 上毫不犹豫选择 epoll
- 如果需要跨平台(Windows、Mac、Linux),可以使用 select 或 poll
- 现代高性能服务器(Nginx、Redis、Node.js)都使用 epoll
关键数据
| 场景 | select/poll | epoll |
|---|---|---|
| 1000 个连接,10 个活跃 | 遍历 1000 次 | 只处理 10 个 |
| 10000 个连接,100 个活跃 | 遍历 10000 次 | 只处理 100 个 |
| 性能差距 | 基准 | 快 100-1000 倍 |
参考资料
更多推荐




所有评论(0)