如果你了解过 Java 的 Netty 框架,PHP 的 swoole 框架,它们都会提到自己是基于 异步事件驱动的网络框架。
异步事件驱动
📌 epoll 内核层的异步事件驱动
epoll 在操作系统内核中的实现是基于 异步事件驱动的方式,完成了 就绪通知 ****这部分。这部分的实现确实是异步的。
- 硬件层:真正的异步(网卡DMA、中断)
- 内核层:epoll是"准异步"(对应用层是同步接口)
关于epoll,可以这样理解:
- 就绪通知是异步事件驱动的:当网络数据到达网卡,内核通过中断、软中断,再触发epoll监视的回调函数,将就绪的fd加入列表。这个过程对你应用程序来说是异步的,你无需主动询问。
- 数据读写是同步的:
epoll_wait返回后,你的应用程序需要自己调用read或write函数来完成数据在内核和用户空间之间的传输。这个IO操作过程是同步的。
所以, "epoll是基于异步事件驱动完成了就绪通知,但本身是同步IO" 这个理解是完全正确的。
📌 epoll 内核层的异步事件驱动 ≠ 异步 I/O
但IO 多路复用也仅仅是 就绪通知,而真正的异步 I/O 是把整个 I/O 搞定后才进行的通知。
I/O 多路复用:你告诉内核“帮我监视这100个socket,如果哪个有数据可读了通知我”。当通知到来时,它只是告诉你“某个socket就绪了” ,但数据的读写操作(从内核缓冲区拷贝到用户缓冲区)仍然需要你的线程自己调用read()函数来完成。所以,这个“通知”只是一个“就绪通知”,I/O 操作本身还是同步的。
真异步I/O:你告诉内核“去读这个socket,读满1KB数据到这个缓冲区,全部搞定后通知我”。数据的等待和拷贝都由内核完成,给你的通知是“完成通知”。
📌 事件循环 (应用层的)
swoole 给的定义
swoole 官网文档 认为:事件循环其实是 阻塞的epoll_wait在给应用程序返回就绪事件列表后,应用程序循环该列表挨个处理已经就绪的事件, 这是一轮事件循环, 要想进入第二轮事件循环, 应用程序就需要再次阻塞到 epoll_wait 处等待操作系统内核通知已就绪的事件列表,然后就能再次循环该列表挨个处理已经就绪的事件。(也就是下面应用层代码的 外层 while(true) 和 内层循环 共同在做 事件循环)
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
public class NIOServerDemo {
public static void main(String[] args) throws IOException {
// 1. 创建 Selector(多路复用器)
// 它可以监听多个 Channel 的 I/O 事件(读、写、连接等)
Selector selector = Selector.open();
// 2. 创建 ServerSocketChannel(相当于 ServerSocket)
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false); // 设置为非阻塞模式
// 绑定端口
serverChannel.bind(new InetSocketAddress(8080));
System.out.println("NIO 服务器启动,监听端口: 8080");
// 3. 将 ServerSocketChannel 注册到 Selector 上
// 监听 "ACCEPT" 事件:表示有新客户端连接请求
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
// ▇▇▇▇▇ 这就是 NIO 的核心:事件轮询主循环 ▇▇▇▇▇
while (true) { // 外层大循环:持续运行服务器
System.out.println("\n--- 开始轮询(调用 select())---");
// 4. 【关键点】selector.select() 是阻塞的,但可以设置超时
// 它会阻塞直到至少有一个注册的 Channel 准备好进行 I/O 操作
// 内部调用的是 epoll_wait()(Linux)或 kqueue(macOS)等系统调用
int readyChannels = selector.select(); // ←←← 这就是“轮询”的开始!
// 底层机制:在Linux上,Java NIO的Selector底层使用的是epoll(或者select/poll,但现代Linux通常用epoll)。
// epoll是一种I/O事件通知机制,它允许一个进程监视多个文件描述符,当某个文件描述符就绪(例如,可读、可写)时,epoll会通知应用程序。
// 所以,您的理解是正确的,底层是事件通知机制。
// 应用层的阻塞行为:selector.select() 方法在应用层表现为阻塞调用,直到至少有一个通道就绪(或者被其他线程唤醒,或者超时)。
// 但是,Selector 也可以配置为非阻塞模式吗?
// 实际上,Selector 本身并不像 Channel 那样有“阻塞模式”和“非阻塞模式”的概念。
// 但是,select 方法有几个重载:
// select(): 阻塞,直到至少有一个通道就绪。
// select(long timeout): 阻塞,直到至少有一个通道就绪,或者超过指定的超时时间。
// selectNow(): 非阻塞,立即返回当前就绪的通道数量(可能为0)。
// 所以,从应用层来看,select() 和 select(long timeout) 是阻塞的,而 selectNow() 是非阻塞的。
// 但是,您可能会注意到,在代码中我们通常会在一个循环中调用 select(),然后处理就绪的通道。这个循环就是事件循环。
// 为什么在事件循环中通常使用阻塞的 select()?
// **因为阻塞的 select() 在没有事件时会让出CPU,不会空转,从而减少系统资源的消耗**。而如果使用非阻塞的 selectNow(),那么在没有事件时,循环会不断空转,消耗CPU。
// 既然是阻塞的,下面这行检测就没有必要有
// if (readyChannels == 0) {
// // 没有就绪的 Channel,继续下一轮
// System.out.println("没有就绪的通道,继续等待...");
// continue;
// }
// 5. 获取所有就绪的 Channel 对应的 SelectionKey 集合
// 这个集合就是 epoll 返回的“就绪事件列表”
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
// ▇▇▇ 内层循环:遍历就绪的事件(这就是你说的“内部循环读取就绪列表”)▇▇▇
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
// 必须手动移除,否则下次还会出现在集合中
keyIterator.remove();
try {
if (key.isValid()) { // 确保 key 没有失效
// 6. 判断是什么类型的事件
if (key.isAcceptable()) {
// 有新连接到来
handleAccept(key, selector);
} else if (key.isReadable()) {
// 有数据可读
handleRead(key);
} else if (key.isWritable()) {
// 可以写数据(通常用于发送大数据分段)
handleWrite(key);
}
}
} catch (IOException e) {
System.err.println("处理事件出错: " + e.getMessage());
key.cancel(); // 出错则取消注册
try {
key.channel().close();
} catch (IOException ex) {
ex.printStackTrace();
}
}
}
}
}
/**
* 处理 ACCEPT 事件:接受新客户端连接
*/
private static void handleAccept(SelectionKey key, Selector selector) throws IOException {
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
// 接受客户端连接,返回一个 SocketChannel
SocketChannel clientChannel = serverChannel.accept();
clientChannel.configureBlocking(false); // 设置为非阻塞
System.out.println("新客户端连接: " + clientChannel.getRemoteAddress());
// 将客户端 Channel 注册到 Selector,监听 READ 事件
clientChannel.register(selector, SelectionKey.OP_READ);
}
/**
* 处理 READ 事件:读取客户端发来的数据
*/
private static void handleRead(SelectionKey key) throws IOException {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024); // 分配缓冲区
int bytesRead;
try {
// 7. 【关键点】read() 是非阻塞的!
// 如果没有数据可读,立即返回 0;如果连接关闭,返回 -1
bytesRead = clientChannel.read(buffer);
} catch (IOException e) {
// 读取失败,可能是客户端断开
throw e;
}
if (bytesRead > 0) {
// 有数据可读
buffer.flip(); // 切换为读模式
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String message = new String(data);
System.out.println("收到消息 from " + clientChannel.getRemoteAddress() + ": " + message);
// 回复客户端(这里只是演示,实际中可能需要异步写)
// 注意:write() 也是非阻塞的,可能无法一次写完
ByteBuffer response = ByteBuffer.wrap(("Echo: " + message).getBytes());
clientChannel.write(response); // 非阻塞写
} else if (bytesRead == -1) {
// 客户端关闭连接
System.out.println("客户端断开: " + clientChannel.getRemoteAddress());
clientChannel.close();
key.cancel();
}
// 如果 bytesRead == 0,说明没有数据,但 channel 可读(可能是空包),忽略即可
}
/**
* 处理 WRITE 事件(通常用于大文件分段发送)
*/
private static void handleWrite(SelectionKey key) throws IOException {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer pendingData = (ByteBuffer) key.attachment(); // 假设我们把待发送的数据存放在 attachment 中
if (pendingData != null && pendingData.hasRemaining()) {
int bytesWritten = clientChannel.write(pendingData);
System.out.println("写入 " + bytesWritten + " 字节");
if (!pendingData.hasRemaining()) {
// 数据全部写完,取消写事件监听
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
📌 总结
当我们基于epoll I/O多路复用进行网络编程时,其实epoll内部在操作系统内核的实现其实是通过硬件中断做到了 异步事件驱动 ,从而实现了 就绪事件 的通知,因此内核的 就绪事件 通知是异步的,因为这是系统内核通过硬件中断实现的异步通知,内部并不存在 事件循环 的概念。
而应用程序是阻塞在 epoll_wait处等待内核的就绪事件通知,为了不断拿到就绪事件列表进行处理,应用程序需要通过 while(true) 来持续对 epoll_wait()是否有就绪事件进行检查, 不过好在 epoll_wait()是阻塞的,期间会释放 CPU,不会致使cpu达到100% ,因此 事件轮询是高效的 。另外应用程序在每一轮获取到就绪事件列表后,还要循环处理列表中的每个事件,我认为这也算是事件轮询的一部分。
参考: chat.deepseek.com/share/kd8ok…
操作系统内核 epoll 的异步事件驱动做到了就绪事件通知 + 应用程序 while(true) + 阻塞的 epoll_wait() ,避免了 传统 while(true) 的盲目轮询导致的 CPU 100%。
正是操作系统内核提供的epoll这样的机制,能够高效地通知应用程序“哪些I/O事件已经准备就绪”,事件循环才能有的放矢,避免盲目的轮询,从而实现用少量线程处理海量并发连接。可以说, epoll等I/O多路复用技术是构建用户空间异步事件驱动框架的底层基石 。
selector.select() :是真正的事件轮询点,阻塞等待操作系统的就绪事件通知。
📌传统轮询 vs 事件轮询+阻塞的epoll
传统轮询 - CPU忙等待
假设 epoll_wait()是非阻塞的,应用程序就需要 主动、频繁地检查事件是否已经准备好了。
cpu 使用率会直接 100%。
相当于你点了一份菜,不停地问服务员菜好了没。
事件轮询+阻塞的 epoll :CPU 的释放
被动等待内核通知,只有真正有事件时才处理。没有事件时,epoll_wait()的阻塞等待会释放 cpu 资源。
操作系统内核的 epoll 就绪事件通知 + 应用程序 epoll_wait()的阻塞 ,避免了 while(true) 的盲目轮询。
相当于你点了一份菜,可以干别的事情了,服务员才好了之后会通知你。
所以 epoll 就应该是阻塞的。