3. epoll IO多路复用你可能理解错了,它既是异步事件驱动,也是同步阻塞

87 阅读10分钟

由 操作系统内核 实现

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个线程可以服务上万个连接

......

其实关于什么是 异步事件驱动,后面还会做更详细的介绍。