跳转到正文
zeno's blog
返回

mio(三):平台后端-epoll、kqueue、IOCP

专题: mio

Table of contents

Open Table of contents

TL;DR

mio 在三个平台上的封装策略完全不同。Linux epoll 和 macOS kqueue 都是 Reactor 模型的原生映射,mio 的封装很薄。Windows IOCP 是 Proactor 模型,mio 必须用 AFD(Auxiliary Function Driver)这个底层驱动来模拟 Reactor 语义——这是 mio 中最复杂的代码路径。


Linux: epoll 集成

源码位置

src/sys/unix/selector/epoll.rs

Selector 结构

pub struct Selector {
    #[cfg(debug_assertions)]
    id: usize,            // debug 模式下用于检测跨 Poll 注册
    ep: OwnedFd,          // epoll 文件描述符
}

三个系统调用

1. epoll_create1(EPOLL_CLOEXEC)

let ep = unsafe { OwnedFd::from_raw_fd(syscall!(epoll_create1(libc::EPOLL_CLOEXEC))?) };

创建 epoll 实例。EPOLL_CLOEXEC 确保 fd 在 fork+exec 时自动关闭——防止子进程意外继承 epoll 实例。

2. epoll_ctl(fd, op, target_fd, event)

三种操作模式:

操作mio 方法语义
EPOLL_CTL_ADDregister()将 fd 添加到 epoll 监听集合
EPOLL_CTL_MODreregister()修改已有 fd 的监听事件
EPOLL_CTL_DELderegister()从 epoll 中移除 fd

3. epoll_wait(epfd, events, maxevents, timeout)

syscall!(epoll_wait(
    self.ep.as_raw_fd(),
    events.as_mut_ptr(),
    events.capacity() as i32,
    timeout,  // 毫秒,-1 表示无限等待
))

边缘触发:EPOLLET 是硬编码的

这是关键设计决策。mio 的 epoll 后端 强制使用边缘触发(edge-triggered),没有选项切换为电平触发:

fn interests_to_epoll(interests: Interest) -> u32 {
    let mut kind = EPOLLET;  // 始终设置 EPOLLET
    if interests.is_readable() {
        kind |= EPOLLIN | EPOLLRDHUP;
    }
    if interests.is_writable() {
        kind |= EPOLLOUT;
    }
    if interests.is_priority() {
        kind |= EPOLLPRI;
    }
    kind as u32
}

为什么硬编码 EPOLLET?

边缘触发的核心规则:收到事件后,必须把 fd 的数据全部读完直到返回 WouldBlock。否则如果没有新数据到达,内核不会再次通知。

Interest 到 epoll 标志的映射

mio Interestepoll 标志含义
READABLEEPOLLIN | EPOLLRDHUP可读 + 对端关闭检测
WRITABLEEPOLLOUT可写
PRIORITYEPOLLPRI带外数据/优先级事件
(始终设置)EPOLLET边缘触发

注意 EPOLLRDHUP 是和 EPOLLIN 绑定的——只要你注册了读事件,mio 就自动帮你监听对端半关闭。

Event 映射

epoll 后端的 Event 直接是 libc::epoll_event

// Event 类型别名
pub type Event = libc::epoll_event;

事件查询函数直接检查 epoll_event.events 位标志:

mio 方法检查的 epoll 标志
is_readable()EPOLLIN | EPOLLPRI
is_writable()EPOLLOUT
is_error()EPOLLERR
is_read_closed()EPOLLHUP 或 (EPOLLIN + EPOLLRDHUP)
is_write_closed()EPOLLHUP,或 (EPOLLERR 单独),或 (EPOLLOUT + EPOLLERR),或 (EPOLLOUT + EPOLLHUP)
is_priority()EPOLLPRI

token() 存储在 epoll_event.u64 字段中。

超时处理

epoll_wait 的 timeout 参数是毫秒级整数。mio 将 Duration 转换为毫秒时向上取整——如果你传 Duration::from_micros(500)(0.5ms),mio 会传 1 给 epoll_wait,而不是 0。这避免了亚毫秒 timeout 被截断为零(立即返回)的意外行为。


macOS/BSD: kqueue 集成

源码位置

src/sys/unix/selector/kqueue.rs

Selector 结构

pub struct Selector {
    #[cfg(debug_assertions)]
    id: usize,
    kq: OwnedFd,  // kqueue 文件描述符
}

核心系统调用

kqueue() — 创建 kqueue 实例 kevent() — 注册事件过滤器 + 等待事件

kqueue 和 epoll 的最大区别:kqueue 用 filter 概念区分事件类型,而不是 epoll 的位标志。读和写是两个独立的过滤器。

注册:两个 kevent 结构体

当注册一个 fd 时,如果同时关心读和写,mio 需要创建 两个 kevent 结构体:

// register() 内部逻辑
if interests.is_writable() {
    // kevent with filter = EVFILT_WRITE, flags = EV_CLEAR | EV_RECEIPT | EV_ADD
}
if interests.is_readable() {
    // kevent with filter = EVFILT_READ, flags = EV_CLEAR | EV_RECEIPT | EV_ADD
}

这和 epoll 不同——epoll 用一个 epoll_event 就能同时表达读写兴趣。

边缘触发:EV_CLEAR

kqueue 通过 EV_CLEAR 标志实现边缘触发语义:

EV_CLEAR — After the event is retrieved by the user, its state is reset.

效果类似 epoll 的 EPOLLET:事件只被报告一次,直到你处理完(drain 数据后状态重置)。

标志组合

mio 在 kqueue 注册时使用的标志:

标志含义
EV_ADD添加过滤器(如果已存在则修改)
EV_CLEAR事件被读取后重置状态(边缘触发)
EV_RECEIPT强制 kevent() 立即返回确认,而不是等待事件

EV_RECEIPT 很重要——它让注册操作变成同步的:kevent() 会立即返回一个确认事件,告诉你注册是否成功,而不是阻塞等待真正的 I/O 事件。

EPIPE 兼容性

源码中有一个有趣的 workaround:

旧版 macOS 在注册 pipe 时可能返回 EPIPE 错误。mio 选择忽略这个错误。

Event 映射

kqueue 的 Event 包装 libc::kevent

pub struct Event {
    inner: libc::kevent,
}

kevent 结构体包含一个 udata 指针字段——mio 用这个字段存储 Token。由于 udata*mut c_void,mio 为 Event 手动实现了 Send + Sync(因为编译器不会自动为包含原始指针的类型实现这些 trait)。

mio 方法kqueue 检查逻辑
is_readable()filter == EVFILT_READfilter == EVFILT_USER
is_writable()filter == EVFILT_WRITE
is_error()flags & EV_ERROR 或 (flags & EV_EOF + fflags != 0)
is_read_closed()filter == EVFILT_READ + flags & EV_EOF
is_write_closed()filter == EVFILT_WRITE + flags & EV_EOF

Waker:EVFILT_USER

kqueue 有一个专门的 EVFILT_USER 过滤器,用于用户空间事件通知。mio 的 Waker 在 kqueue 平台上利用这个机制:

// Waker 结构
pub struct Waker {
    selector: Selector,  // clone 的 selector
    token: Token,
}

wake() 调用 selector 的 wake 方法,向 kqueue 投递一个 EVFILT_USER 事件。这比 eventfd 更轻量——不需要额外的 fd。


Windows: IOCP + AFD 适配

核心挑战

Windows IOCP 是 Proactor 模型:你发起一个异步 I/O 操作,OS 完成后通知你。但 mio 的 API 是 Reactor 模型:你询问哪些 fd 准备好了,然后自己执行 I/O。

要在 IOCP 上模拟 Reactor 语义,mio 使用了 AFD(Auxiliary Function Driver)——这是一个 Windows 内核驱动,支持 IOCTL_AFD_POLL 操作,可以查询 socket 的就绪状态。这个技术并不是 mio 发明的——Node.js 的 libuv 也使用相同的方法。

源码位置

src/sys/windows/selector.rssrc/sys/windows/afd.rs

架构

┌──────────────────────────────────────────────┐
│ mio Reactor API (Poll/Registry/Events)       │
├──────────────────────────────────────────────┤
│ Selector                                     │
│  ┌──────────────┐  ┌──────────────────────┐  │
│  │ CompletionPort│  │ update_queue         │  │
│  │ (IOCP handle)│  │ (VecDeque<SockState>) │  │
│  └──────────────┘  └──────────────────────┘  │
│  ┌──────────────┐                            │
│  │ AfdGroup     │ ← 池化的 AFD 驱动实例      │
│  └──────────────┘                            │
├──────────────────────────────────────────────┤
│ Windows Kernel                               │
│  IOCP + AFD driver (\Device\Afd\Mio)         │
└──────────────────────────────────────────────┘

SockState:每个 socket 的状态机

每个注册到 mio 的 socket 维护一个 SockState

SockState {
    poll_status: Idle | Pending | Cancelled,
    user_interests:  用户请求监听的事件
    pending_events:  内核正在监听的事件
    afd_poll_info:   AFD 轮询参数
    io_status_block: NT I/O 状态块
    delete_pending:  是否待删除
}

状态流转:

register() → Idle

poll() 前刷新 update_queue → 发起 IOCTL_AFD_POLL → Pending

GetQueuedCompletionStatusEx 返回完成 → 处理事件 → Idle

用户调 reregister() → 重新入 update_queue → ...

AFD 驱动的使用

AfdGroup 池化 AFD 实例,每个实例最多管理 32 个 socket

AfdGroup {
    afd_instances: Vec<Afd>,
    // 当前实例满 32 个 socket 时分配新实例
}

IOCTL_AFD_POLL 发送一个 AfdPollInfo 结构到 AFD 驱动,包含:

延迟提交策略

关键性能优化:socket 注册时 不会立即发起 AFD_POLL 操作。而是:

  1. register() / reregister() 将 socket 放入 update_queue
  2. poll() 即将调用 GetQueuedCompletionStatusEx 之前,批量刷新 update_queue,对每个 socket 发起 IOCTL_AFD_POLL

这样避免了注册后不立即 poll 时的无用系统调用。

边缘触发的模拟

IOCP 本质上是电平触发(level-triggered)——只要操作完成了,每次 poll 都会报告。mio 需要在上面模拟边缘触发:

  1. 事件交付给用户后,清除该 socket 的事件标志
  2. 用户的下一次 I/O 操作(通过 do_io())如果返回 WouldBlock,触发重新注册,使 socket 再次进入监听状态

已知的兼容性问题

  1. LSP (Layered Service Provider) 问题:某些网络安全软件安装的 LSP 会破坏 SIO_BASE_HANDLE IOCTL。mio 实现了多级 fallback 来获取底层 socket handle。

  2. AFD_POLL 不可修改/取消:一旦发起了 AFD_POLL,无法原地修改它的参数。要改变 interest 必须先取消(NtCancelIoFileEx),然后重新提交。

  3. 单线程 poll 约束:和 epoll/kqueue 一样,mio 的 Windows 后端也只允许一个线程同时 poll。通过原子标志 is_polling 强制执行。

Event 映射

Windows 后端的事件类型定义了 9 个 AFD 事件标志:

AFD 标志mio 含义
POLL_RECEIVE可读
POLL_SEND可写
POLL_DISCONNECT对端断开
POLL_ABORT连接中止
POLL_ACCEPT有新连接
POLL_CONNECT_FAIL连接失败

Token 策略

Windows 后端使用 偶数 Token 给 AFD handle,奇数 Token 给 NamedPipe 等其他类型。这让 IOCP 事件分发器能快速区分事件来源。

I/O 操作的额外开销

因为 mio 在 Windows 上是 Reactor 模式,而 IOCP 是 Proactor 模式,读/写操作会有一次额外的 buffer copy:

mio 放弃了 IOCP 的零拷贝优势。对于大多数场景这不是瓶颈,但在极端高吞吐的 Windows 服务器上是一个值得注意的性能差异。


平台抽象统一

mio 暴露的跨平台保证

行为保证级别
is_readable() / is_writable()全平台保证
边缘触发语义全平台保证(通过 EPOLLET / EV_CLEAR / AFD 模拟)
is_read_closed() / is_write_closed()尽力而为,不会误报
is_error()尽力而为
is_priority() / is_aio() / is_lio()平台特定,仅在对应平台有效
虚假唤醒(spurious wakeup)可能发生,用户必须处理 WouldBlock

条件编译策略

mio 通过 #[cfg] 属性和 sys 模块下的子模块实现平台分离:

src/sys/
├── unix/
│   ├── selector/
│   │   ├── epoll.rs     (Linux, Android, illumos)
│   │   └── kqueue.rs    (macOS, iOS, FreeBSD, NetBSD, OpenBSD, DragonFly)
│   ├── waker/
│   │   ├── eventfd.rs   (Linux, Android, illumos, Hermit, ESP-IDF)
│   │   └── kqueue.rs    (BSD/macOS: EVFILT_USER)
│   └── ...
├── windows/
│   ├── selector.rs
│   ├── afd.rs
│   └── ...
└── ...

每个 Selector 都暴露相同的方法签名(new, select, register, reregister, deregister),顶层的 PollRegistry 只是代理到 sys::Selector

Waker 实现总结

平台机制开销
Linuxeventfd — 写入 1u64 触发可读事件1 个额外 fd
illumoseventfd — 同上,但需要先 reset 才能触发 edge-triggered 事件1 个额外 fd
macOS/BSDEVFILT_USER — kqueue 内置的用户空间事件0 个额外 fd
WindowsPostQueuedCompletionStatus — 向 IOCP 投递一个完成通知0 个额外 fd

支持的平台列表

mio 1.2.1 的 Cargo.toml 配置了 12 个构建目标的文档:

已明确 不支持 的平台:Solaris(1.0 起删除)、Fuchsia、Bitrig。


分享这篇文章:

上一篇
整洁架构(二):核心原则-依赖方向永远从外向内
下一篇
整洁架构(一):概览-依赖方向与层级职责