从零理解 I/O 多路复用:select、poll、epoll 到 Java NIO

一个聊天服务器同时连接着很多客户端,但大多数连接在大部分时间里都没有消息。服务器怎样知道哪个连接需要处理?如果为每个连接都安排一个线程,能不能有更合适的办法?

这正是 I/O 多路复用要解决的问题。select、poll 和 Linux 的 epoll 都能让程序等待多个文件描述符的 I/O 就绪状态。它们的核心差别,在于“要关注的连接由谁保存、每次等待时怎样检查、程序怎样拿到就绪结果”。

读完这篇,你应该能讲清三个重点:就绪通知不等于自动读取数据;select 和 poll 每次都要处理关注集合;epoll 将持续注册的关注项和就绪事件分开管理,因此在大量连接只有少数活跃时通常更有优势。

一、I/O 多路复用在等什么?

假设服务器接入 10,000 个 TCP 客户端。此刻客户端 A、C 在发消息,其余连接暂时空闲。服务器真正需要的是:等待一批连接,只处理当前可以继续进行 I/O 的连接。

一个直观但不总是理想的做法是“一连接一线程”:每个连接都由一个线程执行阻塞读取。线程在等数据时可能被操作系统挂起,但大量线程仍会占用栈内存、调度资源,并增加并发管理复杂度。

另一种做法是由少量事件循环线程关注许多连接:当某个连接可读或可写时,事件循环收到通知,再对相应连接进行处理。这就是 I/O 多路复用常见的使用方式。

方式 程序在做什么 常见特点
阻塞 I/O 对一个连接执行读取;没数据时等待 使用简单,一线程一连接时线程数量可能很大
非阻塞轮询 反复尝试读取多个连接 空闲时可能频繁空转
I/O 多路复用 一次等待多个连接的就绪状态,返回后处理就绪项 少量线程可以服务多个连接,但仍需主动读写

多路复用并不表示一个线程能在同一瞬间读取所有 Socket。它表示程序可以用统一的等待接口关注多个 I/O 对象,收到就绪结果后再逐个处理。

文件描述符和 Socket

Linux 通过文件描述符(File Descriptor,FD)引用打开的对象。FD 对进程来说是一个整数索引;服务器监听 Socket、每个已建立的客户端 Socket,通常都各自占用一个 FD。

FD 示例用途
3 监听 Socket
4 客户端 A 的连接
5 客户端 B 的连接
6 客户端 C 的连接

这些数字只用于说明,实际编号由操作系统分配;进程里的文件、管道等其他对象也会占用 FD。

“可读”具体是什么意思?

假设客户端 B 发来数据:

1
2
3
4
5
6
7
客户端发送数据
↓
内核网络栈处理数据
↓
数据进入 Socket 接收缓冲区
↓
Socket 对应的 FD 变为可读

可读就绪的意思是现在可以尝试执行读取,而不是“系统已经替应用把消息读好了”。数据仍由应用程序调用 recv()、Java 的 SocketChannel.read() 等接口读取。

就绪也不保证读取任意长度都会成功或一次读完全部数据。它表示当前存在可进行的操作,例如流式 Socket 上有数据可读、连接已关闭以便程序观察到 EOF,或写操作当前可以接受一定量的数据。实际的读写仍应处理部分读写、关闭和非阻塞下的 EAGAIN 等情况。

可写也不代表“可以写入整个应用消息”。它只意味着当前有一定发送空间;如果应用要发的数据很大,仍可能只写出一部分。

二、select:每轮都要带上关注集合

select() 让应用把想关注的 FD 集合交给内核,然后等待其中一个或多个 FD 就绪。程序可以分别关注可读、可写和异常等状态。

1
2
3
4
5
6
7
8
9
应用准备关注集合
↓
调用 select(),必要时阻塞等待
↓
内核检查并等待 FD 就绪
↓
select() 返回就绪结果
↓
应用检查集合,逐个处理就绪 FD

select() 的一个重要特点是:调用返回后,传入的集合会被修改,只保留就绪项。 如果下一轮仍想关注原来的全部 FD,通常要重新构造集合。

假设应用关注 FD 4、5、6,返回时只有 5 可读:

1
2
调用前:{4, 5, 6}
调用后:{5}

在常见 Linux 实现中,调用需要传入关注集合;内核需要检查所关注的 FD,返回后应用也要扫描集合,判断具体哪些 FD 仍在就绪结果中。关注项越多,重复处理集合的开销越明显。

1024 限制不是“最多 1024 个连接”

Linux/glibc 的传统 fd_set 通常由 FD_SETSIZE 限定,常见值为 1024。这个限制针对可表示的 FD 编号范围(通常是 0 到 1023),不是说 Linux 进程最多只能建立 1024 个网络连接。

进程能打开多少 FD 还受资源限制等因素影响;但如果 FD 编号超过 fd_set 能表达的范围,就不能靠传统 select() 安全监视它。这个限制在现代高连接数程序中很不方便。

因此,select 仍适合理解机制、少量连接或受特定平台接口约束的程序,但 Linux 新的高并发网络服务通常会考虑 poll、epoll 或更高层框架。

三、poll:用数组表达关注项

poll() 的等待思路和 select() 相近,但它用 pollfd 数组描述关注项:

1
2
3
4
5
struct pollfd {
int fd; // 要关注的文件描述符
short events; // 应用关心的事件
short revents; // 内核返回的实际事件
};

应用把 pollfd 数组传入 poll()。返回后查看每一项的 revents,了解该 FD 发生了哪些事件。

1
2
3
4
5
6
7
8
9
应用准备 pollfd 数组
↓
调用 poll() 等待
↓
内核检查这组 FD 并等待就绪
↓
revents 标记实际事件
↓
应用遍历数组并处理就绪项

poll 不依赖传统的定长 fd_set,所以没有同一个 FD_SETSIZE 固定集合大小问题;实际容量仍受进程 FD 资源限制、内存和系统配置等约束。

但它没有从根本上改变等待模式:每一轮都要把关注的数组交给内核处理,应用返回后通常也要遍历数组寻找 revents 非零的项。若总共关注一万项、只有三项就绪,程序往往仍要面对这组一万项的数据。

因此,poll 改善了 select 的集合表达限制,但对“很多连接长期空闲、每次只有少量连接活跃”的场景,反复处理整个集合仍然有成本。

四、epoll:注册关注项,等待就绪事件

epoll 是 Linux 提供的 I/O 多路复用接口。它把“管理要关注哪些 FD”和“等待哪些 FD 已经就绪”分成两个操作阶段。

接口 作用
epoll_create1() 创建 epoll 实例,返回一个 epoll FD
epoll_ctl() 把目标 FD 加入、修改或移出关注列表
epoll_wait() 等待并取回一批就绪事件

先注册,再等待:

1
2
3
4
5
6
7
epoll_ctl:注册 FD 4、5、6 以及关注的事件
↓
客户端 B 发来数据,FD 5 可读
↓
epoll_wait:取回 FD 5 的就绪事件
↓
应用调用 recv() 读取数据

从应用视角,可以把 epoll 实例理解为维护了两类信息:

  • 关注列表(interest list):应用注册、希望持续监视的 FD 与事件。
  • 就绪列表(ready list):当前已经就绪、可以交给应用处理的 FD。

在 Linux 的经典 epoll 实现中,内核使用搜索结构管理注册项,并利用就绪事件通知路径维护待处理的就绪项。红黑树和就绪链表常用于解释这一实现,但它们是具体内核实现细节,不应当被理解为所有操作系统、所有版本都必须采用的公开接口约定。

epoll_wait() 返回的是一批就绪事件,应用只需要处理返回的这些事件,而不必每次都从原始关注列表中逐项寻找活跃 FD。epoll_ctl() 的注册和修改也有成本,所以 epoll 并不是没有遍历、没有复制或没有管理开销。

为什么大量空闲连接时 epoll 通常更有优势?

假设同样关注 10,000 个客户端,某一轮只有 5 个可读:

1
2
3
select / poll:处理关注集合 → 返回 → 应用检查集合/数组 → 找出 5 个

epoll:关注项已注册 → 等待 → 返回当前就绪的 5 个事件 → 应用处理

在大量连接、就绪连接相对少的工作负载中,epoll 避免了每一轮都完整提交并扫描关注集合的常见开销,因此往往更合适。

不过,“epoll 永远最快”或“epoll 是 O(1)”都不准确。事件注册和更新需要成本,内核仍要产生并返回就绪事件,应用还要执行实际读写;如果关注的连接不多,或几乎全部同时活跃,实际差异可能不明显。性能取决于连接数量、活跃比例、事件处理逻辑、系统和负载特征。

此外,epoll 是 Linux 接口,不能直接拿来作为跨平台 API。其他操作系统有各自的机制,例如 BSD/macOS 的 kqueue、Windows 的 IOCP。

五、三种机制的对照

对比项 select poll epoll(Linux)
关注项的表示 fd_set 位集合 pollfd 数组 内核中的注册项
固定集合大小限制 常见 fd_set 有 FD_SETSIZE 限制 没有同类固定 fd_set 上限 没有同类固定集合上限,仍受系统资源限制
每轮等待时的输入 传入读、写、异常集合 传入 pollfd 数组 等待时传入 epoll FD 和结果数组
返回结果 原集合被修改为就绪结果 通过每项 revents 返回 返回一批就绪事件
大集合、低活跃比例 每轮常需处理关注集合 每轮常需处理关注数组 通常避免每轮扫描全部注册项
跨平台性 较广 较广 Linux 专有接口

可以用一句话记忆:select 把位集合带进去、结果写回集合;poll 把数组带进去、结果写进 revents;epoll 先注册关注项、再按批次等待就绪事件。

六、LT 与 ET:为什么边缘触发要读到 EAGAIN?

epoll 支持两种常见通知模式:水平触发(LT,Level-Triggered)和边缘触发(ET,Edge-Triggered)。

LT:只要条件还成立,就可以继续通知

LT 是默认模式。只要 Socket 仍满足可读条件,例如接收缓冲区里还有数据,后续等待仍可能再次返回它。

1
2
3
4
5
6
7
缓冲区有 100 字节
↓ 收到可读事件
应用读取 20 字节
↓
缓冲区剩 80 字节,仍可读
↓
后续 epoll_wait 仍可能报告可读

LT 的编程方式相对直观:某次没处理完,仍可能再次收到就绪通知。它也更容易用于对接使用 poll 风格的事件循环。

ET:不要依赖条件持续时反复提醒

ET 更强调就绪状态的变化。假设收到一次可读事件,只读取 20 字节,接收缓冲区里还剩 80 字节;如果程序马上再次等新事件,可能一直等不到,因为 Socket 没有发生新的状态变化。

因此,ET 通常配合非阻塞 Socket使用,并在收到事件后持续读取,直到 recv() 返回 EAGAIN 或 EWOULDBLOCK,表示当前已经没有数据可以立即读取:

1
2
3
4
5
6
7
收到可读事件
↓
循环 recv() 读取数据
↓
读到 EAGAIN / EWOULDBLOCK
↓
再等待后续事件

EAGAIN 在这里不是程序错误,而是非阻塞操作的正常结果:现在没有更多数据可取,稍后等新事件再处理。若代码只读一次就返回事件循环,就可能把缓冲区里已经存在的剩余数据“晾”在那里。

ET 下的可写事件也要谨慎处理:写入可能只完成一部分。如果发送队列还有数据,应暂存剩余内容,并在后续可写时继续;如果当前没有待发送内容,就不要无条件一直订阅可写事件,否则 Socket 通常可写时可能导致事件循环频繁醒来、空转。

对比项 LT ET
通知关注点 当前是否仍满足就绪条件 就绪状态是否发生变化
未处理完的数据 后续仍可能重复报告 不保证条件持续时再次报告
常见使用难度 较容易 较高,通常应配合非阻塞 I/O
处理原则 每次处理好当前可用数据 读、写、accept 通常要持续处理到 EAGAIN

ET 不是“一个连接永远只通知一次”。关键在于不能像 LT 那样假设“条件还满足就会不停重复提醒我”。

七、就绪通知不等于异步 I/O 完成

这两个概念经常被混为一谈。

Linux 的 select、poll 和 epoll 主要提供就绪通知:告诉程序某个 FD 现在可以尝试读、写或接收连接。程序仍需调用相应 I/O 接口执行操作。

完成通知模型则是在 I/O 操作提交后,等操作完成再取得结果。不同平台、框架和 API 对异步 I/O 的实现各不相同;不能仅因为程序使用了事件循环,就把它称为“内核已经帮我异步读完数据”。

1
2
3
就绪模型:等待可读事件 → 程序调用 read/recv → 处理读到的数据

完成模型:提交读取操作 → 操作完成 → 收到完成结果

所以,“epoll 帮应用自动把数据读好”是不对的。它帮助应用高效地等待许多连接的状态或就绪事件,业务读取仍由应用负责。

八、Java NIO Selector:Java 层的统一选择器

Java NIO 的 Selector 允许一个线程注册并等待多个可选择通道,例如 SocketChannel。程序把通道注册到 Selector,并设置感兴趣的操作,再调用 select() 等待可读、可写、连接就绪等事件。

典型事件循环的形状如下:

1
2
3
4
5
6
7
8
9
注册 ServerSocketChannel 的连接事件
↓
selector.select() 等待
↓
遍历 selectedKeys 并移除已处理的 key
↓
接受新连接 / 读取数据 / 写出待发送数据
↓
继续下一轮

示意代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while (true) {
selector.select();

Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // selectedKeys 是待处理集合,处理后要移除

if (key.isAcceptable()) {
// 接受新连接,并将新 SocketChannel 注册到 selector
}
if (key.isReadable()) {
// 读取当前可用的数据,处理 EOF、部分读取和异常
}
if (key.isWritable()) {
// 有待发送数据时继续写;写完后取消 OP_WRITE 兴趣
}
}
}

这只是结构示意,生产代码还要处理通道配置为非阻塞、读写缓冲区、连接关闭、异常清理、写入背压和关闭 Selector 等细节。

Selector 是 Java 提供的抽象接口,不等同于 Linux epoll。具体底层由当前 JDK 和操作系统提供;Linux 上常用 JDK 实现通常会使用 epoll 等机制,但 Java API 本身没有承诺底层必须是 epoll。其他平台可能使用不同的系统机制。

九、怎么选?

  • 学习原理、兼容旧代码或连接数较少:理解 select 和 poll 仍有价值,但注意集合重复处理的成本。
  • Linux 上大量并发连接,活跃比例不高:epoll 常常是合适的底层机制;也可以直接使用成熟的网络框架。
  • Java 服务端:先理解 Selector 的注册、等待、处理流程和非阻塞读写,再看具体 JDK 与框架实现。
  • 跨平台开发:使用语言或框架提供的抽象接口,不要把业务代码直接绑定到 Linux 的 epoll。
  • 读写负载本身很重:即便等待机制高效,业务处理、内存分配、锁竞争和网络带宽仍可能成为瓶颈。

高并发服务也不等于“一个线程管所有事”。常见的 Reactor 架构会使用多个事件循环,每个事件循环管理一组连接;CPU 核数、业务耗时和框架实现都会影响线程划分。I/O 多路复用降低了每连接独占一个阻塞线程的必要性,但没有消除线程调度、系统调用或业务处理成本。

十、面试时可以这样总结

I/O 多路复用是等待多个文件描述符 I/O 就绪的一种机制。内核发现某些 Socket 可读、可写或有其他事件后,应用再对对应 FD 执行真正的读写;就绪通知不等于数据已自动读出,也不等于一次就能读写完整消息。

select 使用 fd_set,每次等待要传入集合,返回时集合会被改写,传统 Linux/glibc 实现通常受 FD_SETSIZE 1024 的 FD 编号限制;poll 使用 pollfd 数组,没有这个固定集合上限,但等待时仍需要处理关注数组;Linux epoll 通过 epoll_ctl 注册关注项,再用 epoll_wait 获取一批就绪事件,在大量连接、低活跃比例的情况下通常更有优势。

epoll 默认是 LT,也支持 ET。ET 通常配合非阻塞 I/O,在收到事件后持续处理到 EAGAIN。Java NIO Selector 则是平台相关底层机制之上的 Java 抽象。

参考资料