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_ADD | register() | 将 fd 添加到 epoll 监听集合 |
EPOLL_CTL_MOD | reregister() | 修改已有 fd 的监听事件 |
EPOLL_CTL_DEL | deregister() | 从 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 持续可读时反复通知。减少
epoll_wait返回的无用事件。 - 简化 API:mio 0.6 允许用户选择 edge/level-triggered(通过
PollOpt),但这引入了太多复杂性和 footgun。0.7 起统一为 edge-triggered。 - 上层兼容:tokio 的 I/O driver 就是按照 edge-triggered 的假设设计的。
边缘触发的核心规则:收到事件后,必须把 fd 的数据全部读完直到返回 WouldBlock。否则如果没有新数据到达,内核不会再次通知。
Interest 到 epoll 标志的映射
| mio Interest | epoll 标志 | 含义 |
|---|---|---|
READABLE | EPOLLIN | EPOLLRDHUP | 可读 + 对端关闭检测 |
WRITABLE | EPOLLOUT | 可写 |
PRIORITY | EPOLLPRI | 带外数据/优先级事件 |
| (始终设置) | 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_READ 或 filter == 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.rs、src/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 handle
- 关心的事件掩码(
POLL_RECEIVE、POLL_SEND、POLL_DISCONNECT等) - timeout
延迟提交策略
关键性能优化:socket 注册时 不会立即发起 AFD_POLL 操作。而是:
register()/reregister()将 socket 放入update_queue- 在
poll()即将调用GetQueuedCompletionStatusEx之前,批量刷新update_queue,对每个 socket 发起IOCTL_AFD_POLL
这样避免了注册后不立即 poll 时的无用系统调用。
边缘触发的模拟
IOCP 本质上是电平触发(level-triggered)——只要操作完成了,每次 poll 都会报告。mio 需要在上面模拟边缘触发:
- 事件交付给用户后,清除该 socket 的事件标志
- 用户的下一次 I/O 操作(通过
do_io())如果返回WouldBlock,触发重新注册,使 socket 再次进入监听状态
已知的兼容性问题
-
LSP (Layered Service Provider) 问题:某些网络安全软件安装的 LSP 会破坏
SIO_BASE_HANDLEIOCTL。mio 实现了多级 fallback 来获取底层 socket handle。 -
AFD_POLL 不可修改/取消:一旦发起了 AFD_POLL,无法原地修改它的参数。要改变 interest 必须先取消(
NtCancelIoFileEx),然后重新提交。 -
单线程 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:
- Reactor 模式的读:收到就绪通知 → 用户调
read(buf)→ 数据从内核 buffer 拷贝到用户 buffer - IOCP Proactor 模式的读:提交 buffer → OS 直接 DMA 到 buffer → 通知完成(零拷贝)
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),顶层的 Poll 和 Registry 只是代理到 sys::Selector。
Waker 实现总结
| 平台 | 机制 | 开销 |
|---|---|---|
| Linux | eventfd — 写入 1u64 触发可读事件 | 1 个额外 fd |
| illumos | eventfd — 同上,但需要先 reset 才能触发 edge-triggered 事件 | 1 个额外 fd |
| macOS/BSD | EVFILT_USER — kqueue 内置的用户空间事件 | 0 个额外 fd |
| Windows | PostQueuedCompletionStatus — 向 IOCP 投递一个完成通知 | 0 个额外 fd |
支持的平台列表
mio 1.2.1 的 Cargo.toml 配置了 12 个构建目标的文档:
- Linux (x86_64, aarch64)
- macOS (x86_64)
- Windows (x86_64, i686)
- iOS (aarch64)
- Android (aarch64)
- FreeBSD (x86_64)
- NetBSD (x86_64)
- WASI (wasm32)
已明确 不支持 的平台:Solaris(1.0 起删除)、Fuchsia、Bitrig。