4. epoll 内核层的异步事件驱动 和 应用程序的事件轮询

86 阅读8分钟

如果你了解过 Java 的 Netty 框架,PHP 的 swoole 框架,它们都会提到自己是基于 异步事件驱动的网络框架。

异步事件驱动

📌 epoll 内核层的异步事件驱动

epoll 在操作系统内核中的实现是基于 异步事件驱动的方式,完成了 就绪通知 ****这部分这部分的实现确实是异步的

  • 硬件层:真正的异步(网卡DMA、中断)
  • 内核层:epoll是"准异步"(对应用层是同步接口)

关于epoll,可以这样理解:

  1. 就绪通知是异步事件驱动的:当网络数据到达网卡,内核通过中断、软中断,再触发epoll监视的回调函数,将就绪的fd加入列表。这个过程对你应用程序来说是异步的,你无需主动询问。
  2. 数据读写是同步的epoll_wait返回后,你的应用程序需要自己调用readwrite函数来完成数据在内核和用户空间之间的传输。这个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() :是真正的事件轮询点阻塞等待操作系统的就绪事件通知

image.png

📌传统轮询 vs 事件轮询+阻塞的epoll

传统轮询 - CPU忙等待

假设 epoll_wait()是非阻塞的,应用程序就需要 主动、频繁地检查事件是否已经准备好了。
cpu 使用率会直接 100%。

相当于你点了一份菜,不停地问服务员菜好了没。

事件轮询+阻塞的 epoll :CPU 的释放

被动等待内核通知,只有真正有事件时才处理。没有事件时,epoll_wait()的阻塞等待会释放 cpu 资源。

操作系统内核的 epoll 就绪事件通知 + 应用程序 epoll_wait()的阻塞 ,避免了 while(true) 的盲目轮询。

相当于你点了一份菜,可以干别的事情了,服务员才好了之后会通知你。

所以 epoll 就应该是阻塞的。