💻CS 操作系统

IO 多路复用

难度:⭐⭐ | 高频指数:🔥🔥🔥

面试回答

常见问法

  • select、poll、epoll 有什么区别?
  • epoll 为什么比 select 高效?
  • epoll 的 ET 和 LT 模式有什么区别?
  • 什么是 Reactor 模式?
  • epoll 底层数据结构是什么?

回答

IO 多路复用是指用一个线程同时监听多个文件描述符(socket),哪个就绪就处理哪个,避免为每个连接创建一个线程。

select / poll / epoll 核心区别:

对比项selectpollepoll
数据结构fd_set(位图)pollfd 数组红黑树 + 就绪链表
fd 上限1024(FD_SETSIZE)无硬限制无硬限制
每次调用需要重新传入所有 fd需要重新传入所有 fd不需要(内核维护)
内核检查方式遍历所有 fd遍历所有 fd回调驱动,只返回就绪的
时间复杂度O(n)O(n)O(1)(就绪事件)
适用场景连接数少、跨平台连接数少大量连接(Linux)

epoll 为什么高效:

  1. 用红黑树管理所有监听的 fd,增删改都是 O(log n)
  2. 内核通过回调机制将就绪的 fd 放入就绪链表
  3. epoll_wait 只返回就绪的 fd,不需要遍历所有
  4. fd 集合在内核维护,不需要每次系统调用都拷贝

追问

1. ET 和 LT 模式的区别?

  • LT(Level Triggered,水平触发):只要 fd 就绪就会一直通知,直到你处理完。默认模式,编程简单,不容易丢事件。
  • ET(Edge Triggered,边缘触发):只在状态变化时通知一次。必须一次性读完所有数据(循环读到 EAGAIN),否则不会再通知。性能更高但编程复杂。

2. ET 模式为什么要配合非阻塞 IO?

因为 ET 只通知一次,你必须循环读直到返回 EAGAIN。如果 fd 是阻塞的,读完数据后最后一次 read 会阻塞住线程,而不是返回 EAGAIN。

3. Reactor 模式是什么?

Reactor 是一种事件驱动的设计模式:

  • 主线程只负责监听事件(epoll_wait)
  • 事件就绪后分发给对应的处理器(handler)
  • 处理器完成实际的读写和业务逻辑

常见变体:单 Reactor 单线程、单 Reactor 多线程、主从 Reactor(Netty、muduo 的模型)。


原理展开

1. select 的工作方式

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);

// 阻塞等待,直到有 fd 就绪
int ret = select(maxfd + 1, &readfds, NULL, NULL, &timeout);

// 需要遍历所有 fd 检查哪个就绪
for (int i = 0; i <= maxfd; i++) {
    if (FD_ISSET(i, &readfds)) {
        // 处理就绪的 fd
    }
}

select 的问题:

  • fd_set 是固定大小的位图,默认最多 1024 个 fd
  • 每次调用都要把 fd_set 从用户态拷贝到内核态
  • 内核遍历所有 fd 检查就绪状态
  • 返回后用户还要再遍历一次找到就绪的 fd

2. poll 的改进

struct pollfd fds[MAX_EVENTS];
fds[0].fd = sockfd;
fds[0].events = POLLIN;

int ret = poll(fds, nfds, timeout);

for (int i = 0; i < nfds; i++) {
    if (fds[i].revents & POLLIN) {
        // 处理就绪的 fd
    }
}

相比 select 的改进:

  • 用 pollfd 数组代替位图,没有 1024 的限制
  • 输入输出分离(events/revents),不需要每次重新设置

仍然存在的问题:

  • 每次调用仍需拷贝整个数组到内核
  • 内核仍然是遍历所有 fd

3. epoll 的三个系统调用

// 1. 创建 epoll 实例
int epfd = epoll_create1(0);

// 2. 注册/修改/删除监听的 fd
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;  // ET 模式
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

// 3. 等待就绪事件
struct epoll_event events[MAX_EVENTS];
int nready = epoll_wait(epfd, events, MAX_EVENTS, timeout);

// 只遍历就绪的 fd
for (int i = 0; i < nready; i++) {
    int fd = events[i].data.fd;
    // 处理...
}

4. epoll 内部数据结构

epoll 实例
├── 红黑树(rbr):存储所有监听的 fd
│   - 增删改:O(log n)
│   - 用于 epoll_ctl 操作

└── 就绪链表(rdllist):存储已就绪的 fd
    - 内核通过回调将就绪 fd 加入链表
    - epoll_wait 只需要检查这个链表

回调机制:

  • 当 fd 对应的设备有数据到达时,内核通过回调函数将该 fd 加入就绪链表
  • 这样 epoll_wait 不需要遍历所有 fd,直接从就绪链表取

5. ET vs LT 详细对比

// LT 模式(默认)
// 如果 socket 有 1000 字节数据,你只读了 500 字节
// 下次 epoll_wait 还会返回这个 fd(因为还有数据可读)

// ET 模式
// 如果 socket 有 1000 字节数据,你只读了 500 字节
// 下次 epoll_wait 不会返回这个 fd(直到有新数据到达)
// 所以必须循环读完:
while (true) {
    int n = read(fd, buf, sizeof(buf));
    if (n == -1) {
        if (errno == EAGAIN) break;  // 读完了
        // 处理错误
    }
    if (n == 0) break;  // 对端关闭
    // 处理数据
}

ET 模式的优势:

  • 减少 epoll_wait 返回的次数
  • 减少系统调用开销
  • 适合高性能服务器

ET 模式的要求:

  • fd 必须设置为非阻塞(O_NONBLOCK)
  • 必须循环读/写直到 EAGAIN
  • 编程复杂度更高

6. Reactor 模式实现框架

主从 Reactor 模型(muduo/Netty):

Main Reactor(主线程)
├── epoll_wait 监听 listen socket
├── 接受新连接(accept)
└── 将新连接分配给 Sub Reactor

Sub Reactor(工作线程,通常 CPU 核数个)
├── epoll_wait 监听分配到的连接
├── 读取数据
├── 调用业务处理函数
└── 写回响应

为什么主从 Reactor 高效:

  • 主线程只做 accept,不会成为瓶颈
  • 多个 Sub Reactor 并行处理 IO
  • 每个 Sub Reactor 独立的 epoll,无锁竞争
  • 业务逻辑可以进一步放到线程池

7. 实际代码框架(简化版)

// 简化的 epoll 服务器框架
int epfd = epoll_create1(0);
// 添加 listen socket
add_event(epfd, listen_fd, EPOLLIN);

while (true) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == listen_fd) {
            // 新连接
            int conn_fd = accept(listen_fd, ...);
            set_nonblocking(conn_fd);
            add_event(epfd, conn_fd, EPOLLIN | EPOLLET);
        } else if (events[i].events & EPOLLIN) {
            // 可读事件
            handle_read(events[i].data.fd);
        } else if (events[i].events & EPOLLOUT) {
            // 可写事件
            handle_write(events[i].data.fd);
        }
    }
}

易错点

  • 说”epoll 在任何场景都比 select 好”——连接数少时 select 可能更快(没有红黑树开销),而且 select 跨平台。
  • 混淆 ET 和 LT 的行为——LT 是”只要有数据就通知”,ET 是”有新数据才通知”。
  • ET 模式忘记设置非阻塞——这会导致最后一次 read 阻塞住线程。
  • 说”epoll 是异步 IO”——epoll 是同步的 IO 多路复用,不是异步 IO(AIO 才是)。
  • 忘记 epoll 的惊群问题——多个线程 epoll_wait 同一个 epfd,新连接到来时可能多个线程被唤醒。
  • 不知道 epoll 的适用平台——epoll 是 Linux 特有的,macOS 用 kqueue,Windows 用 IOCP。

记忆技巧

  • 三者演进:select(位图、1024 限制)→ poll(数组、无限制)→ epoll(红黑树+回调)
  • epoll 三步曲:create → ctl → wait
  • ET vs LT 一句话:LT 像闹钟一直响,ET 像闹钟只响一次
  • epoll 高效原因:红黑树管 fd + 回调填就绪链表 + wait 只取就绪的
  • Reactor 口诀:主线程监听分发,工作线程处理 IO

面试速答版

IO 多路复用用一个线程监听多个 fd,核心是 select/poll/epoll。select 用位图,有 1024 限制,每次要拷贝所有 fd 到内核并遍历。poll 用数组去掉了数量限制但仍需遍历。epoll 用红黑树管理 fd、回调机制将就绪 fd 放入链表,epoll_wait 只返回就绪的,时间复杂度 O(就绪数)。epoll 有 LT 和 ET 两种模式,ET 只在状态变化时通知一次,必须配合非阻塞 IO 循环读完。实际高性能服务器用主从 Reactor 模式:主线程 accept 新连接,分配给多个 Sub Reactor 线程处理 IO。

Related · 操作系统