由 操作系统内核 实现
epoll 是 Linux 内核为处理大量文件描述符而设计的多路复用 I/O 机制。
epoll 的核心功能 完全在 Linux 内核中实现。包括事件注册、监控、通知等机制都在内核空间完成。
epoll 相关的系统调用
epoll 相关的系统调用主要有三个:epoll_create, epoll_ctl, 和 epoll_wait,它们属于操作系统内核中的功能。
epoll_create:创建一个 epoll 实例,内核初始化一棵红黑树和一个就绪链表。
epoll_ctl(EPOLL_CTL_ADD) :将你想要监视的 fd 和感兴趣的事件(event)作为一个节点(epitem)插入到红黑树中。同时,内核会为这个 fd 注册一个回调函数。
I/O 事件发生:当某个 fd 上的事件就绪时,内核会调用之前注册的回调函数。这个回调函数将对应的 epitem 节点添加到就绪链表中。
epoll_wait:检查就绪链表。如果链表不为空,说明有事件就绪,它将就绪的事件数据复制到用户空间,并从链表中移除这些项。如果链表为空,进程则会休眠(阻塞),直到有事件发生后被唤醒。
epoll 工作机制
- 应用程序通过系统调用(如epoll_ctl)告诉内核:“我关心这些文件描述符上的事件,当它们发生时请告诉我”。
- 内核会监听这些文件描述符。当某个文件描述符上发生事件(如可读、可写)时,内核会通过文件描述符上绑定的回调函数,将这个文件描述符对应的事件添加到 epoll 的就绪队列中并唤醒等待的进程(即调用epoll_wait的进程)。
- 然后应用程序被唤醒,从epoll_wait 获取返回的就绪事件,并循环处理事件。
我们可以看到 epoll 在操作系统内核中就绪事件通知到应用进程,这个过程其实是异步事件通知的。
应用程序告诉内核:“我关心这些 Socket 上的读/写事件,你帮我看着点。” 然后应用程序就去干别的事了(或者休眠)。当网络数据到达网卡,某个被监控的 Socket 就绪时,内核是主动的发起者。它会通过一个机制(后面详述)通知应用程序:“你关心的那个 XXX 号 Socket 已经就绪了,可以去处理了。”
小结
所以,其实无论你用什么语言做网络编程,无论你在该语言中调用的是什么方法,比如 java 的代码可能如下(其实大致看下流程即可):
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 io 多路复用在操作系统底层的这三个 系统调用。
epoll 在系统内核中的关键组件
两个核心结构
监视列表--红黑树
epoll 内部用于存储所有需要监视的文件描述符(fd)的主要数据结构是一棵 红黑树。这样可以高效地添加、删除和查找文件描述符;
就绪列表--双向连表
它内部还使用双向链表 做了一个 就绪列表(ready list) 来存储已经就绪的事件;
📌epoll 内部的异步编程模式(内部的事件驱动)
应用程序通过 epoll_ctl 向内核注册它关心的 socket 和事件(例如“可读”、“可写”)。当这些事件在某个 socket 上发生时,内核就知道该通知谁。应用程序调用 epoll_wait 时,不再是轮询,而是休眠,直到被内核唤醒并被告知:“你关心的那几个 socket 已经就绪了,可以去操作了。” 这就是典型的事件驱动。
当被监视的文件描述符上有事件发生时,内核会调用一个回调函数(这个回调函数是在添加文件描述符时通过 epoll_ctl 设置的),该回调函数会将这个文件描述符对应的事件添加到 epoll 的就绪队列中。然后,如果有一个进程正在 epoll_wait 上等待,内核会唤醒这个进程。
内核在此处的回调通知,其实是异步的,但这只是能异步将就绪事件放到。
### epoll 内部虽然基于操作系统底层的异步事件通知,但它可不是异步 IO
理解什么是异步通知
异步通知(Asynchronous Notification) :指的是当事件发生时,由内核主动通知应用程序,而不是应用程序主动去查询。
📌 epoll 内部的异步
epoll 的回调机制确实是异步的。因为当被监控的文件描述符上有事件发生时,内核会通过回调机制通知等待的进程。
epoll 内核内部是基于事件驱动的异步通知机制。但是,epoll 本身构建的是“同步 I/O 多路复用”模型。 因为真正的 I/O 数据读写操作(read/write)仍然是由应用程序线程同步阻塞地完成的。epoll 只是解决了“何时去读/写”这个效率问题。
epoll 可不是 AIO
真正的 AIO 指的是整套 IO 的完成都是异步的。
epoll 内部的“异步通知”是指事件就绪的通知,而不是I/O操作完成的异步通知。
在epoll中,当数据到达时,内核会通知应用程序“数据已经准备好了,可以读取了”,尽管内核在这里的操作是异步的,但整个 IO 操作并未完成,应用程序仍然需要调用read函数来将数据从内核缓冲区拷贝到用户缓冲区。这个读取的过程是同步的(因为read调用会阻塞直到数据拷贝完成)。
而在真正的异步I/O中,应用程序发起一个读操作(比如aio_read),内核会负责将数据从设备读到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区,整个过程都是异步的,应用程序在发起读操作后就可以去做别的事情,当整个读操作完成(包括数据拷贝)时,内核会发送一个信号或者调用一个回调函数来通知应用程序。
所以,epoll内部是“事件通知的异步”,而AIO是“I/O操作执行的异步”。
epoll方式:
- 快递员(内核)异步通知你:"快递到了,在门口"
- 但你还是要自己同步去门口取快递
真正异步I/O:
- 快递员(内核)直接把快递放到你指定位置
- 然后异步通知你:"快递已放到书桌上了"
epoll 也是阻塞的(从应用层来看)
抛开操作系统内核的机制,对于应用程序来说,在使用 epoll 时,它是阻塞的,但它是在所有连接上等待任意事件,而不是在单个连接上阻塞。
虽然 epoll 可以同时监视大量文件描述符,但如果一直没有任何事件就绪,进程就会在 epoll_wait 上阻塞。当然,epoll_wait 可以设置超时时间,避免无限期阻塞。不过,对于应用程序来说,epoll_wait 监听了大量文件描述符,偶尔的等待也算是合理的"工作等待"。
| 阻塞类型 | 阻塞对象 | 效率 |
|---|---|---|
| 传统阻塞 | 每个连接单独阻塞 | 1个线程只能服务1个连接 |
| epoll阻塞 | 在所有连接上等待 | 1个线程可以服务上万个连接 |
......
其实关于什么是 异步事件驱动,后面还会做更详细的介绍。