💻CS 操作系统
IO 多路复用
难度:⭐⭐ | 高频指数:🔥🔥🔥
面试回答
常见问法
- select、poll、epoll 有什么区别?
- epoll 为什么比 select 高效?
- epoll 的 ET 和 LT 模式有什么区别?
- 什么是 Reactor 模式?
- epoll 底层数据结构是什么?
回答
IO 多路复用是指用一个线程同时监听多个文件描述符(socket),哪个就绪就处理哪个,避免为每个连接创建一个线程。
select / poll / epoll 核心区别:
| 对比项 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set(位图) | pollfd 数组 | 红黑树 + 就绪链表 |
| fd 上限 | 1024(FD_SETSIZE) | 无硬限制 | 无硬限制 |
| 每次调用 | 需要重新传入所有 fd | 需要重新传入所有 fd | 不需要(内核维护) |
| 内核检查方式 | 遍历所有 fd | 遍历所有 fd | 回调驱动,只返回就绪的 |
| 时间复杂度 | O(n) | O(n) | O(1)(就绪事件) |
| 适用场景 | 连接数少、跨平台 | 连接数少 | 大量连接(Linux) |
epoll 为什么高效:
- 用红黑树管理所有监听的 fd,增删改都是 O(log n)
- 内核通过回调机制将就绪的 fd 放入就绪链表
epoll_wait只返回就绪的 fd,不需要遍历所有- 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 · 操作系统