I/O多路复用
I/O 多路复用(I/O Multiplexing)是一种在计算机网络编程中至关重要的技术,也是构建高性能服务器的基石。
1. 背景:为什么需要 I/O 多路复用?
想象一下你要开发一个网络服务器,需要同时处理多个客户端的连接。我们来看看几种最原始的模型以及它们的缺陷。
模型一:阻塞 I/O + 多进程/多线程
这是最直观的模型。主进程负责监听(listen)新的连接请求。每当有一个新的客户端连接进来(accept),服务器就创建一个新的进程或线程专门为这个客户端服务。
工作流程:
- 主线程/进程
accept()等待新连接,阻塞。 - 新连接到达,
accept()返回一个新的socket文件描述符(fd)。 - 创建一个子线程/进程,将这个新的
fd交给它处理。 - 子线程/进程在这个
fd上调用read()/recv(),等待客户端发送数据,阻塞。 - 数据到达,
read()返回,处理数据,然后可能调用write()/send(),也可能阻塞。 - 主线程/进程继续循环,等待下一个连接。
- 主线程/进程
缺点:
- 资源开销巨大:每来一个连接就要创建一个线程/进程。线程需要栈空间(通常是 MB 级别),进程开销更大。当连接数成千上万时(即著名的 C10K 问题),系统资源会迅速耗尽。
- 上下文切换频繁:大量的线程/进程会导致 CPU 在它们之间频繁切换,这本身就是一笔巨大的性能开销。
模型二:非阻塞 I/O + 忙轮询 (Busy-Polling)
为了解决阻塞问题,我们可以将 socket 设置为非阻塞模式。
工作流程:
- 将监听
socket和所有已连接的socket都设置为非阻塞。 - 用一个循环,不断地遍历所有的
socket文件描述符。 - 对监听
socket尝试调用accept(),如果有新连接就处理,没有就立即返回错误(如EWOULDBLOCK)。 - 对已连接的
socket尝试调用read(),如果有数据就处理,没有就立即返回错误。
- 将监听
缺点:
- CPU 100%:无论
socket是否活跃,这个循环都在疯狂地、无差别地遍历所有socket,进行大量的无效系统调用。这会导致 CPU 使用率飙升,大部分时间都在做无用功。
- CPU 100%:无论
这两种模型都有致命的缺陷。我们需要一种更优雅的方式:既能避免为每个连接创建线程,又能避免 CPU 空转。
2. 核心思想:它到底是什么?
I/O 多路复用 就是为了解决上述问题而生的。
它的核心思想可以概括为:将“等待”这个任务交给操作系统内核去做。
- “I/O”:指的是网络 I/O,如
read、write。 - “多路”:指的是多个网络连接,即多个
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 多路复用实现,几乎所有操作系统都支持。
工作方式:
- 你需要创建三个文件描述符集合(
fd_set),分别用于监视可读、可写和异常事件。fd_set本质上是一个位图(bitmask)。 - 将你关心的
fd添加到相应的集合中。 - 调用
select(max_fd + 1, &read_fds, &write_fds, &except_fds, &timeout)。 select函数会阻塞,直到有fd就绪或者超时。- 函数返回后,
fd_set会被内核修改,只有那些就绪的fd对应的位仍然是 1。 - 你需要遍历整个
fd_set(从 0 到max_fd),检查哪个fd对应的位是 1,然后处理它。
- 你需要创建三个文件描述符集合(
缺点:
- 最大连接数限制:
fd_set的大小是固定的(通常是 1024 或 2048),由FD_SETSIZE宏定义,这限制了select能同时监视的fd数量。 - 重复的数据拷贝:每次调用
select前,都需要重新构建fd_set,因为内核会修改它。并且,每次调用时,都需要将这个fd_set从用户空间完整地拷贝到内核空间。连接数越多,拷贝开销越大。 - 线性扫描开销:
select返回后,你只知道“有fd就绪了”,但不知道是哪几个。你必须遍历所有被监视的fd来找到就绪的那些。如果监视了 1000 个fd,但只有 1 个就绪,你仍然要做 1000 次检查。这个开销是 O(N),N 是被监视的fd总数。
- 最大连接数限制:
B. poll
poll 是对 select 的改进,解决了 select 的一些问题。
工作方式:
poll不再使用fd_set,而是使用一个pollfd结构体数组。每个pollfd结构体包含fd、关心的事件(events)和返回的实际发生的事件(revents)。- 你创建一个
pollfd数组,填入你关心的fd和事件。 - 调用
poll(fds_array, num_fds, timeout)。 poll返回后,你遍历这个数组,检查每个pollfd结构体的revents字段,来判断该fd是否就绪。
相比
select的改进:- 无最大连接数限制:它使用数组而非位图,理论上可以监视的
fd数量只受限于系统内存。
- 无最大连接数限制:它使用数组而非位图,理论上可以监视的
仍然存在的缺点:
- 重复的数据拷贝:每次调用
poll,仍然需要将整个pollfd数组从用户空间拷贝到内核空间。 - 线性扫描开销:
poll返回后,你仍然需要遍历整个pollfd数组来找到就绪的fd。开销依然是 O(N)。
- 重复的数据拷贝:每次调用
C. epoll (Linux 特有)
epoll 是对 select 和 poll 的重大改进,是 Linux 下实现高性能网络服务器的首选,它彻底解决了前两者的核心性能瓶瓶颈。
工作方式:
epoll将其功能分成了三个独立的系统调用。epoll_create(): 在内核中创建一个epoll实例,并返回一个指向该实例的文件描述符(epoll_fd)。这个实例内部维护了需要监视的fd集合(通常是红黑树)和就绪fd列表(链表)。这个创建动作只需要一次。epoll_ctl(): 用于向epoll实例中添加(EPOLL_CTL_ADD)、修改(EPOLL_CTL_MOD)或删除(EPOLL_CTL_DEL)需要监视的fd。每个fd只需要通过epoll_ctl添加一次,之后它就一直在内核的监视列表中,无需在每次等待时重复提交。epoll_wait(): 等待事件发生,类似于select和poll。但它返回时,只会返回那些已经就绪的fd,而不是所有被监视的fd。它会将就绪的fd列表从内核空间拷贝到用户空间的一个数组里。
相比
select和poll的巨大优势:- 无需重复拷贝:
fd列表通过epoll_ctl维护在内核中,调用epoll_wait时无需重复拷贝整个列表。 - 没有线性扫描:
epoll的核心是事件驱动。当某个fd就绪时,内核会通过回调机制将其加入到一个就绪链表中。epoll_wait只是检查这个链表是否为空,如果不为空,就返回链表中的内容。因此,其时间复杂度是 O(1)(或者说 O(k),k 为就绪的fd数量),与被监视的fd总数无关! - 内存共享 (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 |
总结:在连接数少、且都比较活跃的情况下,select 和 poll 的性能尚可。但在高并发、大量连接(其中大部分是空闲的)场景下,epoll 的性能优势是压倒性的。
5. 工作模式:水平触发 (LT) vs 边沿触发 (ET)
首先,我们要明白“触发”指的是什么。在 epoll 的语境下,“触发”是指当一个被监视的文件描述符(fd)满足了我们所关心的事件(如可读、可写)时,epoll_wait 会被唤醒并返回该 fd。
LT 和 ET 的根本区别在于 epoll_wait 被唤醒并返回的“时机”和“条件”。
A、 水平触发 (Level Triggered, LT)
LT 是 epoll 的默认工作模式。它的行为模式非常直观,也和传统的 select、poll 类似。
只要文件描述符处于某个我们关心的状态(State),epoll_wait 就会一直触发通知。
- 对于读事件:只要该
fd的内核接收缓冲区里有数据(缓冲区大小 > 0),epoll_wait每次被调用时都会返回这个fd,告诉你“嘿,这里有数据,你可以读了!”。 - 对于写事件:只要该
fd的内核发送缓冲区没有满(还有空间可以写入),epoll_wait每次被调用时都会返回这个fd,告诉你“嘿,这里有地方,你可以写了!”。
假设一个客户端发送了 1000 字节的数据到服务器。
- 数据到达:1000 字节数据进入了服务器端
socket的内核接收缓冲区。 - 第一次触发:应用程序调用
epoll_wait,它会立即返回,报告这个socket fd是可读的。 - 不完整的处理:你的应用程序代码从这个
fd中只读取了 500 字节,因为你程序里的 buffer 只有这么大。此时,内核缓冲区里还剩下 500 字节。 - 再次调用
epoll_wait:当你的事件循环下一次调用epoll_wait时,由于内核缓冲区里仍然有数据(满足“可读”状态),epoll_wait会再一次立即返回,告诉你这个fd还是可读的。 - 继续处理:你的程序再次读取剩下的 500 字节。现在内核缓冲区空了。
- 停止触发:再下一次调用
epoll_wait时,由于“可读”状态已经不存在,它将不会再因为这个fd而返回(除非有新数据到达)。
优点:
- 编程简单,不易出错:它的逻辑非常稳健(robust)。即使你因为某些原因没有一次性把数据处理完,也不用担心数据会“丢失”事件通知。下一次循环
epoll还会提醒你,给了你“亡羊补牢”的机会。 - 与
select/poll兼容:行为模式一致,从旧模型迁移过来比较容易。
- 编程简单,不易出错:它的逻辑非常稳健(robust)。即使你因为某些原因没有一次性把数据处理完,也不用担心数据会“丢失”事件通知。下一次循环
缺点:
- 可能导致不必要的唤醒:如果你的程序逻辑有问题,一直不去处理某个
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 字节的数据到服务器。
- 数据到达:1000 字节数据进入内核缓冲区,状态从空 -> 非空,一个“边沿”产生了。
- 第一次触发:应用程序调用
epoll_wait,它会返回,报告这个socket fd可读。 - 不完整的处理:你的应用程序只读取了 500 字节。内核缓冲区里还剩下 500 字节。
- 再次调用
epoll_wait:当你的事件循环下一次调用epoll_wait时,尽管缓冲区里还有数据,但因为没有新的“边沿”产生(状态没有从空变为非空),epoll_wait将会阻塞,不会因为这个fd而返回! - 后果:剩下的 500 字节数据就被“遗忘”在缓冲区里了。你的程序将永远不会收到关于这 500 字节的通知,直到客户端发送下一批新数据,从而产生一个新的“边沿”。这就导致了数据处理的延迟甚至丢失。
为了避免上述问题,使用 ET 模式时必须遵循一个黄金法则:
当收到一个 ET 事件通知时,你必须持续地对该 fd 进行 I/O 操作,直到该操作返回 EAGAIN 或 EWOULDBLOCK 错误为止。
这意味着:
- 文件描述符必须设置为非阻塞(
O_NONBLOCK)。这是绝对的前提,否则当缓冲区读完后,最后一次read()调用会永远阻塞你的程序。 - 使用循环来处理 I/O。
正确的ET读操作代码伪代码:
1 | // epoll_wait 返回了就绪的 fd |
写操作同理,需要在一个循环里 write(),直到数据全部写完或者 write() 返回 EAGAIN。
优点:
- 性能极高:它极大地减少了
epoll_wait被唤醒的次数,从而降低了系统调用的开销。一个事件只通知一次,非常高效,特别是在有大量连接但只有少数活跃的场景下。Nginx、Redis 等高性能组件都重度使用 ET 模式。 - 避免“惊群”:在多线程环境下,一个事件不会被多个线程同时处理,因为只有一个线程会收到该事件的通知。
- 性能极高:它极大地减少了
缺点:
- 编程复杂度高:逻辑比 LT 复杂得多,非常容易出错。如果忘记了循环处理 I/O 直到
EAGAIN,就会导致数据饥饿(starvation)或连接假死。 - 对程序员要求高:需要对非阻塞 I/O 和事件循环有深刻的理解。
- 编程复杂度高:逻辑比 LT 复杂得多,非常容易出错。如果忘记了循环处理 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的封装。










