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 倍!

详细对比表

维度selectpollepoll
数据结构位图数组红黑树+链表
fd 限制1024无限制无限制
拷贝次数每次都拷贝每次都拷贝只拷贝一次
查找方式遍历所有遍历所有事件通知
时间复杂度O(n)O(n)O(1)
10000 个 fd,10 个活跃遍历 10000 次遍历 10000 次只处理 10 个
适用场景少量连接中等连接大量连接
跨平台❌ (Linux)

特性对比

特性selectpollepoll
fd 数量限制1024无限制无限制
数据拷贝每次都拷贝每次都拷贝只拷贝一次
查找就绪 fdO(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/pollepoll
1000 个连接,10 个活跃遍历 1000 次只处理 10 个
10000 个连接,100 个活跃遍历 10000 次只处理 100 个
性能差距基准快 100-1000 倍

参考资料

Logo

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

更多推荐