从零理解 I/O 多路复用:select、poll、epoll 到 Java NIO
从零理解 I/O 多路复用:select、poll、epoll 到 Java NIO
YYT一个聊天服务器同时连接着很多客户端,但大多数连接在大部分时间里都没有消息。服务器怎样知道哪个连接需要处理?如果为每个连接都安排一个线程,能不能有更合适的办法?
这正是 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 | 客户端发送数据 |
可读就绪的意思是现在可以尝试执行读取,而不是“系统已经替应用把消息读好了”。数据仍由应用程序调用 recv()、Java 的 SocketChannel.read() 等接口读取。
就绪也不保证读取任意长度都会成功或一次读完全部数据。它表示当前存在可进行的操作,例如流式 Socket 上有数据可读、连接已关闭以便程序观察到 EOF,或写操作当前可以接受一定量的数据。实际的读写仍应处理部分读写、关闭和非阻塞下的 EAGAIN 等情况。
可写也不代表“可以写入整个应用消息”。它只意味着当前有一定发送空间;如果应用要发的数据很大,仍可能只写出一部分。
二、select:每轮都要带上关注集合
select() 让应用把想关注的 FD 集合交给内核,然后等待其中一个或多个 FD 就绪。程序可以分别关注可读、可写和异常等状态。
1 | 应用准备关注集合 |
select() 的一个重要特点是:调用返回后,传入的集合会被修改,只保留就绪项。 如果下一轮仍想关注原来的全部 FD,通常要重新构造集合。
假设应用关注 FD 4、5、6,返回时只有 5 可读:
1 | 调用前:{4, 5, 6} |
在常见 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 | struct pollfd { |
应用把 pollfd 数组传入 poll()。返回后查看每一项的 revents,了解该 FD 发生了哪些事件。
1 | 应用准备 pollfd 数组 |
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 | epoll_ctl:注册 FD 4、5、6 以及关注的事件 |
从应用视角,可以把 epoll 实例理解为维护了两类信息:
- 关注列表(interest list):应用注册、希望持续监视的 FD 与事件。
- 就绪列表(ready list):当前已经就绪、可以交给应用处理的 FD。
在 Linux 的经典 epoll 实现中,内核使用搜索结构管理注册项,并利用就绪事件通知路径维护待处理的就绪项。红黑树和就绪链表常用于解释这一实现,但它们是具体内核实现细节,不应当被理解为所有操作系统、所有版本都必须采用的公开接口约定。
epoll_wait() 返回的是一批就绪事件,应用只需要处理返回的这些事件,而不必每次都从原始关注列表中逐项寻找活跃 FD。epoll_ctl() 的注册和修改也有成本,所以 epoll 并不是没有遍历、没有复制或没有管理开销。
为什么大量空闲连接时 epoll 通常更有优势?
假设同样关注 10,000 个客户端,某一轮只有 5 个可读:
1 | select / poll:处理关注集合 → 返回 → 应用检查集合/数组 → 找出 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 | 缓冲区有 100 字节 |
LT 的编程方式相对直观:某次没处理完,仍可能再次收到就绪通知。它也更容易用于对接使用 poll 风格的事件循环。
ET:不要依赖条件持续时反复提醒
ET 更强调就绪状态的变化。假设收到一次可读事件,只读取 20 字节,接收缓冲区里还剩 80 字节;如果程序马上再次等新事件,可能一直等不到,因为 Socket 没有发生新的状态变化。
因此,ET 通常配合非阻塞 Socket使用,并在收到事件后持续读取,直到 recv() 返回 EAGAIN 或 EWOULDBLOCK,表示当前已经没有数据可以立即读取:
1 | 收到可读事件 |
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 | 就绪模型:等待可读事件 → 程序调用 read/recv → 处理读到的数据 |
所以,“epoll 帮应用自动把数据读好”是不对的。它帮助应用高效地等待许多连接的状态或就绪事件,业务读取仍由应用负责。
八、Java NIO Selector:Java 层的统一选择器
Java NIO 的 Selector 允许一个线程注册并等待多个可选择通道,例如 SocketChannel。程序把通道注册到 Selector,并设置感兴趣的操作,再调用 select() 等待可读、可写、连接就绪等事件。
典型事件循环的形状如下:
1 | 注册 ServerSocketChannel 的连接事件 |
示意代码:
1 | while (true) { |
这只是结构示意,生产代码还要处理通道配置为非阻塞、读写缓冲区、连接关闭、异常清理、写入背压和关闭 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数组,没有这个固定集合上限,但等待时仍需要处理关注数组;Linuxepoll通过epoll_ctl注册关注项,再用epoll_wait获取一批就绪事件,在大量连接、低活跃比例的情况下通常更有优势。epoll 默认是 LT,也支持 ET。ET 通常配合非阻塞 I/O,在收到事件后持续处理到
EAGAIN。Java NIO Selector 则是平台相关底层机制之上的 Java 抽象。