Skip to content

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 的限制
// 但本质上还是遍历所有 fd

poll 的改进:

✅ 没有 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 本质上是同一套机制。