Appearance
IO 多路复用
IO 多路复用是 Redis 高性能的核心技术之一。但很多人在聊"多路复用"时,其实并不真正理解它解决了什么问题、怎么解决的。本文从底层原理出发,把 IO 多路复用彻底讲透。
一、没有 IO 多路复用之前,世界是什么样的?
1.1 BIO(阻塞 IO)
一个连接一个线程:
客户端 A ──→ Thread 1 ──→ read() 阻塞等待
客户端 B ──→ Thread 2 ──→ read() 阻塞等待
客户端 C ──→ Thread 3 ──→ read() 阻塞等待
...
问题:
- 1000 个连接 = 1000 个线程
- 大多数线程在空等(没有数据可读)
- 线程切换消耗大量 CPU
- 内存开销:每个线程 1MB 栈空间 → 1000 线程 = 1GB 内存1.2 NIO(非阻塞 IO)
轮询模式:
while (true) {
for (每个 socket) {
if (socket 有数据) {
read(socket);
}
}
}
问题:
- 虽然一个线程能处理多个连接了
- 但需要不断轮询,即使 1000 个连接中只有 1 个有数据
- 999 次无意义的系统调用
- CPU 空转,浪费资源这就是 IO 多路复用要解决的问题: 让一个线程高效地监控多个连接,只在有数据时才处理,不空转。
二、IO 多路复用的核心思想
传统方式:你主动去问每个人"你有事吗?"
IO 多路复用:你坐下来,谁有事谁来找你
┌────────────────────────────────────────────────┐
│ IO 多路复用 │
│ │
│ 一个线程监控 1000 个 socket │
│ │ │
│ ▼ │
│ epoll_wait() ← 阻塞等待 │
│ │ │
│ ▼ │
│ "socket[3] 和 socket[7] 有数据" │
│ │ │
│ ▼ │
│ 只处理 socket[3] 和 socket[7] │
│ │ │
│ ▼ │
│ 继续 epoll_wait() │
└────────────────────────────────────────────────┘三、三剑客:select / poll / epoll
3.1 select
c
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
// fd_set 是一个位图,默认大小 1024 位
// 每次调用需要把整个 fd_set 从用户态拷贝到内核态
// 内核遍历所有 fd,检查哪些就绪
// 返回后,用户态遍历所有 fd,找出就绪的select 的问题:
1. fd 数量限制:默认 1024(FD_SETSIZE)
要支持更多连接,需要重新编译内核
2. 每次都要拷贝整个 fd_set
1000 个连接,每次 select 都要拷贝 1000 个 fd 到内核
3. 内核需要遍历所有 fd
即使只有 1 个 fd 就绪,也要遍历 1000 个
4. 用户态也需要遍历所有 fd
返回值只是"有几个就绪",不告诉你具体是谁
你还得自己遍历一遍3.2 poll
c
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 监听的事件
short revents; // 返回的事件
};
// 用链表代替了位图,没有 1024 的限制
// 但本质上还是遍历所有 fdpoll 的改进:
✅ 没有 fd 数量限制(链表存储)
❌ 仍然需要遍历所有 fd(O(n))
❌ 每次仍要拷贝整个 pollfd 数组3.3 epoll(Linux 专属,Redis 使用)
c
// 1. 创建 epoll 实例(返回一个 fd)
int epfd = epoll_create(int size);
// 2. 注册要监听的 fd(告诉内核"我关心这个 fd 的哪些事件")
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 3. 等待事件(阻塞直到有事件发生)
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll 的核心优势:
1. 事件驱动,不是轮询
fd 就绪时,内核通过回调函数将它加入就绪队列
不需要遍历所有 fd,O(1) 获取就绪事件
2. 只返回就绪的 fd
epoll_wait 返回的 events 数组里全是就绪的 fd
用户态直接处理,不需要再遍历
3. 内存映射(mmap)
epoll 使用 mmap 共享内存,避免用户态/内核态拷贝
4. 两种触发模式
LT(水平触发):只要 fd 还有数据没读完,下次 epoll_wait 还会通知
ET(边缘触发):只在 fd 状态变化时通知一次,必须一次性读完四、epoll 底层原理
4.1 核心数据结构
epoll 使用红黑树 + 就绪队列:
┌─────────────────────────────────────────────────────┐
│ epoll 实例 │
│ │
│ ┌──────────────────────┐ │
│ │ 红黑树(rbr) │ ← 存储所有被监听的 fd │
│ │ │ 增删改查 O(log N) │
│ │ fd1, fd2, ..., fdN │ │
│ └──────────────────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ 就绪队列(rdllist) │ ← 存储就绪的 fd │
│ │ │ epoll_wait 直接从这里取 │
│ │ fd3, fd7 │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────┘4.2 工作流程
1. epoll_create()
→ 内核创建 eventpoll 对象(红黑树 + 就绪队列)
2. epoll_ctl(ADD, fd, events)
→ 将 fd 加入红黑树
→ 给内核中断处理程序注册回调函数
→ 回调函数:当 fd 就绪时,将其加入就绪队列
3. 数据到达网卡
→ 网卡触发硬件中断
→ 内核处理网络数据
→ 发现数据属于 fd3
→ 调用回调函数:将 fd3 加入就绪队列
→ 如果 epoll_wait 在阻塞,唤醒它
4. epoll_wait()
→ 检查就绪队列
→ 如果有数据,返回就绪的 fd 列表
→ 如果没有数据,阻塞等待(可设置超时)五、Redis 如何使用 epoll?
5.1 Redis 的事件驱动模型
c
// Redis 的事件循环(简化版)
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
// 处理时间事件(定时任务)
aeProcessTimeEvents(eventLoop);
// 处理文件事件(网络 IO)
aeProcessFileEvents(eventLoop);
}
}
// 文件事件处理
int aeProcessFileEvents(aeEventLoop *eventLoop) {
// 调用 epoll_wait
int numevents = aeApiPoll(eventLoop, tvp);
for (int i = 0; i < numevents; i++) {
// 从就绪事件中获取 fd 和事件类型
int fd = eventLoop->fired[i].fd;
int mask = eventLoop->fired[i].mask;
// 可读事件
if (mask & AE_READABLE) {
eventLoop->events[fd].rfileProc(eventLoop, fd, ...);
}
// 可写事件
if (mask & AE_WRITABLE) {
eventLoop->events[fd].wfileProc(eventLoop, fd, ...);
}
}
}5.2 Redis 事件类型
Redis 使用 epoll 处理两类事件:
文件事件(File Event):
- 客户端连接(accept)
- 客户端请求(read)
- 响应发送(write)
→ 通过 epoll 监控 Socket fd
时间事件(Time Event):
- 定时任务(serverCron:过期 key 清理、AOF 重写等)
- 一次性任务
→ 通过时间轮/最小堆管理六、LT 和 ET 的区别
| 维度 | LT(水平触发) | ET(边缘触发) |
|---|---|---|
| 触发方式 | 有数据就通知 | 状态变化时通知一次 |
| 未读完 | 下次继续通知 | 不会再次通知 |
| 编程难度 | 低 | 高(必须一次性读完) |
| 系统调用 | 多(可能重复通知) | 少 |
| Redis 使用 | ✅ 默认使用 | ❌ |
Redis 为什么用 LT?
1. 简单可靠:不用担心漏读数据
2. Redis 的单线程模型:处理完一个请求才会处理下一个
如果用 ET,一次没读完,这个 fd 就永久丢失了
3. 性能差异不大:Redis 的工作负载下,LT 和 ET 的性能差异可忽略七、常见误区
误区一:IO 多路复用 = 异步 IO
❌ 错误:epoll 是异步 IO
✅ 正确:epoll 是 IO 多路复用,不是异步 IO
IO 多路复用:你问内核"哪些 fd 就绪了?",然后自己 read
异步 IO:你告诉内核"帮我读数据,读完了通知我",不需要自己 read
Redis 是多路复用 + 非阻塞 IO,不是异步 IO误区二:epoll 一定比 select 快
❌ 错误:epoll 在任何场景下都比 select 快
✅ 正确:连接数少时,select 可能更快
select 的位图操作在 fd 少时非常高效
epoll 的系统调用开销(epoll_ctl)在 fd 少时反而更大
连接数 < 100 时,select 和 epoll 性能差异不大
连接数 > 1000 时,epoll 的优势才明显误区三:Redis 快只是因为 IO 多路复用
❌ 错误:Redis 快的唯一原因是 IO 多路复用
✅ 正确:IO 多路复用只是其中一个因素
Redis 快的完整原因:
1. 纯内存操作(占比最大)
2. 单线程无锁竞争
3. IO 多路复用(epoll)
4. 高效的数据结构
5. RESP 简单协议八、面试追问
Q1:epoll 的红黑树存的是什么?为什么用红黑树?
存的是被监听的所有 fd(socket 文件描述符)。红黑树增删查都是 O(log N),适合频繁的 epoll_ctl 操作(连接建立/断开时添加/删除 fd)。
Q2:epoll 的就绪队列是什么数据结构?
双向链表。就绪事件按 FIFO 顺序入队,epoll_wait 从链表头部取事件。链表插入 O(1),不需要排序。
Q3:一个进程能创建多少个 epoll 实例?能监听多少个 fd?
epoll 实例数量受进程 fd 限制(每个 epoll 实例也是一个 fd)。单个 epoll 实例能监听的 fd 数量受系统内存限制,理论上可以到百万级别。/proc/sys/fs/epoll/max_user_watches 可查看上限。
Q4:Java NIO 的 Selector 底层也是 epoll 吗?
是的。Java NIO 的 Selector 在 Linux 上底层就是 epoll。Netty、Tomcat NIO、Spring WebFlux 都是基于此。Java 的 Selector 和 Redis 的 epoll 本质上是同一套机制。
