一、整体知识体系:用户态、内核态、IO 多路复用
在理解 select、poll、epoll 之前,先建立一条主线:
用户态:我们写的 Java 代码运行的地方
内核态:操作系统内核运行的地方
IO 多路复用:用户态线程通过系统调用,让内核帮忙监听多个 socket 的 IO 事件
在 Java 后端里,我们写的代码,比如 NIO、Netty、业务逻辑,都是运行在用户态。
但是网络 IO 的底层,比如 TCP 协议栈、socket 缓冲区、网卡数据接收,这些都由操作系统内核管理,也就是在内核态完成。
所以用户程序不能直接判断某个 socket 有没有数据,它需要通过系统调用进入内核态,让内核帮它检查。
一句话总结:
IO 多路复用就是一个用户线程通过 select、poll、epoll 这样的系统调用,让内核同时监听多个 fd,哪个 fd 就绪了,用户线程就处理哪个 fd。
二、fd 是什么?
fd 全称是:
File Descriptor
文件描述符
它是操作系统给当前进程打开的 IO 资源分配的一个编号。
在 Linux 里,很多东西都被抽象成文件,比如:
普通文件
socket 连接
管道
标准输入
标准输出
标准错误
所以这些资源都会有 fd。
例如:
标准输入 stdin -> fd = 0
标准输出 stdout -> fd = 1
标准错误 stderr -> fd = 2
某个 socket 连接 -> fd = 3
另一个 socket 连接 -> fd = 4
某个日志文件 -> fd = 5
所以要注意:
fd 不是连接数量本身,而是操作系统给某个 IO 资源分配的编号。
但是在网络编程里,一个 socket 通常对应一个 fd,所以很多时候我们说“监听多个 fd”,本质上就是监听多个 socket 连接。
三、fd 和 socket 连接的关系
在服务端网络编程里,一般有两类 socket。
1. 监听 socket
服务端启动时,会创建一个监听端口的 socket。
比如服务端监听 8080 端口:
ServerSocketChannel 监听 8080
listenFd = 3
这个 listenFd 是监听 socket 的 fd。
它的作用是接收新的客户端连接,不代表某一个具体客户端连接。
2. 已连接 socket
当客户端连接进来之后,服务端通过 accept() 得到一个新的 socket。
例如:
客户端 A 连接进来 -> connFd = 4
客户端 B 连接进来 -> connFd = 5
客户端 C 连接进来 -> connFd = 6
这些 fd 才分别对应一个个客户端连接。
所以服务端中可能是这样的:
fd = 3:监听 socket,负责接收新连接
fd = 4:客户端 A 的连接 socket
fd = 5:客户端 B 的连接 socket
fd = 6:客户端 C 的连接 socket
面试时可以这样说:
fd 是操作系统层面对 IO 资源的编号。一个 socket 通常对应一个 fd;在服务端中,监听 socket 有一个监听 fd,每 accept 一个客户端连接,内核会为这个已连接 socket 分配一个新的 fd。
四、IO 多路复用监听的到底是什么?
严格来说,IO 多路复用监听的是:
多个 fd 上的 IO 就绪事件
它不是直接监听“连接个数”。
但是在网络编程场景中,大多数 fd 都是 socket fd,所以可以近似理解为:
监听多个 fd
≈ 监听多个 socket
≈ 监听多个客户端连接
更严谨的表达是:
IO 多路复用监听的是多个文件描述符 fd 的 IO 就绪事件。在网络编程里,一个已连接 socket 通常对应一个 fd,所以监听多个 fd 就相当于监听多个 socket 连接。
五、BIO 和 IO 多路复用的区别
假设服务端有三个客户端连接:
客户端 A -> fd = 4
客户端 B -> fd = 5
客户端 C -> fd = 6
1. BIO 的处理方式
BIO 通常是:
一个连接对应一个线程
例如:
线程 1 阻塞等待 fd=4 的数据
线程 2 阻塞等待 fd=5 的数据
线程 3 阻塞等待 fd=6 的数据
如果连接数很多,线程数量就会爆炸。
这会带来几个问题:
线程占用内存
线程上下文切换成本高
大量线程阻塞在 IO 上
CPU 利用率不高
并发扩展能力差
2. IO 多路复用的处理方式
IO 多路复用是一个线程调用 select、poll 或 epoll,把多个 fd 交给内核监听:
一个线程监听 fd=4、fd=5、fd=6
内核负责检查:
fd=4 有没有数据?
fd=5 有没有数据?
fd=6 有没有数据?
如果某一刻:
fd=5 有数据可读
fd=6 有数据可读
内核就通知用户态线程:
fd=5 就绪了
fd=6 就绪了
然后用户态线程再去处理对应连接的数据。
核心区别是:
BIO 是一个线程阻塞在一个 socket 上;IO 多路复用是一个线程阻塞在多路复用器上,让内核帮它监听多个 socket。
六、select 的工作原理
select 是比较早期的 IO 多路复用机制。
它的核心流程是:
1. 用户态应用程序准备一批 fd 集合
2. 通过 select 系统调用把 fd 集合传给内核
3. 内核遍历 fd 集合,检查哪些 fd 就绪
4. 内核把结果写回用户传入的数据结构
5. select 返回用户态
6. 用户态程序再次遍历 fd 集合,找出真正就绪的 fd
7. 处理对应连接
例如用户态有这些 fd:
fd = 3
fd = 4
fd = 5
fd = 6
fd = 7
应用程序调用 select 后,内核遍历这些 fd,发现:
fd = 4 可读
fd = 7 可读
select 返回后,用户态程序还要自己再遍历一遍 fd 集合,才能知道到底是 fd=4 和 fd=7 就绪了。
select 的缺点
1. fd 数量有限制
select 使用 fd_set 保存 fd 集合,常见限制是 1024。
也就是说,一个 select 能监听的 fd 数量通常有限。
2. 每次调用都要重复传递 fd 集合
每次调用 select,都要把 fd 集合从用户态拷贝到内核态。
连接数量越多,这个拷贝成本越高。
3. 内核和用户态都要遍历所有 fd
即使只有一个 fd 就绪,内核也要遍历所有 fd。
返回用户态后,用户程序还要再遍历所有 fd。
所以时间复杂度是:
O(n)
一句话总结:
select 有 fd 数量限制,每次调用都要传入全部 fd,内核和用户态都要遍历全部 fd。
七、poll 的工作原理
poll 的整体思想和 select 很像,也是:
用户态把一批 fd 交给内核
内核检查哪些 fd 就绪
返回后用户态再遍历
不同点是:
poll 不再使用固定大小的 fd_set,而是使用 pollfd 数组,因此解决了 select 的 fd 数量限制问题。
pollfd 可以简单理解为:
fd:要监听哪个连接
events:关心什么事件,比如可读、可写
revents:内核返回实际发生了什么事件
poll 的流程是:
1. 用户态准备 pollfd 数组
2. 调用 poll 进入内核态
3. 内核遍历 pollfd 数组,检查每个 fd 是否就绪
4. 内核把结果写到 revents 字段
5. poll 返回用户态
6. 用户态遍历 pollfd 数组,找出 revents 有事件的 fd
poll 的优点
相比 select,poll 的主要优点是:
解决了 fd 数量限制问题
poll 的缺点
但是 poll 没有解决核心性能问题:
每次调用仍然要传递全部 fd
内核仍然要遍历全部 fd
返回用户态后仍然要遍历全部 fd
所以 poll 的时间复杂度仍然是:
O(n)
一句话总结:
poll 解决了 select 的 fd 数量限制,但没有解决全量传递和全量遍历的问题。
八、epoll 的工作原理
epoll 是 Linux 对 IO 多路复用的优化,更适合高并发场景。
它和 select、poll 最大的区别是:
select/poll 是每次调用都把全部 fd 传给内核;epoll 是先把 fd 注册到内核,后续只等待就绪事件。
epoll 的三个核心系统调用
epoll_create:创建 epoll 实例
epoll_ctl:添加、修改、删除要监听的 fd
epoll_wait:等待就绪事件
epoll 的流程
1. 用户态调用 epoll_create 创建 epoll 对象
2. 用户态调用 epoll_ctl 把 socket fd 注册到内核
3. 内核维护这些 fd 的监听关系
4. 当某个 fd 就绪时,内核把它放入就绪队列
5. 用户态调用 epoll_wait 等待事件
6. epoll_wait 返回的就是已经就绪的 fd
7. 用户态程序直接处理这些就绪 fd
假设服务端维护 10000 个连接,但只有 10 个连接有数据。
select/poll 的做法是:
遍历 10000 个 fd,找到 10 个就绪 fd
epoll 的做法是:
内核把 10 个就绪 fd 放入就绪队列
epoll_wait 直接返回这 10 个 fd
所以 epoll 更适合:
大量连接
少量活跃
高并发网络服务
九、epoll 为什么性能更好?
epoll 性能更好的原因主要有三个。
1. 不需要每次重复传递全部 fd
select/poll 每次调用都要把 fd 集合从用户态传给内核。
epoll 是先通过 epoll_ctl 注册 fd,之后由内核自己维护这些 fd。
所以后续 epoll_wait 不需要每次重复传递全部 fd。
2. 不需要遍历全部 fd 找就绪事件
select/poll 返回后,用户态程序还要遍历所有 fd,找出真正就绪的连接。
epoll 中,内核会把已经就绪的 fd 放到就绪队列里。
所以 epoll_wait 返回的就是就绪 fd。
3. 监听集合和就绪集合分离
epoll 内部可以简单理解为:
红黑树:管理所有注册的 fd
就绪链表:保存已经就绪的 fd
红黑树用于管理监听集合。
就绪链表用于保存已经发生事件的 fd。
这就是 epoll 的核心优势:
监听集合和就绪集合分离。
十、select、poll、epoll 核心区别对比
| 对比项 | select | poll | epoll |
|---|---|---|---|
| 监听 fd 数量 | 有限制,常见 1024 | 理论上无固定限制 | 理论上无固定限制 |
| 数据结构 | fd_set / bitmap | pollfd 数组 | 红黑树 + 就绪链表 |
| 是否每次传全部 fd | 是 | 是 | 否,先注册后等待 |
| 内核是否遍历全部 fd | 是 | 是 | 不需要全量遍历就绪结果 |
| 用户态是否遍历全部 fd | 是 | 是 | 只遍历就绪事件 |
| 时间复杂度 | O(n) | O(n) | 和就绪 fd 数量更相关 |
| 适合场景 | 少量连接 | 中等连接 | 高并发、大量连接、少量活跃 |
最关键的区别:
select 有 fd 数量限制,并且每次都要全量传递、全量遍历;poll 解决了 fd 数量限制,但仍然要全量传递、全量遍历;epoll 通过事件驱动和就绪队列,只返回真正就绪的 fd,更适合高并发场景。
十一、epoll 的 LT 和 ET 模式
epoll 有两种触发模式:
LT:Level Trigger,水平触发
ET:Edge Trigger,边缘触发
1. LT 水平触发
LT 是默认模式。
特点是:
只要 fd 中还有数据没读完,epoll_wait 就会一直通知你。
例如 socket 缓冲区里有 100 字节数据,这次只读了 50 字节,那么下次 epoll_wait 还会继续通知这个 fd 可读。
优点:
编程简单
不容易漏事件
缺点:
可能重复通知
效率相对低一些
2. ET 边缘触发
ET 模式是:
只有 fd 状态发生变化时才通知一次。
比如从“没有数据”变成“有数据”时通知一次。
如果这次没有把数据读完,后续不一定继续通知。
所以 ET 模式通常要求:
必须配合非阻塞 IO,并且一次性循环读到 EAGAIN 为止。
优点:
通知次数少
效率更高
缺点:
编程复杂
容易漏读数据
面试表达:
LT 模式下,只要数据没读完,epoll 会反复通知;ET 模式下,只在状态变化时通知一次,所以必须循环读到 EAGAIN,否则可能漏事件。ET 性能更高,但编码要求也更高。
十二、用户态和内核态在 select/poll/epoll 中如何配合
select/poll 的用户态和内核态流程
用户态应用程序准备 fd 集合
↓
通过 select/poll 系统调用进入内核态
↓
内核遍历全部 fd,检查哪些 fd 就绪
↓
内核把结果写回用户传入的数据结构
↓
系统调用返回用户态
↓
用户态程序再次遍历全部 fd,找出真正就绪的 fd
所以 select/poll 的问题是:
内核遍历一遍,用户态返回后还要再遍历一遍。
epoll 的用户态和内核态流程
用户态通过 epoll_create 创建 epoll 实例
↓
用户态通过 epoll_ctl 把 fd 注册到内核
↓
内核维护监听集合和就绪队列
↓
用户态调用 epoll_wait 等待事件
↓
某些 fd 就绪后,内核把它们放入就绪队列
↓
epoll_wait 返回用户态
↓
用户态只处理返回的就绪 fd
所以 epoll 的优势是:
fd 只需要注册一次,后续 epoll_wait 只返回就绪 fd,减少了重复拷贝和无意义遍历。
十三、和 Java NIO 的关系
Java NIO 中的 Selector 本质上是对操作系统 IO 多路复用能力的封装。
Java 代码里可能这样写:
Selector selector = Selector.open();
while (true) {
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isReadable()) {
// 处理读事件
}
}
}
这里的:
selector.select();
底层会通过 JVM native 方法调用操作系统的多路复用机制。
在 Linux 下,通常底层就是 epoll。
对应关系可以这样理解:
Java 层:SocketChannel / ServerSocketChannel
↓
JVM native 层:调用操作系统 socket API
↓
操作系统层:socket fd
↓
Selector 底层:select / poll / epoll
所以:
SocketChannel 是 Java 对 socket fd 的封装;Selector 是 Java 层的多路复用器;epoll/select/poll 是操作系统层的多路复用机制。
十四、完整理解链路
可以把 IO 多路复用理解成下面这条链路:
客户端连接
↓
服务端 accept 得到 socket
↓
操作系统给这个 socket 分配 fd
↓
Java 的 SocketChannel 封装这个 fd
↓
把 Channel 注册到 Selector
↓
Selector 底层把 fd 注册给 epoll/select/poll
↓
内核监听这些 fd 的 IO 状态
↓
fd 可读/可写时,内核通知用户态
↓
Java 线程处理对应 Channel 的读写事件
关键对应关系:
fd:操作系统层面的资源编号
Channel:Java 层面对 fd 的封装
Selector:Java 层的多路复用器
epoll/select/poll:操作系统层的多路复用机制