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) 复杂度)              |

缺点

  1. fd 数量限制:fd_set 是固定大小的位图,通常限制为 1024
  2. 重复拷贝:每次调用都要把 fd_set 从用户态拷贝到内核态
  3. 两次遍历:内核遍历一次检查,用户态遍历一次找结果
  4. 性能差:监听 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() 直接返回就绪列表

核心优势

  1. 事件驱动:不是轮询,而是事件通知
  2. 只返回就绪的:不需要遍历所有 fd
  3. 避免重复拷贝:fd 只注册一次
  4. 内核维护状态:用户态不需要维护 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 就绪列表) {
        处理(客户端);  // 并发处理多个
    }
}

优势:所有客户端都能快速响应!

总结

核心要点

  1. select、poll、epoll 的作用:让程序能够高效地同时监听多个文件描述符(socket),等待其中任何一个变为可读/可写状态

  2. 为什么 epoll 最快

    • 事件驱动,不是轮询
    • 只返回就绪的 fd,不需要遍历所有
    • 避免重复拷贝,fd 只注册一次
    • 内核维护状态,用户态不需要维护 fd 集合
  3. 使用场景

    • select:连接数少、跨平台需求
    • poll:连接数中等、需要跨平台
    • epoll:高并发、大量连接、Linux 环境(推荐)

选择建议

  • 如果你的服务器需要处理成千上万的并发连接,在 Linux 上毫不犹豫选择 epoll
  • 如果需要跨平台(Windows、Mac、Linux),可以使用 selectpoll
  • 现代高性能服务器(Nginx、Redis、Node.js)都使用 epoll

关键数据

场景 select/poll epoll
1000 个连接,10 个活跃 遍历 1000 次 只处理 10 个
10000 个连接,100 个活跃 遍历 10000 次 只处理 100 个
性能差距 基准 快 100-1000 倍

参考资料

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐