前言
上一篇我们讲到,为了解决 C10K 问题,服务端不能再简单地为每个连接分配一个平台线程。
于是,Java NIO 出现了这样的代码:
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(9090));
List<SocketChannel> clients = new ArrayList<>();
while (true) {
SocketChannel client = server.accept();
if (client != null) {
client.configureBlocking(false);
clients.add(client);
}
for (SocketChannel channel : clients) {
int count = channel.read(buffer);
if (count > 0) {
handle(buffer);
}
}
}
这段代码只有一个线程,也不会阻塞在某一个连接上。
看起来,一连接一线程的问题已经解决了。
但新的问题很快出现:
如果服务器有十万个连接,难道每轮都要检查十万个 Channel?
十万个连接中只有三个有数据,剩下的检查是不是都浪费了?
select()、poll()和epoll()都叫多路复用,它们有什么区别?
epoll 为什么不需要每次传入所有文件描述符?
epoll 是怎样知道某个 Socket 已经可读的?
文件描述符真的会从红黑树“移动”到就绪链表吗?
epoll_wait()返回后,数据是不是已经被读进了应用程序?
epoll 靠网卡中断工作吗?
LT 和 ET 有什么区别?
Java 的
Selector.open()是否必然使用 epoll?
epoll 是同步 I/O,还是异步 I/O?
这些问题最终都指向一个核心:
epoll 如何从海量连接中找到真正就绪的连接?
要回答它,我们需要先从最简单的非阻塞轮询开始。
一、轮询的问题
1. 阻塞式读取
传统 BIO 中,线程调用:
int count = input.read(buffer);
如果连接中没有数据,线程会停在这里。
调用 read()
↓
没有数据
↓
线程等待
↓
数据到达
↓
read() 返回
一个线程阻塞在连接 A 上,就无法继续处理连接 B。
所以传统 BIO 经常采用:
一个连接
↓
一个处理线程
问题是,大量空闲连接会占用大量平台线程。
2. 非阻塞读取
将 SocketChannel 设置为非阻塞:
channel.configureBlocking(false);
再次读取:
int count = channel.read(buffer);
结果可能是:
count > 0
读取到了数据
count == 0
当前没有可立即读取的数据
count == -1
对端关闭了连接
非阻塞模式解决了“线程被一个连接卡住”的问题。
但它没有告诉程序:
下一次应该检查哪个连接?
应用只能自己轮询:
检查 fd 4
检查 fd 5
检查 fd 6
检查 fd 7
……
检查 fd 100003
如果共有十万个连接,只有三个有数据:
有效检查:3 次
无效检查:99997 次
这就是手工非阻塞轮询的问题。
3. 系统调用太多
每次调用:
channel.read(buffer);
最终都可能进入操作系统,检查该 Socket 当前是否有数据。
假设应用每轮检查十万个连接:
10 万个连接
↓
最多 10 万次读取尝试
↓
大量无效的用户态与内核态交互
即使每次调用都立即返回,调用本身也不是零成本。
因此,我们真正需要的不是:
应用逐个询问:
“你有数据吗?”
而是:
应用一次询问内核:
“哪些连接有数据?”
二、多路复用
I/O 多路复用的核心思想是:
多个文件描述符
↓
交给一个等待接口
↓
内核检查就绪状态
↓
返回已经就绪的描述符
这里的“多路”指:
多个 Socket
多个文件描述符
多个 I/O 通道
“复用”指:
一个线程
+
一次等待调用
+
管理多条 I/O 路径
可以把模型理解为:
fd 4 ─┐
fd 5 │
fd 6 │
fd 7 ├──→ 多路复用器 ──→ fd 5、fd 920 已就绪
... │
fd N ─┘
多路复用器只报告:
哪个 fd 可以读
哪个 fd 可以写
哪个监听 fd 可以 accept
哪个 fd 出现错误
它不会自动替应用完成业务数据读取。
应用获得就绪结果后,仍然需要调用:
accept()
read()
write()
所以,多路复用优化的是:
等待和发现就绪连接的过程。
而不是直接替应用处理网络协议。
三、select 怎么做?
select() 是经典的同步 I/O 多路复用接口。调用者分别提供需要关注读取、写入和异常状态的文件描述符集合,内核等待其中一个或多个描述符进入就绪状态。
简化后的调用形式:
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout
);
1. 提交 fd 集合
应用先准备一组文件描述符:
readfds:
fd 4
fd 5
fd 6
fd 7
然后调用:
select(max_fd + 1, &readfds, NULL, NULL, &timeout);
内核检查这些描述符,等待它们进入可读状态。
返回后,集合会被修改,只保留当前就绪的 fd。所以下一轮调用前,应用需要重新构造集合。
调用前:
fd 4、fd 5、fd 6、fd 7
调用后:
fd 5、fd 7
应用再执行:
if (FD_ISSET(fd, &readfds)) {
read(fd, buffer, size);
}
2. 仍需扫描
select() 返回的是被修改后的 fd 集合。
应用仍然需要从较小的 fd 一直检查到:
最大 fd + 1
寻找哪些 fd 仍在集合中。
因此,它不是:
一次调用后 O(1) 找到全部连接
而是:
一次系统调用
+
内核检查 fd
+
应用扫描返回集合
它减少了逐个执行 read() 的系统调用,却没有消除对描述符范围的扫描。
3. 1024 限制
Linux 标准 select() 只能监控编号小于 FD_SETSIZE 的文件描述符,常见的 FD_SETSIZE 是 1024。限制针对的是文件描述符编号,而不只是集合中有多少个元素。Linux 手册明确建议现代高并发应用使用 poll() 或 epoll()。
例如:
只监控一个 fd
但 fd 编号是 1500
仍然无法放入标准的 fd_set。
4. 超时含义
select() 的超时参数容易记反:
timeout == NULL
一直等待
timeout 为 0 秒 0 微秒
立即返回
timeout 为正数
最多等待指定时间
零超时不是永久阻塞,而是立即执行一次状态检查后返回。
四、poll 改了什么?
poll() 与 select() 解决的是同一类问题:
等待一组文件描述符进入 I/O 就绪状态。
它不再使用固定大小的 fd_set,而是接收一个结构体数组。
struct pollfd {
int fd;
short events;
short revents;
};
其中:
fd
文件描述符
events
应用关注的事件
revents
内核返回的实际事件
调用示例:
struct pollfd fds[3];
fds[0].fd = 4;
fds[0].events = POLLIN;
fds[1].fd = 5;
fds[1].events = POLLIN;
fds[2].fd = 6;
fds[2].events = POLLOUT;
int count = poll(fds, 3, 1000);
1. 没有固定 fd_set
poll() 不受传统 FD_SETSIZE=1024 的直接限制。
能监控多少描述符,主要受到:
进程文件描述符上限
内存
操作系统资源
等条件限制。
2. 仍要传全部 fd
每次调用 poll() 时,应用仍然需要提交完整数组:
第 1 次 poll:
传入 10 万个 pollfd
第 2 次 poll:
再次传入 10 万个 pollfd
第 3 次 poll:
再次传入 10 万个 pollfd
返回后,应用还需要遍历数组,检查:
fds[i].revents
因此,当连接很多、活跃连接很少时,工作量仍然受到全部监控对象数量影响。
poll() 解决了 select() 固定集合大小和接口表达上的部分问题,却没有改变:
每次提交全部关注对象
+
每次检查全部结果
这一基本模式。
五、epoll 三步
epoll 将:
注册关注对象
和:
等待就绪事件
拆成了不同操作。
它主要包含三个系统调用:
epoll_create1()
epoll_ctl()
epoll_wait()
1. 创建实例
int epfd = epoll_create1(0);
调用成功后返回一个文件描述符:
epfd
它指向内核中的一个 epoll 实例。
epoll 实例本身也通过文件描述符表示,因此可以:
关闭
传递
放入其他等待机制
2. 注册关注
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = client_fd;
epoll_ctl(
epfd,
EPOLL_CTL_ADD,
client_fd,
&event
);
epoll_ctl() 可以执行三类操作:
EPOLL_CTL_ADD
添加关注对象
EPOLL_CTL_MOD
修改关注事件
EPOLL_CTL_DEL
删除关注对象
内核会保存该 fd 及应用关注的事件,不需要应用在每次等待时重新传入全部 fd。
3. 等待事件
struct epoll_event events[1024];
int count = epoll_wait(
epfd,
events,
1024,
-1
);
返回后:
events[0 ... count - 1]
包含当前已经就绪的事件。
epoll_wait() 的超时含义为:
-1
一直等待
0
立即返回
正数
最多等待指定毫秒数
它返回的是就绪列表中的事件信息,而不是全部注册的文件描述符。
六、两份集合
从用户空间观察,epoll 实例可以理解为维护两份集合:
Interest List
Ready List
Linux 手册也使用这两个概念解释 epoll。
1. Interest List
Interest List 保存:
应用注册了哪些 fd
每个 fd 关注哪些事件
与事件关联的用户数据
例如:
fd 4:关注可接收连接
fd 5:关注可读
fd 6:关注可读
fd 7:关注可读和错误
epoll_ctl() 操作的就是这份关注集合。
2. Ready List
Ready List 保存:
当前已经发生关注事件的对象引用
例如:
fd 5:可读
fd 7:连接关闭
epoll_wait() 主要从 Ready List 中取得结果。
3. fd 不会被移走
常见说法是:
fd 平时在红黑树中
数据到达后,
fd 被移动到就绪链表
这会造成误解。
更准确的理解是:
fd 对应的注册关系
仍然保留在 Interest List 中
fd 就绪时
一个就绪引用被加入 Ready List
Ready List 是 Interest List 中就绪对象的引用集合,而不是把注册对象从关注集合中删除。Linux 手册也明确说明,Ready List 是 Interest List 中 fd 的子集,更精确地说,是对这些 fd 的引用集合。
因此,一次事件返回后,fd 通常仍然受 epoll 监控。
只有执行:
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
或者关闭相应资源,注册关系才会被删除。
4. 红黑树不是 API 承诺
Linux 的具体实现通常会使用适合查找和管理注册对象的数据结构,很多资料会直接称其为红黑树。
但对应用程序来说,稳定且重要的语义是:
Interest List
Ready List
epoll_ctl()
epoll_wait()
至于内核某个版本使用哪种具体结构,属于实现细节,不应该作为 epoll API 的永久承诺。
七、事件如何就绪?
现在考虑一条 TCP 连接:
客户端
↓
发送网络数据
↓
服务端网卡收到数据
后续过程可以抽象为:
网卡接收数据
↓
驱动和 Linux 网络栈处理
↓
找到对应 TCP 连接
↓
数据进入 Socket 接收队列
↓
Socket 变为可读
↓
内核更新相关就绪通知
↓
epoll Ready List 出现事件
↓
唤醒 epoll_wait()
epoll 手册并没有把这一过程描述成“应用主动扫描所有 fd”,而是说明 Ready List 会随着这些文件描述符上的 I/O 活动由内核动态填充。
1. 不一定每包一次中断
把网络接收简单理解为:
一个数据包
↓
一次硬中断
并不准确。
现代 Linux 网络栈使用 NAPI 等机制处理网络事件。在负载较高时,可以结合中断与轮询批量处理数据;Linux 还支持 Busy Polling,由应用在设备中断触发前主动检查数据,以 CPU 消耗换取更低延迟。
所以更准确的说法是:
网卡中断可能是网络数据处理的起点之一,但 epoll 面向的是文件描述符就绪状态,而不是直接面向某次硬件中断。
2. 不只支持 Socket
epoll 监控的是支持就绪通知语义的文件描述符。
除网络 Socket 外,还可能包括:
管道
FIFO
eventfd
timerfd
signalfd
部分设备
因此,不能把 epoll 简化成:
网卡中断监听器
它是更通用的:
I/O 事件通知机制
八、为什么更快?
假设服务器监控十万个连接,本轮只有三个连接有事件。
1. 手工轮询
应用依次 read 十万个 fd
↓
找到 3 个有数据的连接
应用需要逐个发起读取尝试。
2. select 和 poll
每轮提交全部 fd
↓
内核检查全部 fd
↓
应用扫描返回集合
一次调用减少了系统调用次数,但每轮仍与全部监控对象有关。
3. epoll
第一次注册十万个 fd
↓
内核长期保存关注关系
事件到达
↓
内核形成就绪记录
epoll_wait()
↓
返回本轮就绪的 3 个事件
epoll 的核心优势不是“所有操作都是 O(1)”,而是:
等待时不需要应用反复提交并扫描全部空闲连接,处理成本更接近实际发生的就绪事件规模。
但 epoll 仍然存在成本:
添加和删除注册
修改关注事件
内核维护就绪状态
唤醒等待线程
把事件返回用户空间
应用处理每个就绪事件
如果十万个连接同时就绪,应用仍然要处理十万个事件。
所以:
epoll 擅长连接多、活跃少
不等于:
epoll 能零成本处理任意负载
九、LT 与 ET
epoll 支持两种主要触发方式:
LT:Level Triggered
ET:Edge Triggered
1. 水平触发
LT 是默认模式。
只要读取条件仍然成立,就可能继续报告可读事件:
接收缓冲区有 10 KB 数据
↓
epoll_wait 返回可读
↓
应用只读取 2 KB
↓
还剩 8 KB
↓
下一次仍可返回可读
它关注的是:
现在是否仍然处于可读状态
可以类比成:
只要水面高于警戒线
警报就可以继续响
LT 较容易写出正确代码。
2. 边缘触发
ET 更关注状态变化:
不可读
↓
变为可读
例如:
接收缓冲区从空变成有数据
↓
触发一次事件
假设应用收到通知后只读了一部分:
缓冲区仍然有数据
但没有再次发生“空 → 非空”
下一次 epoll_wait() 可能不会再次提醒。
所以 ET 模式通常需要:
fd 设置为非阻塞
收到可读事件后
不断读取
直到 read 返回 EAGAIN
Linux 手册明确建议,ET 模式使用非阻塞 fd,并一直处理到 read() 或 write() 返回 EAGAIN。
伪代码如下:
while (1) {
ssize_t count = read(fd, buffer, sizeof(buffer));
if (count > 0) {
handle(buffer, count);
continue;
}
if (count == -1 && errno == EAGAIN) {
break;
}
if (count == 0) {
close(fd);
break;
}
handle_error();
break;
}
3. ET 不一定更好
ET 可能减少部分重复通知,但会增加:
读取到 EAGAIN 的要求
写半包处理
连接状态管理
饥饿控制
错误处理难度
如果一个连接中积压了大量数据,应用一直读取到 EAGAIN,还可能让其他连接长时间得不到处理。
所以:
ET 更复杂
不等于:
ET 在任何场景都更快
十、写事件陷阱
读事件通常来自对端:
对端发送数据
↓
本地 Socket 可读
写事件则不同。
一个 Socket 只要发送缓冲区还有空间,通常就具备一定的可写条件。EPOLLOUT 表示关联对象当前可执行写操作,而不代表整个业务消息一定能一次写完。
如果应用始终关注写事件:
Socket 大多数时间都可写
↓
epoll_wait 频繁返回
↓
EventLoop 不断处理无意义写事件
更合理的做法是:
平时不关注写事件
直接尝试 write
↓
全部写完
结束
只写了一部分或返回 EAGAIN
↓
保存剩余数据
↓
注册 OP_WRITE
再次可写
↓
继续发送
↓
发送完成后取消 OP_WRITE
这也是事件驱动网络程序中非常重要的原则:
读事件通常长期关注,写事件通常按需关注。
十一、Java Selector
Java 使用:
Selector
SelectableChannel
SelectionKey
抽象底层多路复用能力。
Selector 是 SelectableChannel 的多路复用器,通过系统默认的 SelectorProvider 创建;Java API 并不向应用承诺底层一定使用某一个特定系统调用。
因此:
Selector selector = Selector.open();
语义是:
创建平台提供的 Selector
而不是 Java 规范层面保证:
必然直接调用 epoll_create
在不同平台、不同 JDK 实现和版本中,底层实现可以不同。
1. 注册 Channel
监听 Channel:
ServerSocketChannel server =
ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(9090));
server.register(
selector,
SelectionKey.OP_ACCEPT
);
连接 Channel:
client.configureBlocking(false);
client.register(
selector,
SelectionKey.OP_READ
);
一个 Channel 注册到 Selector 后,会得到一个 SelectionKey:
Channel
↓
SelectionKey
↓
Selector
SelectionKey 保存:
对应的 Channel
对应的 Selector
关注事件 interestOps
就绪事件 readyOps
应用附加对象 attachment
2. 三个集合
Java Selector 维护三组重要的 Key:
Key Set
Selected-Key Set
Cancelled-Key Set
其中:
Key Set
当前所有有效注册
Selected-Key Set
本轮或此前发现就绪的 Key
Cancelled-Key Set
已申请取消、等待解除注册的 Key
Oracle 文档明确说明,Selected-Key Set 是 Key Set 的子集,并保存已经检测到就绪事件的 Key。
3. 等待事件
int count = selector.select();
它会阻塞,直到:
至少一个 Channel 就绪
Selector 被 wakeup()
当前线程被中断
Selector 被关闭
带超时版本:
selector.select(1000);
其中:
timeout > 0
最多等待指定毫秒
timeout == 0
一直等待
Java Selector.select(0) 与 Linux select() 的零超时语义不同:Java 中零表示无限等待,而 Linux select() 的零 timeval 表示立即返回。
立即返回应使用:
selector.selectNow();
4. 处理 Key
经典写法:
Iterator<SelectionKey> iterator =
selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 处理完成后从 Selected-Key Set 移除
iterator.remove();
if (!key.isValid()) {
continue;
}
if (key.isAcceptable()) {
handleAccept(key);
}
if (key.isReadable()) {
handleRead(key);
}
if (key.isWritable()) {
handleWrite(key);
}
}
Selected-Key Set 允许应用删除 Key,但不允许应用直接添加 Key。经典循环中,如果处理后不移除,旧 Key 会继续留在集合中,容易造成重复处理或状态判断混乱。
5. accept 后仍是阻塞模式
有一个容易忽略的细节:
SocketChannel client = server.accept();
即使 ServerSocketChannel 是非阻塞模式,返回的新 SocketChannel 默认仍然是阻塞模式。
因此需要再次调用:
client.configureBlocking(false);
然后才能注册到 Selector。
十二、完整示例
下面是一个精简的单线程 Selector 服务端:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
public final class SelectorServer {
public static void main(String[] args) throws IOException {
try (Selector selector = Selector.open();
ServerSocketChannel server =
ServerSocketChannel.open()) {
server.configureBlocking(false);
server.bind(new InetSocketAddress(9090));
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
Iterator<SelectionKey> iterator =
selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (!key.isValid()) {
continue;
}
try {
if (key.isAcceptable()) {
accept(selector, key);
}
if (key.isReadable()) {
read(key);
}
if (key.isWritable()) {
write(key);
}
} catch (IOException exception) {
close(key);
}
}
}
}
}
private static void accept(
Selector selector,
SelectionKey key
) throws IOException {
ServerSocketChannel server =
(ServerSocketChannel) key.channel();
SocketChannel client;
// 非阻塞模式下一次可能积压多个连接。
while ((client = server.accept()) != null) {
client.configureBlocking(false);
ConnectionContext context =
new ConnectionContext();
client.register(
selector,
SelectionKey.OP_READ,
context
);
}
}
private static void read(SelectionKey key)
throws IOException {
SocketChannel client =
(SocketChannel) key.channel();
ConnectionContext context =
(ConnectionContext) key.attachment();
ByteBuffer buffer = context.readBuffer;
int count = client.read(buffer);
if (count == -1) {
close(key);
return;
}
if (count == 0) {
return;
}
buffer.flip();
String message =
StandardCharsets.UTF_8.decode(buffer).toString();
System.out.println(message);
buffer.compact();
}
private static void write(SelectionKey key)
throws IOException {
SocketChannel client =
(SocketChannel) key.channel();
ConnectionContext context =
(ConnectionContext) key.attachment();
ByteBuffer buffer = context.writeBuffer;
if (buffer == null) {
key.interestOps(
key.interestOps() & ~SelectionKey.OP_WRITE
);
return;
}
client.write(buffer);
if (!buffer.hasRemaining()) {
context.writeBuffer = null;
key.interestOps(
key.interestOps() & ~SelectionKey.OP_WRITE
);
}
}
private static void close(SelectionKey key) {
key.cancel();
try {
key.channel().close();
} catch (IOException ignored) {
}
}
private static final class ConnectionContext {
private final ByteBuffer readBuffer =
ByteBuffer.allocate(4096);
private ByteBuffer writeBuffer;
}
}
这只是 I/O 框架。
生产代码还需要处理:
消息边界
半包与粘包
字符解码状态
多个待发送 Buffer
写半包
连接超时
流量控制
背压
TLS
异常恢复
Selector 只负责告诉应用:
哪些 Channel 当前就绪
它并不理解业务协议。
十三、EventLoop
Selector 通常被放进一个循环:
while (!stopped) {
selector.select();
processSelectedKeys();
runTasks();
}
这就是 EventLoop 的基本形态:
等待事件
↓
处理 I/O
↓
执行任务
↓
再次等待
1. 不能阻塞
假设一个 EventLoop 管理一万个连接。
某个连接的回调中执行:
查询数据库 3 秒
调用远程服务 5 秒
执行复杂计算 10 秒
那么同一 EventLoop 管理的其他连接都不能及时处理。
连接 A 执行慢任务
↓
连接 B 的读事件等待
连接 C 的写事件等待
连接 D 的新消息等待
所以 EventLoop 中应该只执行:
快速读取
协议解码
状态更新
任务分发
快速写入
耗时或阻塞操作应交给其他执行器。
2. 单线程不等于单核架构
一个 Selector 通常由一个 EventLoop 线程驱动。
但服务器可以创建多个 EventLoop:
EventLoop 1 → 一组连接
EventLoop 2 → 一组连接
EventLoop 3 → 一组连接
EventLoop 4 → 一组连接
这样既保留每组连接的线程内串行化,又能利用多个 CPU 核心。
Netty 的 EventLoopGroup,本质上就是对这类模型的进一步封装与工程化。
十四、同步还是异步?
epoll 经常被称为“异步通知”,但从 I/O 模型分类看,它通常仍属于同步 I/O 多路复用。
过程是:
应用调用 epoll_wait()
↓
内核返回哪个 fd 就绪
↓
应用自己调用 read()
↓
数据进入应用缓冲区
关键点是:
真正的数据读取
仍由应用线程发起并完成
因此 epoll 提供的是:
Readiness Notification
就绪通知
而不是:
Completion Notification
完成通知
1. 就绪模型
内核:
“fd 5 现在可以读了。”
应用:
“我调用 read() 读取。”
select、poll、epoll 和 Java Selector 都属于这种思路。
Linux 的 select() 手册也直接将其称为同步 I/O 多路复用。
2. 完成模型
真正的异步完成模型更接近:
应用:
“请把这个 I/O 操作完成。”
内核:
执行操作
完成后:
把结果放入完成队列
现代 Linux 提供 io_uring 等异步接口。io_uring 使用提交队列描述请求,并通过完成队列返回完成结果,因此“Linux 没有异步 I/O”已经不是正确结论。
3. 异步处理不等于异步 I/O
下面这段代码:
selectorThreadRead();
workerPool.submit(() -> handle(data));
只是把业务处理交给了其他线程。
网络数据仍然由 Selector 线程调用 read() 读取,因此 I/O 模型仍然是同步就绪模型。
不能因为使用了:
线程池
回调
CompletableFuture
就把底层 I/O 自动称为异步 I/O。
十五、常见误区
1. 非阻塞就是多路复用
错误。
非阻塞只表示调用不能立即完成时快速返回。
如果应用仍然逐个扫描所有 fd,就只是手工轮询。
2. 多路复用器直接读取数据
错误。
它只报告就绪状态,应用仍需调用 read() 或 write()。
3. select 一次调用是 O(1)
错误。
内核需要检查描述符,应用还要扫描修改后的集合。
4. select 的超时为零表示永久等待
错误。
Linux select() 的零 timeval 表示立即返回;传入 NULL 才是无限等待。
5. poll 完全解决了遍历问题
错误。
poll 不受传统 fd_set 大小限制,但每轮仍要提交和检查完整数组。
6. epoll 每次都扫描红黑树
错误。
epoll 通过长期保存关注关系和单独维护就绪信息,避免每次等待都重新提交和扫描所有空闲 fd。
7. fd 会从红黑树移到链表
不准确。
注册关系仍在 Interest List 中,就绪时 Ready List 中增加相应引用。
8. epoll 只依赖网卡中断
错误。
现代网络处理可能结合中断、NAPI 轮询和 Busy Polling;epoll 面向的是 fd 就绪状态,而不是某个特定硬件中断。
9. epoll 只能监控 Socket
错误。
它面向支持就绪通知的文件描述符,还可用于管道、eventfd、timerfd 等对象。
10. epoll_wait 返回后数据已经进入 Java Buffer
错误。
它只表示 fd 当前就绪,Java 仍需调用 SocketChannel.read()。
11. epoll 是纯异步 I/O
错误。
epoll 主要提供就绪通知,实际读写仍由应用发起。
12. Linux 没有异步 I/O
错误。
Linux 已有 AIO、io_uring 等异步能力。
13. ET 一定比 LT 快
错误。
ET 可能减少部分重复通知,但代码更复杂,读取不彻底还可能造成连接长期得不到再次通知。
14. ET 收到事件后读取一次即可
错误。
通常需要持续读取到 EAGAIN。
15. OP_WRITE 应该一直注册
错误。
Socket 经常处于可写状态,一直注册可能导致 Selector 频繁返回。通常只在存在未写完数据时关注写事件。
16. Selector.open() 必然调用 epoll_create
不属于 Java API 保证。
Java 使用平台 SelectorProvider,具体后端属于 JDK 与操作系统实现细节。
17. Java select(0) 会立即返回
错误。
Java Selector.select(0) 表示无限等待;立即返回应使用 selectNow()。
18. 单线程 Selector 能处理任何业务
错误。
它适合管理大量 I/O 事件,但不能让阻塞业务、复杂计算或慢数据库操作变快。
总结
手工非阻塞轮询的模型是:
应用遍历所有连接
↓
逐个尝试 read()
↓
找到少量有数据的连接
问题在于:
连接越多
无效检查越多
select() 把多个 fd 一次性交给内核:
准备 fd_set
↓
select()
↓
内核修改集合
↓
应用扫描就绪 fd
但它受到传统 FD_SETSIZE 限制,并且每轮都要重新准备和扫描集合。
poll() 使用 pollfd 数组,取消了固定 fd_set 限制:
提交全部 pollfd
↓
内核设置 revents
↓
应用遍历数组
它仍然需要每轮处理完整描述符数组。
epoll 将注册和等待拆开:
epoll_create1()
创建 epoll 实例
epoll_ctl()
长期保存关注关系
epoll_wait()
获取当前就绪事件
epoll 可以从用户空间角度理解为维护:
Interest List
所有关注对象
Ready List
当前就绪对象的引用
fd 就绪后,不是从关注集合中被删除,而是其就绪引用进入 Ready List。
网络数据到达时,可能经历:
网卡与驱动
↓
NAPI 和网络协议栈
↓
Socket 接收队列
↓
Socket 变为可读
↓
Ready List 更新
↓
epoll_wait 返回
LT 关注当前状态:
只要仍可读
就可以继续通知
ET 关注状态变化:
不可读 → 可读
所以 ET 通常要求:
非阻塞 fd
+
一直读取到 EAGAIN
Java Selector 则把平台多路复用能力抽象成:
Selector
SelectionKey
SelectableChannel
应用通过 interestOps 表达关注事件,通过 readyOps 判断就绪事件,再调用 accept()、read() 和 write() 完成真实 I/O。
最后,可以用一句话概括 epoll 的核心:
select 和 poll 在等待时重新检查全部连接,而 epoll 长期保存关注关系,并让内核提前记录已经就绪的连接,使应用只处理本轮真正发生事件的对象。
评论区