I/O 多路复用(I/O Multiplexing)是一种在计算机网络编程中至关重要的技术,也是构建高性能服务器的基石。


1. 背景:为什么需要 I/O 多路复用?

想象一下你要开发一个网络服务器,需要同时处理多个客户端的连接。我们来看看几种最原始的模型以及它们的缺陷。

模型一:阻塞 I/O + 多进程/多线程

这是最直观的模型。主进程负责监听(listen)新的连接请求。每当有一个新的客户端连接进来(accept),服务器就创建一个新的进程或线程专门为这个客户端服务。

  • 工作流程

    1. 主线程/进程 accept() 等待新连接,阻塞。
    2. 新连接到达,accept() 返回一个新的socket文件描述符(fd)。
    3. 创建一个子线程/进程,将这个新的fd交给它处理。
    4. 子线程/进程在这个fd上调用 read()/recv(),等待客户端发送数据,阻塞。
    5. 数据到达,read() 返回,处理数据,然后可能调用 write()/send(),也可能阻塞。
    6. 主线程/进程继续循环,等待下一个连接。
  • 缺点

    • 资源开销巨大:每来一个连接就要创建一个线程/进程。线程需要栈空间(通常是 MB 级别),进程开销更大。当连接数成千上万时(即著名的 C10K 问题),系统资源会迅速耗尽。
    • 上下文切换频繁:大量的线程/进程会导致 CPU 在它们之间频繁切换,这本身就是一笔巨大的性能开销。

模型二:非阻塞 I/O + 忙轮询 (Busy-Polling)

为了解决阻塞问题,我们可以将 socket 设置为非阻塞模式。

  • 工作流程

    1. 将监听 socket 和所有已连接的 socket 都设置为非阻塞。
    2. 用一个循环,不断地遍历所有的 socket 文件描述符。
    3. 对监听 socket 尝试调用 accept(),如果有新连接就处理,没有就立即返回错误(如 EWOULDBLOCK)。
    4. 对已连接的 socket 尝试调用 read(),如果有数据就处理,没有就立即返回错误。
  • 缺点

    • CPU 100%:无论 socket 是否活跃,这个循环都在疯狂地、无差别地遍历所有 socket,进行大量的无效系统调用。这会导致 CPU 使用率飙升,大部分时间都在做无用功。

这两种模型都有致命的缺陷。我们需要一种更优雅的方式:既能避免为每个连接创建线程,又能避免 CPU 空转。


2. 核心思想:它到底是什么?

I/O 多路复用 就是为了解决上述问题而生的。

它的核心思想可以概括为:将“等待”这个任务交给操作系统内核去做。

  • “I/O”:指的是网络 I/O,如 readwrite
  • “多路”:指的是多个网络连接,即多个 socket 文件描述符(fd)。
  • “复用”:指的是复用同一个线程(或少量线程)来处理这些连接。

技术上讲,I/O 多路复用是一种同步I/O模型。 它的关键是一个能够同时监视多个文件描述符的系统调用(如 select, poll, epoll)。这个调用会阻塞,直到一个或多个 fd 变为“就绪”状态(比如可读、可写、或出错)。当调用返回时,它会告诉我们哪些 fd 已经准备好了,然后我们的单个线程就可以去依次处理这些就绪的 fd,而不会在未就绪的 fd 上浪费时间。


3. 三种主要实现:select, poll, epoll

这三种是 Linux 系统下 I/O 多路复用的主要实现机制。

A. select

select 是最早的、最通用的 I/O 多路复用实现,几乎所有操作系统都支持。

  • 工作方式

    1. 你需要创建三个文件描述符集合(fd_set),分别用于监视可读可写异常事件。fd_set 本质上是一个位图(bitmask)。
    2. 将你关心的 fd 添加到相应的集合中。
    3. 调用 select(max_fd + 1, &read_fds, &write_fds, &except_fds, &timeout)
    4. select 函数会阻塞,直到有 fd 就绪或者超时。
    5. 函数返回后,fd_set 会被内核修改,只有那些就绪的 fd 对应的位仍然是 1。
    6. 你需要遍历整个 fd_set(从 0 到 max_fd),检查哪个 fd 对应的位是 1,然后处理它。
  • 缺点

    1. 最大连接数限制fd_set 的大小是固定的(通常是 1024 或 2048),由 FD_SETSIZE 宏定义,这限制了 select 能同时监视的 fd 数量。
    2. 重复的数据拷贝:每次调用 select 前,都需要重新构建 fd_set,因为内核会修改它。并且,每次调用时,都需要将这个 fd_set 从用户空间完整地拷贝到内核空间。连接数越多,拷贝开销越大。
    3. 线性扫描开销select 返回后,你只知道“有 fd 就绪了”,但不知道是哪几个。你必须遍历所有被监视的 fd 来找到就绪的那些。如果监视了 1000 个 fd,但只有 1 个就绪,你仍然要做 1000 次检查。这个开销是 O(N),N 是被监视的 fd 总数。

B. poll

poll 是对 select 的改进,解决了 select 的一些问题。

  • 工作方式

    1. poll 不再使用 fd_set,而是使用一个 pollfd 结构体数组。每个 pollfd 结构体包含 fd、关心的事件(events)和返回的实际发生的事件(revents)。
    2. 你创建一个 pollfd 数组,填入你关心的 fd 和事件。
    3. 调用 poll(fds_array, num_fds, timeout)
    4. poll 返回后,你遍历这个数组,检查每个 pollfd 结构体的 revents 字段,来判断该 fd 是否就绪。
  • 相比 select 的改进

    1. 无最大连接数限制:它使用数组而非位图,理论上可以监视的 fd 数量只受限于系统内存。
  • 仍然存在的缺点

    1. 重复的数据拷贝:每次调用 poll,仍然需要将整个 pollfd 数组从用户空间拷贝到内核空间。
    2. 线性扫描开销poll 返回后,你仍然需要遍历整个 pollfd 数组来找到就绪的 fd。开销依然是 O(N)。

C. epoll (Linux 特有)

epoll 是对 selectpoll 的重大改进,是 Linux 下实现高性能网络服务器的首选,它彻底解决了前两者的核心性能瓶瓶颈。

  • 工作方式epoll 将其功能分成了三个独立的系统调用。

    1. epoll_create(): 在内核中创建一个 epoll 实例,并返回一个指向该实例的文件描述符(epoll_fd)。这个实例内部维护了需要监视的 fd 集合(通常是红黑树)和就绪 fd 列表(链表)。这个创建动作只需要一次
    2. epoll_ctl(): 用于向 epoll 实例中添加EPOLL_CTL_ADD)、修改EPOLL_CTL_MOD)或删除EPOLL_CTL_DEL)需要监视的 fd。每个 fd 只需要通过 epoll_ctl 添加一次,之后它就一直在内核的监视列表中,无需在每次等待时重复提交。
    3. epoll_wait(): 等待事件发生,类似于 selectpoll。但它返回时,只会返回那些已经就绪的 fd,而不是所有被监视的 fd。它会将就绪的 fd 列表从内核空间拷贝到用户空间的一个数组里。
  • 相比 selectpoll 的巨大优势

    1. 无需重复拷贝fd 列表通过 epoll_ctl 维护在内核中,调用 epoll_wait 时无需重复拷贝整个列表。
    2. 没有线性扫描epoll 的核心是事件驱动。当某个 fd 就绪时,内核会通过回调机制将其加入到一个就绪链表中。epoll_wait 只是检查这个链表是否为空,如果不为空,就返回链表中的内容。因此,其时间复杂度是 O(1)(或者说 O(k),k 为就绪的 fd 数量),与被监视的 fd 总数无关!
    3. 内存共享 (mmap): epoll 通过 mmap 技术让内核和用户空间共享一块内存,进一步避免了不必要的内存拷贝。

4. 详细对比:select vs poll vs epoll

特性 select poll epoll
底层数据结构 位图 (Bitmask) 结构体数组 (Array of structs) 红黑树 + 双向链表
FD 数量限制 有,通常是 1024 (FD_SETSIZE) 无 (受限于内存) 无 (受限于内存)
用户/内核拷贝 每次调用都拷贝整个 fd_set 每次调用都拷贝整个 pollfd 数组 初始 epoll_ctl 时拷贝,epoll_wait 只拷贝就绪的 fd
内核扫描方式 线性扫描所有被监视的 fd 线性扫描所有被监视的 fd 事件回调,只处理活跃的 fd
效率 O(N) O(N) O(1) 或 O(k) (k为活跃连接数)
可移植性 非常好,所有主流系统支持 较好,大部分 Unix-like 系统支持 差,仅 Linux 2.6+ 支持
API 使用 简单,但每次调用需重置 fd_set 简单 稍复杂,分为 create, ctl, wait

总结:在连接数少、且都比较活跃的情况下,selectpoll 的性能尚可。但在高并发、大量连接(其中大部分是空闲的)场景下,epoll 的性能优势是压倒性的。


5. 工作模式:水平触发 (LT) vs 边沿触发 (ET)

首先,我们要明白“触发”指的是什么。在 epoll 的语境下,“触发”是指当一个被监视的文件描述符(fd)满足了我们所关心的事件(如可读、可写)时,epoll_wait 会被唤醒并返回该 fd

LT 和 ET 的根本区别在于 epoll_wait 被唤醒并返回的“时机”和“条件”

A、 水平触发 (Level Triggered, LT)

LT 是 epoll默认工作模式。它的行为模式非常直观,也和传统的 selectpoll 类似。

只要文件描述符处于某个我们关心的状态(State),epoll_wait 就会一直触发通知。

  • 对于读事件:只要该 fd 的内核接收缓冲区里有数据(缓冲区大小 > 0),epoll_wait 每次被调用时都会返回这个 fd,告诉你“嘿,这里有数据,你可以读了!”。
  • 对于写事件:只要该 fd 的内核发送缓冲区没有满(还有空间可以写入),epoll_wait 每次被调用时都会返回这个 fd,告诉你“嘿,这里有地方,你可以写了!”。

假设一个客户端发送了 1000 字节的数据到服务器。

  1. 数据到达:1000 字节数据进入了服务器端 socket 的内核接收缓冲区。
  2. 第一次触发:应用程序调用 epoll_wait,它会立即返回,报告这个 socket fd 是可读的。
  3. 不完整的处理:你的应用程序代码从这个 fd 中只读取了 500 字节,因为你程序里的 buffer 只有这么大。此时,内核缓冲区里还剩下 500 字节
  4. 再次调用 epoll_wait:当你的事件循环下一次调用 epoll_wait 时,由于内核缓冲区里仍然有数据(满足“可读”状态),epoll_wait再一次立即返回,告诉你这个 fd 还是可读的。
  5. 继续处理:你的程序再次读取剩下的 500 字节。现在内核缓冲区空了。
  6. 停止触发:再下一次调用 epoll_wait 时,由于“可读”状态已经不存在,它将不会再因为这个 fd 而返回(除非有新数据到达)。
  • 优点

    • 编程简单,不易出错:它的逻辑非常稳健(robust)。即使你因为某些原因没有一次性把数据处理完,也不用担心数据会“丢失”事件通知。下一次循环 epoll 还会提醒你,给了你“亡羊补牢”的机会。
    • select/poll 兼容:行为模式一致,从旧模型迁移过来比较容易。
  • 缺点

    • 可能导致不必要的唤醒:如果你的程序逻辑有问题,一直不去处理某个 fd 上的数据,epoll_wait 会被反复地、无效地唤醒,造成一点性能开销(尽管这个开销远小于 select 的 O(N) 扫描)。这在某种程度上是“惊群效应”的微缩版。

B、 边沿触发 (Edge Triggered, ET)

ET 是一种更高性能但编程要求也更高的模式。要使用 ET 模式,你需要在 epoll_ctl 添加 fd 时明确指定 EPOLLET 标志。

只有当文件描述符的状态发生变化(Transition / Edge)时,epoll_wait 才会触发一次通知。

  • 对于读事件:只有当 fd 的内核接收缓冲区从空变为非空的那一刻,epoll_wait 才会返回这个 fd。之后,即使缓冲区里还有数据,只要没有的数据到达,epoll_wait不会再返回这个 fd
  • 对于写事件:只有当 fd 的内核发送缓冲区从满变为非满的那一刻,epoll_wait 才会返回这个 fd

同样,客户端发送了 1000 字节的数据到服务器。

  1. 数据到达:1000 字节数据进入内核缓冲区,状态从空 -> 非空,一个“边沿”产生了。
  2. 第一次触发:应用程序调用 epoll_wait,它会返回,报告这个 socket fd 可读。
  3. 不完整的处理:你的应用程序只读取了 500 字节。内核缓冲区里还剩下 500 字节
  4. 再次调用 epoll_wait:当你的事件循环下一次调用 epoll_wait 时,尽管缓冲区里还有数据,但因为没有新的“边沿”产生(状态没有从空变为非空),epoll_wait 将会阻塞不会因为这个 fd 而返回!
  5. 后果:剩下的 500 字节数据就被“遗忘”在缓冲区里了。你的程序将永远不会收到关于这 500 字节的通知,直到客户端发送下一批新数据,从而产生一个新的“边沿”。这就导致了数据处理的延迟甚至丢失。

为了避免上述问题,使用 ET 模式时必须遵循一个黄金法则:

当收到一个 ET 事件通知时,你必须持续地对该 fd 进行 I/O 操作,直到该操作返回 EAGAINEWOULDBLOCK 错误为止。

这意味着:

  • 文件描述符必须设置为非阻塞(O_NONBLOCK。这是绝对的前提,否则当缓冲区读完后,最后一次 read() 调用会永远阻塞你的程序。
  • 使用循环来处理 I/O

正确的ET读操作代码伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// epoll_wait 返回了就绪的 fd
while (1) {
// 循环读取
bytes_read = read(fd, buffer, sizeof(buffer));

if (bytes_read == -1) {
// 如果错误是 EAGAIN 或 EWOULDBLOCK,说明数据已经读完了
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 这不是一个真正的错误,只是一个信号:ET事件处理完毕
break;
} else {
// 其他错误,表示连接出错了
perror("read");
close(fd);
break;
}
} else if (bytes_read == 0) {
// 对端关闭了连接
close(fd);
break;
} else {
// 成功读取了 bytes_read 字节的数据,处理它
process_data(buffer, bytes_read);
}
}

写操作同理,需要在一个循环里 write(),直到数据全部写完或者 write() 返回 EAGAIN

  • 优点

    • 性能极高:它极大地减少了 epoll_wait 被唤醒的次数,从而降低了系统调用的开销。一个事件只通知一次,非常高效,特别是在有大量连接但只有少数活跃的场景下。Nginx、Redis 等高性能组件都重度使用 ET 模式。
    • 避免“惊群”:在多线程环境下,一个事件不会被多个线程同时处理,因为只有一个线程会收到该事件的通知。
  • 缺点

    • 编程复杂度高:逻辑比 LT 复杂得多,非常容易出错。如果忘记了循环处理 I/O 直到 EAGAIN,就会导致数据饥饿(starvation)或连接假死。
    • 对程序员要求高:需要对非阻塞 I/O 和事件循环有深刻的理解。

C、 总结与对比

特性 水平触发 (Level Triggered, LT) 边沿触发 (Edge Triggered, ET)
触发条件 只要满足条件(缓冲区有数据/有空间),就持续触发 仅在状态发生改变(空->非空,满->非满)时,触发一次
工作模式 状态驱动 (State-driven) 变化驱动 (Transition-driven)
处理要求 可以不一次性处理完所有数据,下次还会收到通知 必须一次性处理完所有数据,直到返回 EAGAIN/EWOULDBLOCK
依赖 无特殊依赖 必须与非阻塞 I/O (O_NONBLOCK) 配合使用
编程复杂度 ,逻辑简单,容错性好 ,逻辑复杂,易出错(如数据饥饿)
性能 较好,但可能有不必要的唤醒 极高,系统调用次数最少
适用场景 绝大多数常规场景,追求稳健和开发效率 追求极致性能的场景,如高性能服务器 (Nginx)、中间件 (Redis)
比喻 水位传感器(持续报警) 门铃(只响一次)
  • 如果你不确定用哪个,或者你是初学者,请使用默认的 LT 模式。 它的稳健性可以帮你避免很多难以调试的 bug。
  • 只有当你明确知道你的应用瓶颈在于 epoll 的唤醒次数,并且你对非阻塞 I/O 的编程模型有十足的把握时,才考虑使用 ET 模式。 ET 是为追求极致性能的专家准备的“利器”,用不好很容易伤到自己。

6. 优缺点与应用场景

I/O 多路复用的优点

  • 高并发,低资源消耗:可以用极少的线程(甚至单线程)处理成千上万的并发连接,极大地节省了内存和 CPU 上下文切换的开销。
  • 统一事件源:可以将网络 I/O、文件 I/O、信号等多种事件源放在一起统一处理。

I/O 多路复用的缺点

  • 编程复杂度高:相比简单的多线程阻塞模型,使用 select/poll/epoll 需要自己管理连接状态,编写状态机,处理起来更复杂,尤其是 epoll 的 ET 模式。
  • 不适用于 CPU 密集型任务:如果每个连接的业务逻辑需要大量的 CPU 计算,那么单线程模型会导致所有其他连接被阻塞。在这种情况下,”I/O线程 + 工作线程池” 的模式会更合适。

经典应用

I/O 多路复用是所有高性能网络框架和服务器的基石:

  • Nginx:经典的高性能 Web 服务器,其事件驱动模型就是基于 epoll
  • Redis:著名的内存数据库,其单线程却能实现极高 QPS 的原因就在于它采用了 epoll 进行 I/O 多路复用。
  • Node.js:其异步、非阻塞的特性底层就是由 libuv 库实现的,而 libuv 在 Linux 上正是封装了 epoll
  • Netty (Java):Java NIO 的高性能网络框架,其 Selector 机制也是对 select/poll/epoll 的封装。