侧边栏壁纸
  • 累计撰写 146 篇文章
  • 累计创建 21 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

Epoll 如何找到就绪连接?

YaFuX
2026-06-22 / 0 评论 / 0 点赞 / 0 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

上一篇我们讲到,为了解决 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

抽象底层多路复用能力。

SelectorSelectableChannel 的多路复用器,通过系统默认的 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 长期保存关注关系,并让内核提前记录已经就绪的连接,使应用只处理本轮真正发生事件的对象。

0

评论区