Tokio 源码深度解析
第9章 Mio 与系统调用抽象
本章要点
- mio(Metal I/O) 是 Rust 生态的跨平台 I/O 事件原语库,第一个 commit 在 2014 年 8 月、早于 Tokio,是整个 async Rust 生态的基础设施。本章按 mio 1.0.1(tokio 1.40.0 的
Cargo.toml里声明的版本)讲 - mio 三层架构:
Poll(顶层门面)→Registry(fd 注册器,可 clone)→sys::Selector(per-OS 实现) - Linux 下
Selector核心就是 3 个系统调用:epoll_create1(new)、epoll_ctl(register / reregister / deregister)、epoll_wait(select) - macOS / FreeBSD 下 Selector 封装
kqueue(2)/kevent(2)—— 事件模型不同但语义统一 - Windows 下 Selector 建立在 IOCP + AFD 驱动的
IOCTL_AFD_POLL上 —— 最复杂的一个,因为 IOCP 是 completion-based、mio 需要在它之上还原 readiness 语义 mio::Waker是跨线程打断poll的机制,三个平台用三种完全不同的底层:Linux eventfd、macOS/FreeBSDEVFILT_USER、Windows 往 IOCP 直接 post 一个带特定 token 的 completion packet- mio 的代码故意保持极简 —— 刻意不做 "高级抽象",不做 connection pool、不做 timer、不做 scheduler。功能少,每一层的开销也就薄
9.1 开篇:为什么要花一整章讲一个"别人的库"
这是我能预见的一个读者困惑——"mio 不是 Tokio 的代码、为什么要花一整章讲?"。答案有两层:(1)mio 是 Tokio I/O 路径的关键基础、不讲它整条链路就不完整;(2)mio 本身就是 Rust 系统编程的教科书级案例、脱离 Tokio 语境单独读也有价值。
本章是本专栏里一个略特殊的章节——它讲的不是 Tokio 本身的代码,而是Tokio 的依赖 mio 的内部。你可能会问:这偏题了吗?
不偏,因为:
- Tokio I/O Driver 是 mio 的薄封装 —— 不懂 mio,你对 Tokio I/O 的理解就停留在"神秘的 epoll"
- mio 是 Tokio 这一系异步生态的基础设施 —— 别的运行时要么直接用它,要么用形状相近的替代品(如 smol 一系的
pollingcrate,见 9.12 节) - mio 本身的设计美学(薄抽象、最小功能、严谨跨平台)值得专门讲 —— 它是 Rust 生态里做好基础设施的教科书
- 读过 mio,你就真正理解了 Linux epoll、BSD kqueue、Windows IOCP 的差异——这是做底层系统编程的通用知识
这一章的价值超出 Tokio 本身——它给你一把理解任何事件驱动系统的钥匙。Node.js、Nginx、Redis、Envoy、Netty——所有这些系统的底层都是 epoll / kqueue / IOCP 的某种封装。读懂 mio 里那几百行核心代码,再去读这些系统的 I/O 层就有了底子。
9.2 mio 在 Rust 异步栈里的位置
mio 在 Rust 异步栈里的位置常被比作"Rust 的 libevent"——它是底层跨平台事件抽象库、不关心上层 async 怎么用、只负责把 Linux/macOS/Windows 的事件机制统一封装。这种定位让 mio 能被上层随意复用——Tokio 整条 I/O 栈就压在它上面;别的运行时要么直接用它,要么用形状相近的替代品(smol 生态的 polling、io-uring 绑定等,见 9.12 节)。这是 Rust 生态基础设施复用的典范——一份好的底层抽象让整个生态受益。
第 8 章讲了 Tokio I/O Driver 是 "mio 的薄封装"。但mio 自己又是什么的封装?——答案是:系统调用的薄封装。
┌────────────────────────────────────────────┐
│ 业务代码:TcpStream::read().await │
├────────────────────────────────────────────┤
│ Tokio I/O:ScheduledIo + poll_readiness │
├────────────────────────────────────────────┤
│ mio:Poll + Registry + Selector │ ← 本章
├────────────────────────────────────────────┤
│ OS 系统调用:epoll_wait / kevent / IOCP │ ← 下一层
├────────────────────────────────────────────┤
│ Linux kernel / BSD kernel / Windows NT │
└────────────────────────────────────────────┘
mio 是整个栈的第 3 层,再往下就是 OS 系统调用。这意味着:
- mio 要跨平台:同一套 Rust API 要在 Linux / macOS / FreeBSD / Windows 上跑
- mio 要尽可能薄:每加一层抽象就是一层开销,而 mio 是 Tokio 生态里所有异步 I/O 的共同底座——它慢,压在它上面的一切都慢
- mio 要长期稳定:Tokio 以及整个压在 Tokio 上的生态都指着它,任何一次破坏性改动都会波及大片下游
这三个目标互相拉扯——跨平台意味着要抽象掉 OS 差异、但薄又要求最小化抽象成本。mio 团队(和 Tokio 同属 tokio-rs 组织)打磨了十年——从 2014 年第一个 commit 到 2024 年 7 月的 1.0.1,今天这个版本就是这个平衡的产物。
mio 的哲学:最少的功能
mio 只做一件事:提供统一 API 让你注册感兴趣的 I/O 事件、等它们发生、被通知。
它不做:
- ❌ Timer 管理(不提供定时器 API)
- ❌ 任务调度(不是运行时)
- ❌ 连接池(不管理 connection lifetime)
- ❌ 协议解析(不做 HTTP / TCP 框架)
- ❌ 缓冲区管理(不提供 ring buffer / zero-copy API)
- ❌ Async abstractions(Mio 没有
Future,它是一个同步 API,只是它的事件通知模型适合和 async 结合)
这种"刻意的最小化"是 mio 的核心价值观。增加任何一个功能都会让库变慢、变复杂、变难跨平台。
这份"只做一件事"有着深远的工程影响。对比一下各运行时对事件管理的抽象选择:
- mio:事件注册 + 等待,完结
- libuv (Node.js):事件 + timer + DNS + thread pool + file I/O + subprocess + TTY——超大
- libev:事件 + timer + signal + child —— 中等
- boost.asio:几乎包含整个 C++ 异步世界 —— 极大
libuv 的"大而全"来源于 JavaScript 的单线程模型——Node 需要在 libuv 里做所有异步原语,否则暴露到 JS 就多线程了。Rust 不需要这个——Tokio 可以在 mio 之上自己组装 timer、thread pool、subprocess,每一块都独立设计 / 独立优化。
这是 Rust 异步栈和 Node.js 在架构层次上最大的差异——Rust 有更细粒度的可组合性。mio 只是这种可组合性的最底层体现。
mio 的 CHANGELOG 本身就是这份克制的证据——但要把话说准:0.6 → 0.7 是一次真正的大破坏。Registry、Interest、Waker 三个类型都是 0.7.0(2020-03-02)新加的,用来替掉 0.6 的 Ready / PollOpt;同一版还把 PollOpt 整个删掉、"all registrations use edge-triggers"(9.9 节讲的 ET 语义就是这时定死的)、把 Evented 改名成 event::Source、删掉用户态自定义 readiness 队列、重写整个 Windows 实现。真正"形状基本没变"的是 0.7 → 0.8 → 1.0 这段——1.0 那一版的改动清单里最显眼的两条是"MSRV 提到 1.70,于是能实现 I/O safety traits(AsFd)"和"删掉自己的 SocketAddr、改用标准库的"——都是在做减法、往标准库靠,而不是加功能。
9.3 Poll + Registry:mio 的顶层 API
mio 对外暴露的 API 非常简洁——Poll(等事件的主体)和 Registry(注册 fd 的句柄)两个核心类型加 Events(事件容器)就够了。这种"小而精"的 API 表面是 mio 作为底层库的典型特征——把复杂度留给实现、把接口简化到极致。对比一些 Python / Java 的事件库提供几十个 API 方法,mio 的精简让用户"不会迷路"、所有使用都围绕这几个类型展开。
打开 mio 1.0.1 的 src/poll.rs,顶层 API 就两个 struct(Poll 在 src/poll.rs:266,Registry 在 src/poll.rs:271):
// 来源:tokio-rs/mio · src/poll.rs:266 与 src/poll.rs:271 (v1.0.1)
pub struct Poll {
registry: Registry,
}
pub struct Registry {
selector: sys::Selector,
#[cfg(all(debug_assertions, not(target_os = "wasi")))]
has_waker: Arc<AtomicBool>,
}
两个 struct 的关系:
Poll只包一个Registry——它是 "Poll = 能 poll + 能 register" 的综合门面Registry只包一个sys::Selector——它是"仅注册 fd"的最小接口,可以try_clone()出副本跨线程使用
Poll 不能 clone,但 Registry 可以——对应不同使用场景:
- 你只需要"等事件 + 分发"的地方拿
Poll(独占) - 你只需要"注册新 fd"的地方拿
Registry(可共享)
这个分拆和 Tokio 的 Driver / Handle(第 8 章)、Runtime / Handle(第 4 章)是同一种思路的上游版本——Tokio 的设计很大程度上继承自 mio 的分拆哲学。
Poll::new:三行代码的构造器
// 来源:src/poll.rs:320 (v1.0.1)
pub fn new() -> io::Result<Poll> {
sys::Selector::new().map(|selector| Poll {
registry: Registry {
selector,
#[cfg(all(debug_assertions, not(target_os = "wasi")))]
has_waker: Arc::new(AtomicBool::new(false)),
},
})
}
三行干了什么:
sys::Selector::new()—— 调用 per-OS 的 Selector 构造器(9.4 节详解)- 用返回的 Selector 包装出 Registry
- 用 Registry 包装出 Poll
has_waker: AtomicBool(src/poll.rs:275,只在 debug 模式有)是一个自检机制——如果你注册了两个 Waker(mio 规定每个 Poll 只能有一个 Waker),debug 下会 panic 提醒你:那句断言就是 src/poll.rs:721 的 !self.has_waker.swap(true, Ordering::AcqRel)。release 下省掉这个检查。
Poll::poll:一行就搞定
// 来源:src/poll.rs:434 (v1.0.1)
pub fn poll(&mut self, events: &mut Events, timeout: Option<Duration>) -> io::Result<()> {
self.registry.selector.select(events.sys(), timeout)
}
一行。mio 的 poll 方法就是把参数转发给 Selector::select——Selector 才是真正做事的。
这种"门面方法直接委托"的设计让上层 API 稳定(Poll 的签名几年不变),下层实现可以自由演化(Selector 各平台可以独立改)。稳定 + 灵活的双赢。
一个观察:mio 几乎没用 Rust 的"高级特性"
打开 mio 源码浏览一圈,你会发现它几乎没用 Rust 的"时髦特性":
- 没有复杂的 trait 层次
- 没有 GAT、没有 HKT(higher-kinded types)
- 没有过程宏
- 几乎没有泛型函数
- 没有
async fn(它是同步 API) - 错误处理就是
Result<_, io::Error>,没有自定义 error type
这不是巧合——mio 作为底层库故意如此。复杂的类型系统抽象会让 FFI 代码变难写、变难 review、变难跨 Rust 版本升级。mio 追求最简 Rust——每个 struct 字段清晰、多数 unsafe 块旁边写着为什么安全、每个系统调用包装到位。
这种写法让 mio 的代码像 C 代码一样直接(但保留 Rust 的类型安全)——恰到好处的 Rust。
9.4 Linux 下的 Selector:三个系统调用的薄封装
Linux 上 mio 的实现归根结底就是三个 epoll 系统调用的 Rust 封装:epoll_create1(创建 epoll 实例)、epoll_ctl(添加/删除/修改 fd 关注)、epoll_wait(等事件)。这么薄的封装听起来不值得讲——但魔鬼藏在细节里:如何把 errno 统一成 io::Error(被信号打断的 EINTR 也原样作为 Interrupted 交给调用方,Tokio 的 I/O Driver 在 turn 里忽略它)、如何把毫秒超时取整、如何在一次 epoll_wait 里批量收割多个事件。读这节会让你对"Rust 封装 C API 应该做到什么"有具体标准。
进入 src/sys/unix/selector/epoll.rs,Linux 下的 mio 核心实现。原样:
// 来源:src/sys/unix/selector/epoll.rs:16 (v1.0.1)
#[derive(Debug)]
pub struct Selector {
#[cfg(debug_assertions)]
id: usize,
ep: OwnedFd,
}
Linux Selector 只有一个实质字段:ep: OwnedFd——一个 epoll 的文件描述符。debug 下多一个 id 用于诊断(SelectorId 机制,防止把同一个 source 注册到两个 Poll 上;NEXT_ID 在 src/sys/unix/selector/epoll.rs:13)。
注意这里是 OwnedFd 而不是裸的 RawFd——这是 mio 1.0 相对 0.8 的一个实质变化。1.0 把 MSRV 提到 1.70 之后就能用标准库的 I/O safety 类型了,于是"谁负责 close 这个 fd"这件事从"手写 impl Drop"变成了类型自己保证。这条 CHANGELOG 写得明明白白:
With v1 Mio is able to bump its MSRV to 1.70, allowing us to implement I/O safety traits and replace
SocketAddrwith the version found in the standard library.
new:epoll_create1(2)
// 来源:src/sys/unix/selector/epoll.rs:23 (v1.0.1)
pub fn new() -> io::Result<Selector> {
// SAFETY: `epoll_create1(2)` ensures the fd is valid.
let ep = unsafe { OwnedFd::from_raw_fd(syscall!(epoll_create1(libc::EPOLL_CLOEXEC))?) };
Ok(Selector {
#[cfg(debug_assertions)]
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
ep,
})
}
六行。0.8 时代这里还有一大段"内核太老没有 epoll_create1 就 fallback 到 epoll_create + fcntl"和 Android 的 raw syscall() 分支,1.0 全部删掉了——删除提交的说明是:epoll_create1 在 Linux 2.6.27、glibc 2.9、Android API 21 就有了,都不高于 mio 支持的底线,fallback 不再需要;Android 那支则因 libc crate 已经提供 epoll_create1 而改成直接调用。这是"薄库"的另一面:该丢的历史包袱要丢。
核心系统调用:
int epoll_create1(int flags);
返回一个 epoll fd。EPOLL_CLOEXEC 标志确保这个 fd 在 exec() 时自动关闭——这是 Unix 多进程编程的安全卫生常识,mio 默认就给你加上。
历史对照:epoll_create1 是 Linux 2.6.27(2008)才有的。mio 0.8 及更早的版本里,这里有一段 ENOSYS fallback(退回 epoll_create(1024) + fcntl(F_SETFD, FD_CLOEXEC)),还有一段专门给早期 Android libc 用的 raw syscall(SYS_epoll_create1, ...) 分支。这些在 1.0 里都不存在了——引用旧文章看到那段代码时,记得那是历史版本。
register:epoll_ctl(2) EPOLL_CTL_ADD
// 来源:src/sys/unix/selector/epoll.rs:69 (v1.0.1)
pub fn register(&self, fd: RawFd, token: Token, interests: Interest) -> io::Result<()> {
let mut event = libc::epoll_event {
events: interests_to_epoll(interests),
u64: usize::from(token) as u64,
#[cfg(target_os = "redox")]
_pad: 0,
};
let ep = self.ep.as_raw_fd();
syscall!(epoll_ctl(ep, libc::EPOLL_CTL_ADD, fd, &mut event)).map(|_| ())
}
核心系统调用:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
op 是 EPOLL_CTL_ADD(新增)/ EPOLL_CTL_MOD(改)/ EPOLL_CTL_DEL(删)之一。event 结构体包含两个核心字段:
events:这个 fd 要关注什么事件(EPOLLIN读、EPOLLOUT写、EPOLLETedge-triggered 等)。mio 把 Rust 的Interest(READABLE / WRITABLE)转成 epoll 的位组合u64: data:一个 64-bit 用户数据,epoll 在事件返回时会原样还给你——这就是 Token!第 8 章讲的"Token = 指针"技巧在这里变成"把 token 存到epoll_event.u64"
mio 把 token 存到 event.u64。epoll_wait 返回事件时,这个 u64 原样回来——Tokio 的 I/O Driver 再 as *const ScheduledIo 转回指针。全链路无 HashMap 查找的秘密就在这里。
select:epoll_wait(2)
// 来源:src/sys/unix/selector/epoll.rs:42 (v1.0.1)
pub fn select(&self, events: &mut Events, timeout: Option<Duration>) -> io::Result<()> {
let timeout = timeout
.map(|to| {
// `Duration::as_millis` truncates, so round up. This avoids
// turning sub-millisecond timeouts into a zero timeout, unless
// the caller explicitly requests that by specifying a zero
// timeout.
to.checked_add(Duration::from_nanos(999_999))
.unwrap_or(to)
.as_millis() as libc::c_int
})
.unwrap_or(-1);
events.clear();
syscall!(epoll_wait(
self.ep.as_raw_fd(),
events.as_mut_ptr(),
events.capacity() as i32,
timeout,
))
.map(|n_events| {
// This is safe because `epoll_wait` ensures that `n_events` are
// assigned.
unsafe { events.set_len(n_events as usize) };
})
}
核心系统调用:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
三个精细细节:
-
timeout 向上取整到毫秒:
checked_add(Duration::from_nanos(999_999))后再as_millis()(src/sys/unix/selector/epoll.rs:49)。源码注释直接说了原因——Duration::as_millis是截断的,如果你传 1.5 ms 直接转成 1 ms 会早于你要求的时间醒;向上取整成 2 ms 才对。而且注释特意点明:只有调用方显式传 0 才会得到零超时,不会因为舍入意外变成忙轮询 -
-1表示无限等:Rust 侧timeout是None时传 -1 给epoll_wait -
events.set_len(n_events):epoll_wait的返回值是"写进缓冲的事件数"。用 unsafeset_len而不是 push——缓冲已经预分配好了,只需要告诉Vec"现在我有 n 个有效元素"。零额外内存操作
整个 select 函数十几行,实际干活的是 1 个系统调用。这就是 mio 的本色。
为什么 Tokio 的默认 nevents = 1024
第 4 章讲过 Tokio Builder::nevents 字段默认 1024——一次 epoll_wait 最多取 1024 个事件。这个数字从哪来?
源码里没有给这个数字写理由(tokio/src/runtime/builder.rs:277 里就是一句 nevents: 1024),但它的取舍方向是清楚的:
- 太小:内核就绪队列里堆着上万个事件时,一次只捞几十个,就要多做很多次
epoll_wait系统调用 - 太大:
Events缓冲一直占着内存(x86_64 上epoll_event是 packed 的 12 字节,1024 个约 12 KB),而且大部分时候用不满
要改就改:Builder::max_io_events_per_tick(n)(tokio/src/runtime/builder.rs:1289 处赋值)就是设这个值的。默认值适合绝大多数场景,除非你 profile 出 epoll_wait 调用次数异常高,否则不用动。
epoll_wait 的 timeout 单位:毫秒的遗产
看那段 timeout 计算逻辑——.as_millis() as libc::c_int。epoll_wait 的 timeout 参数是一个毫秒整数,这是 epoll 进内核那天就定下的接口(man 7 epoll:The epoll API was introduced in Linux kernel 2.5.44,也就是 2002 年,2.6 是它随之发布的稳定线)。
实际影响:如果你的 timer 设 100 微秒、想被精确唤醒,epoll_wait 做不到——它最多 1 毫秒精度。Tokio 的 Time Driver 也没打算做得更细:它把时间换算成毫秒 tick,截止时间向上取整到整毫秒(tokio-1.40.0/tokio/src/runtime/time/source.rs:17-tokio-1.40.0/tokio/src/runtime/time/source.rs:19,第 11 章细讲)。真正的高精度 timer 需要其他机制(比如 Linux 的 timerfd + CLOCK_MONOTONIC)。
对 Tokio 常见工作负载(HTTP 服务、数据库),毫秒精度的定时器完全够用。对纳秒级敏感的工作(高频交易、实时音视频),你可能需要避开 epoll,直接用更精确的内核机制。
Events 的真相
对外的 mio::Events 是个只包一个字段的 newtype:
// 来源:src/event/events.rs:42 (v1.0.1)
pub struct Events {
inner: sys::Events,
}
而 Linux 下的 sys::Events 就是:
// 来源:src/sys/unix/selector/epoll.rs:132 (v1.0.1)
pub type Event = libc::epoll_event;
pub type Events = Vec<Event>;
Tokio 在 Driver 里那个 events: mio::Events 字段(第 8 章),拆到底就是一个 Vec<libc::epoll_event>。跨 turn 复用这个 Vec,就省掉了每次 poll 都分配/释放上千个 event 结构。这也解释了为什么 select 里要先 events.clear() 再 set_len(n)——它在手动管理一个复用缓冲的有效长度。
epoll_event.data 是 union:mio 用了 u64 成员
libc::epoll_event 在 C 里是这样定义:
struct epoll_event {
uint32_t events; // 事件类型
epoll_data_t data; // union
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
data 是 union——同一块内存可以解释成 void* / int fd / u32 / u64。内核不在乎里面是什么,它只是原样存到事件里、原样返回给你。
mio 选择用 u64 成员:
u64: usize::from(token) as u64,
为什么用 u64 而不是 ptr?因为:
- 跨平台一致:所有架构下 u64 都是 8 字节,
void *在 32 位架构只有 4 字节——用 u64 省掉架构分支 - 可以装下指针:在 64 位架构上 usize = 8 字节,可以塞进 u64。32 位架构上 4 字节也没问题
- 清晰意图:u64 没有"我是个指针"的暗示,API 用户自己决定怎么解释
这个小选择让 Tokio 在 32 位和 64 位架构上都能"把 ScheduledIo 地址塞进 token"。薄抽象成就具体应用的灵活性。
9.5 macOS / BSD 下的 Selector:kqueue
kqueue 是 FreeBSD 在 2000 年推出的事件通知机制,后来被 macOS 和其他 BSD 系统采纳。它比 epoll 早几年出现、设计上比 epoll 更通用(不仅能监听 fd 事件、还能监听进程状态、信号、定时器等)——但这种通用性也带来 API 复杂度。mio 在 kqueue 上的封装要把它的丰富语义"映射"到 mio 的统一 API(读 / 写 / 错误)——这个映射过程本身就是跨平台抽象的典型挑战。
同一套 mio API 在 macOS 和 BSD 系列上用完全不同的系统调用——kqueue(2) 和 kevent(2)。简化版的 Selector:
// 来源:src/sys/unix/selector/kqueue.rs:67、src/sys/unix/selector/kqueue.rs:74、src/sys/unix/selector/kqueue.rs:124 (v1.0.1,节选)
pub struct Selector {
#[cfg(debug_assertions)]
id: usize,
kq: OwnedFd, // 和 epoll 那边一样,1.0 起用 OwnedFd
}
pub fn new() -> io::Result<Selector> {
// SAFETY: `kqueue(2)` ensures the fd is valid.
let kq = unsafe { OwnedFd::from_raw_fd(syscall!(kqueue())?) };
syscall!(fcntl(kq.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC))?;
Ok(Selector { /* id, */ kq })
}
pub fn register(&self, fd: RawFd, token: Token, interests: Interest) -> io::Result<()> {
let flags = libc::EV_CLEAR | libc::EV_RECEIPT | libc::EV_ADD;
// 最多两条 change,可能只需要一条
let mut changes: [MaybeUninit<libc::kevent>; 2] =
[MaybeUninit::uninit(), MaybeUninit::uninit()];
let mut n_changes = 0;
if interests.is_writable() {
changes[n_changes] = MaybeUninit::new(kevent!(fd, libc::EVFILT_WRITE, flags, token.0));
n_changes += 1;
}
if interests.is_readable() {
changes[n_changes] = MaybeUninit::new(kevent!(fd, libc::EVFILT_READ, flags, token.0));
n_changes += 1;
}
// ...把前 n_changes 条一次性提交给 kevent,并忽略 EPIPE
}
三个细节:
kqueue()没有CLOEXEC参数,所以要额外一次fcntl(F_SETFD, FD_CLOEXEC)——不像epoll_create1可以一次搞定。跨平台库里这种"同一个语义、这边一步那边两步"的差异非常常见EV_CLEAR就是 kqueue 的 edge-triggered(src/sys/unix/selector/kqueue.rs:125),和 epoll 那边interests_to_epoll(src/sys/unix/selector/epoll.rs:114)里第一句let mut kind = EPOLLET;(src/sys/unix/selector/epoll.rs:115)对应——两个平台都是 ETEPIPE被主动忽略:源码里一大段注释说明,老版本 macOS(10.10 / 10.11)注册一个对端已关闭的 pipe 时会返回EPIPE,但之后 kevent 照样会报事件——所以直接吞掉这个错误(注释在src/sys/unix/selector/kqueue.rs:144-src/sys/unix/selector/kqueue.rs:154,提交时把EPIPE列进忽略名单在src/sys/unix/selector/kqueue.rs:160;见 tokio-rs/mio#582)。这就是"底层库替你挡下真实世界的混乱"的一个具体样本
kqueue 和 epoll 的主要差异:
| 维度 | epoll | kqueue |
|---|---|---|
| 事件类型 | EPOLLIN / EPOLLOUT / EPOLLHUP 等通用 flag |
EVFILT_READ / EVFILT_WRITE / EVFILT_TIMER 等独立 filter |
| 一次 fd 注册 | 一个 epoll_event 覆盖读+写 | 一个 fd 要注册两个 kevent(一个 READ filter + 一个 WRITE filter) |
| 用户数据 | u64 data 字段 |
void *udata 字段 |
| 接口系统调用 | epoll_ctl(修改)+ epoll_wait(等) |
kevent() 一个函数既能改又能等 |
mio 抹平了这些差异——对外暴露统一的 register(fd, token, interests) + select(events, timeout) API。具体到 macOS 实现,"注册一个 fd 的读+写兴趣"会展开成两条 change(一条 EVFILT_READ、一条 EVFILT_WRITE),在一次 kevent 调用里提交(src/sys/unix/selector/kqueue.rs:160 的 kevent_register)。
这是 mio 价值的真实体现:上层代码(Tokio)一套代码跑三个平台,不用写 #[cfg] 分支。
特殊情况:macOS 的"signal via kqueue"
kqueue 的一个超能力:它还能监听 signal(信号)、子进程状态、timer、文件系统变化——远超 epoll 的"只管 fd 事件"。
这让 macOS / FreeBSD 下很多事情可以用一个 kqueue 搞定。Linux 必须用不同的系统调用(signalfd / timerfd / inotify)然后把 fd 注册进 epoll。
mio 没有把 kqueue 的这些超能力暴露出去——因为暴露了 Linux 下没有对应实现,API 不再统一。这是跨平台抽象的常见取舍——取并集意味着 Linux 上缺实现,取交集则让 macOS 的能力闲置。mio 取交集,牺牲 macOS 的一些能力换 API 的简洁性。
9.6 Windows 下的 Selector:IOCP 的模拟
Windows 的 IOCP 是 mio 跨平台工作里最困难的一块——因为 IOCP 和 epoll/kqueue 是完全不同的编程模型:epoll 是"readiness based"(告诉你 fd 现在能读了、你自己去 read)、IOCP 是"completion based"(你告诉系统读、它完成后通知你)。把一个 completion 模型伪装成 readiness 模型、需要 mio 在 Windows 上做相当复杂的适配工作——这一节讲这个适配的具体做法。
Windows 的 I/O 事件系统叫 IOCP(I/O Completion Ports),和 epoll / kqueue 在语义上根本不同:
- epoll / kqueue 是 readiness 模型:告诉你"fd 现在可读了",你自己去 read
- IOCP 是 completion 模型:你提交一个 read 请求、传你的 buffer;IOCP 帮你把数据搬到 buffer 里,完成时通知你
这两种模型的语义差异巨大。Rust async 世界普遍是 readiness 模型(因为 epoll 先有),Tokio 的 AsyncRead trait 也是基于 readiness 设计。mio 在 Windows 下必须"模拟"readiness 语义。
mio 1.0.1 的实际做法不是"提交 0 字节读",而是走 Winsock 底层的 AFD 驱动(\Device\Afd)。看 src/sys/windows/afd.rs 开头那个常量就知道了:
// 来源:src/sys/windows/afd.rs:15 (v1.0.1)
const IOCTL_AFD_POLL: u32 = 0x00012024;
流程是:
- mio 打开 AFD 句柄并关联到 IOCP,由
src/sys/windows/selector.rs里的AfdGroup管理(每个句柄最多给 32 个 socket 共用) register一个 socket 时,先把它放进 selector 的update_queue;真正通过NtDeviceIoControlFile向 AFD 发IOCTL_AFD_POLL请求(src/sys/windows/afd.rs:73)、把"我关心这个 socket 的哪些 readiness 位"告诉驱动,是在下一次GetQueuedCompletionStatusEx之前统一做的(若此刻已有线程阻塞在 poll 里,则立即发)- 这个请求通常返回
STATUS_PENDING,挂在 IOCP 上 - socket 真的变成可读/可写时,AFD 完成这个请求,completion packet 落到 IOCP
- mio 的
select从 IOCP 收走 packet,把 AFD 报的 flag 翻译成 mio 的Event,然后重新发一次IOCTL_AFD_POLL继续监听
这是"用一个 completion 机制去承载 readiness 语义"的标准解法(wepoll、libuv 在 Windows 上的 poll handle 也走 AFD 这条路;mio 的 src/sys/windows/afd.rs:231 就直接引用了 wepoll 的提交)。代价是每报一次 readiness 就要重发一次 AFD 请求,比 epoll 那种"内核维护就绪列表"多做一点事;收益是上层 API 和 Linux / macOS 完全一致。
一个细节:为什么 mio 不暴露 kqueue 的"批量注册"?
kqueue 有一个比 epoll 强的特性:一次 kevent() 调用可以同时注册多个 fd、同时等事件返回。epoll 的 epoll_ctl 一次只能操作一个 fd、想批量只能多次调用。
mio 没有暴露这个能力——因为 epoll 不支持,API 统一不了。每次 register 一个 fd 就调一次 kevent(或 epoll_ctl),没有批量。
代价:在 macOS 下,spawn 大量连接时比理论极限慢。平时每连接多一次系统调用无关紧要,但如果你的服务启动时要注册 10 万个 connection,这个序列化会累积。
这个取舍是 mio 的一贯选择——宁愿保持薄抽象,不追求单平台极致。追求极致的用户可以绕过 mio 直接调 kqueue(kqueue-sys crate 提供原始 FFI)。mio 给大多数人提供够用的方案,剩下的少数人自己处理。
9.7 mio::Waker:跨线程唤醒的三种底层
注意这里的 mio::Waker 不是 Rust std 的 std::task::Waker——mio 的 Waker 专门用于"让正在 epoll_wait 的线程被其他线程唤醒"。看 src/sys/unix/waker/ 目录就知道 unix 侧就有三份实现:eventfd.rs、kqueue.rs、pipe.rs(给没有 eventfd 也不走 kqueue 的平台兜底),Windows 侧还有第四份。这种"同一 API、三种实现"是跨平台库的典型挑战——mio 的处理方式值得学习。
mio::Waker 的跨平台抽象是 mio 最精妙的部分之一。API 极简:
// 来源:src/waker.rs:78 (v1.0.1)
#[derive(Debug)]
pub struct Waker {
inner: sys::Waker,
}
impl Waker {
pub fn new(registry: &Registry, token: Token) -> io::Result<Waker> {
#[cfg(debug_assertions)]
registry.register_waker(); // debug 下检查"一个 Poll 只能有一个 Waker"
sys::Waker::new(registry.selector(), token).map(|inner| Waker { inner })
}
pub fn wake(&self) -> io::Result<()> {
self.inner.wake()
}
}
但三个平台的底层实现完全不同:
Linux:eventfd(2)
eventfd 是 Linux 2.6.22 引入的一个特殊 fd 类型:一个 64 位计数器,可以 write() 加值、read() 清零。它同时也是一个可被 epoll 监听的 fd。
mio 在 Linux 下用 eventfd 实现 Waker(src/sys/unix/waker/eventfd.rs:20):
// 来源:src/sys/unix/waker/eventfd.rs:20、:26、:44 (v1.0.1,节选)
pub(crate) struct Waker { fd: File }
pub(crate) fn new(selector: &Selector, token: Token) -> io::Result<Waker> {
let waker = Waker::new_unregistered()?; // eventfd(0, EFD_CLOEXEC|EFD_NONBLOCK)
selector.register(waker.fd.as_raw_fd(), token, Interest::READABLE)?;
Ok(waker)
}
pub(crate) fn wake(&self) -> io::Result<()> {
let buf: [u8; 8] = 1u64.to_ne_bytes();
match (&self.fd).write(&buf) {
Ok(_) => Ok(()),
Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => {
// 只有计数器要溢出时才会 block:清零后重试
self.reset()?;
self.wake()
}
Err(err) => Err(err),
}
}
三个细节值得看:
- fd 用
File包着,不是裸的RawFd——又是 1.0 的 I/O safety 收益,drop 时自动 close EFD_NONBLOCK,所以write在计数器要溢出(超过u64::MAX - 1)时返回WouldBlock而不是阻塞;此时reset()(src/sys/unix/waker/eventfd.rs:65)读一次把计数器清零,再重试wake()写的是 8 字节——eventfd 的读写都必须是 8 字节的本机序 u64,源码注释专门写了这条约定
macOS / FreeBSD:EVFILT_USER
kqueue 支持 user-defined events(EVFILT_USER)——一个完全不涉及 fd 或 IO 的 "虚拟事件",你可以用 kevent() 手动"触发"它。
// 来源:src/sys/unix/waker/kqueue.rs:13 (v1.0.1)
pub(crate) struct Waker {
selector: Selector, // try_clone 出来的一份 kqueue 句柄
token: Token,
}
impl Waker {
pub(crate) fn new(selector: &Selector, token: Token) -> io::Result<Waker> {
let selector = selector.try_clone()?;
selector.setup_waker(token)?; // 往 kqueue 里 EV_ADD 一个 EVFILT_USER
Ok(Waker { selector, token })
}
pub(crate) fn wake(&self) -> io::Result<()> {
self.selector.wake(self.token) // 触发那个 EVFILT_USER
}
}
结构上和 eventfd 版本很不一样:eventfd 版本注册完就可以忘掉 selector(唤醒是往自己那个 fd 写),kqueue 版本必须一直握着 kqueue 的句柄——因为 EVFILT_USER 不是一个 fd,"唤醒"这个动作本身就是往 kqueue 提交一条 kevent。源码注释把这个理由写得很直白:"we need access to kqueue, thus we need to duplicate the file descriptor"(src/sys/unix/waker/kqueue.rs:10)。
mio 只在 macOS / iOS 系和 FreeBSD 上用这份实现;NetBSD、OpenBSD、DragonFly 虽然也是 kqueue,Waker 走的却是 pipe 兜底(见 src/sys/unix/mod.rs 里 mod waker 的 cfg_attr)。
Windows:特殊的 IOCP post
Windows 下 mio 用 PostQueuedCompletionStatus API——手动往 IOCP 队列里 post 一个特殊的 completion packet:
- 创建一个特殊 token 的 waker 对象
wake()=PostQueuedCompletionStatus往 IOCP 队列塞一个带特定 token 的包GetQueuedCompletionStatusEx拿到这个包 → 识别为 waker token
三种机制语义完全不同、但效果完全一样:都是"从另一个线程让阻塞在 poll 上的 mio 立刻返回"。
eventfd vs pipe:为什么不用 pipe
在 Linux 上,另一个常见的"跨线程唤醒"方式是管道(pipe):写一个字节到管道,读端可以被 epoll_wait 唤醒。
为什么 mio 用 eventfd 而不是 pipe?几个原因:
- eventfd 只占 1 个 fd,pipe 占 2 个(读端 + 写端)—— eventfd 更节省 fd 配额
- eventfd 是 atomic counter,多次 wake 只是计数器累加,一次 8 字节
read就能清零。pipe 每次写都留下一个独立字节,要清空就得把这些字节全部读回来 —— 浪费 - eventfd 的上限是
u64::MAX - 1,实际碰不到(碰到了 mio 也会先清零再写);pipe 的 buffer 有限(Linux 默认 64 KB,见man 7 pipe),高频 wake 可能把 pipe 写满,非阻塞的write返回WouldBlock,得先读空再写(mio 的pipe.rs就是这么兜底的) - eventfd 是 Linux 2.6.22+ 的现代原语—— 所有相关内核都支持,mio 在 Linux 上默认不需要 fallback
这些差异累积起来让 eventfd 在性能和语义上都优于 pipe。老的 I/O 库(libev、libevent 早期版本)用 pipe——mio 自己早期也是 pipe:0.6 的唤醒器叫 Awakener,unix 侧那份实现整个包在一个 mod pipe 里——一对 unix::pipe(),wakeup() 就是往写端写一个字节。直到 0.7.0(2020-03-02)引入 Waker 类型时,Linux 这一支才换成 eventfd,并且一直保留一份 pipe 版本给没有 eventfd 的平台兜底(就是 9.7 节开头说的 src/sys/unix/waker/pipe.rs)。
每个 Poll 只能注册一个 Waker
mio 有一个硬限制:每个 Poll 实例只能注册一个 Waker。看 Poll::new 里那个 has_waker: AtomicBool 字段(src/poll.rs:275,断言在 src/poll.rs:721)——就是在 debug 构建下强制这个限制的;release 下不检查,src/waker.rs 的文档只说多个 Waker 的行为"unspecified"。
为什么限制:简化实现 + 降低错误概率。以 kqueue 实现为例,setup_waker 注册的 EVFILT_USER 事件 ident 固定为 0(src/sys/unix/selector/kqueue.rs:222-src/sys/unix/selector/kqueue.rs:227),第二个 Waker 会改写同一条事件的 token,第一个 Waker 的 token 就收不到了。与其为多 Waker 定义语义,mio 直接规定"一个就够"——需要多线程共享就用 Arc 包一个。
实践影响:Tokio 的每个 runtime 只有一个 I/O Driver、只有一个 mio::Waker——完美符合这个限制。如果你同一个进程跑多个 runtime(罕见),每个 runtime 有自己的 Poll + Waker,不冲突。
mio 把这三种差异彻底隐藏在 sys::Waker 后面——Tokio 只看到 mio::Waker::wake()。这是跨平台抽象最成功的案例之一。
三平台 Waker 机制的结构对比
| 平台 | 机制 | 一次 wake() 做了什么 |
|---|---|---|
| Linux | eventfd | 一次 8 字节 write(2)(溢出时多一次 read + 重试) |
| macOS / FreeBSD | EVFILT_USER |
一次 kevent(2),往 kqueue 里塞一个用户事件(Waker 自己持有一份 try_clone 出来的 Selector) |
| Windows | IOCP post |
一次 PostQueuedCompletionStatus,直接把一个带 token 的 completion packet 塞进队列 |
三者都是一次系统调用,量级相当。真要比绝对开销请在自己机器上测——但对 Tokio 的实际负载来说,wake() 的频率远低于 epoll_wait 返回的事件数,它几乎不会成为瓶颈。
值得一提的是 macOS 那份实现的巧思:Waker 里存的是 selector: Selector(try_clone 出来的一份),因为 EVFILT_USER 不是一个 fd、没法像 eventfd 那样"注册进去然后忘掉"——唤醒动作本身就是往 kqueue 写一个事件,所以必须一直握着 kqueue 的句柄。
为什么不用 futex / condvar
Linux 上还有一个跨线程同步原语:futex(std::sync::Mutex 底层就是它)。为什么 mio 不用 futex 做 Waker?
因为 futex 不能被 epoll_wait 等待。futex 是 "让等待的线程睡在一个地址上" 的原语,和 epoll 的 "让线程睡在多个 fd 事件上" 是不同的机制。你不能用 futex 打断 epoll_wait。
所以 mio 必须用 "可以被 epoll 监听的东西"作为 waker——eventfd 是最轻的这类东西。
这是操作系统原语选择的系统设计考量——每个原语有它的适用范围,跨范围组合需要桥接。mio 在这里选了最优桥。
9.8 syscall! 宏:统一的系统调用包装
syscall! 是 mio 内部的一个小工具宏——作用是把"调 C 函数、检查返回值、处理 errno、转成 Rust Result"这套 boilerplate 封装起来。看起来是小优化、但它让 mio 源码里数十处 syscall 调用保持一致的错误处理风格——读源码时心智负担大幅降低。这种"自制小宏封装重复模式"是 Rust 代码精炼的典型手法——值得学习。
mio 源码里到处是 syscall!(...)——这是 mio 自己定义的一个宏,用来调用 libc 系统调用并处理错误:
// 来源:src/sys/unix/mod.rs:5 (v1.0.1)
macro_rules! syscall {
($fn: ident ( $($arg: expr),* $(,)* ) ) => {{
#[allow(unused_unsafe)]
let res = unsafe { libc::$fn($($arg, )*) };
if res < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(res)
}
}};
}
这个十行宏统一了 mio unix 侧所有系统调用的写法:
- 检查返回值
< 0→ 返回io::Error::last_os_error() - 否则返回
Ok(返回值)
Windows 侧(src/sys/windows/mod.rs:23)有一个同名但判断条件不同的版本——同一个名字、按平台各自实现,和 sys::Selector 是一个套路。
写宏的好处:代码整洁、错误处理统一。不写宏的坏处:每处都重复 "if res == -1 { errno check } else { ... }"——几十处冗余。
这是 Rust 宏最正当的使用场景之一——消除重复、统一模式。但不要滥用:如果一个宏只用一两次,写成函数更好;用十几处以上、模式清晰,宏才值得。
9.9 epoll 的 edge-triggered vs level-triggered:Tokio 选了哪一个
epoll 支持两种触发模式:
- Level-Triggered (LT):只要 fd 的 readiness 还在,
epoll_wait就会持续返回它。符合常识,默认行为 - Edge-Triggered (ET):只在 readiness 从无到有的瞬间返回一次。之后除非 fd 又"空了再来",否则不会再报。更省系统调用,但使用者要确保"读空"
答案是 Edge-Triggered——而且这个决定不在 Tokio、在 mio。interests_to_epoll 的第一句就写死了:
// 来源:src/sys/unix/selector/epoll.rs:114 (v1.0.1)
fn interests_to_epoll(interests: Interest) -> u32 {
let mut kind = EPOLLET; // ← 无条件 edge-triggered
if interests.is_readable() {
kind = kind | EPOLLIN | EPOLLRDHUP;
}
if interests.is_writable() {
kind |= EPOLLOUT;
}
if interests.is_priority() {
kind |= EPOLLPRI;
}
kind as u32
}
(顺带看到另一个细节:关注可读时会顺带加上 EPOLLRDHUP(src/sys/unix/selector/epoll.rs:118),这样对端半关闭才能被 is_read_closed 识别出来。)
为什么选 ET:
- 性能:同一 fd 的一次 readiness 只触发一次 wake——避免"反复 wake 同一个 Task"的浪费
- Tokio 的模型适合 ET:ScheduledIo 已经有 tick 和 readiness 位跟踪——ET 的"读空才下次触发"语义正好匹配 Tokio 的 "读到 WouldBlock 才 clear_readiness、然后等下一次事件"
ET 的代价:使用者必须读到 EAGAIN 为止。如果你只读一次(哪怕拿到了数据也不继续读),下次可能再也不会被 wake。Tokio 的 AsyncRead 实现里有这个细节——PollEvented::poll_read 读成功后返回 Ready、一般不 clear_readiness,保留 READABLE 标志直到真的读到 EAGAIN;唯一的例外是在 epoll / kqueue 平台上读到的字节比缓冲区空间少(0 < n && n < len)时,这已说明内核缓冲读空了,就提前 clear_readiness(tokio-1.40.0/tokio/src/io/poll_evented.rs:218-tokio-1.40.0/tokio/src/io/poll_evented.rs:219,见 tokio#5866)。
这个选择影响到 Tokio 所有 I/O 类型的实现——TcpStream::poll_read、UdpSocket::poll_recv、UnixStream::poll_read 等。第 10 章会看到具体代码。
这个选择是 mio 替所有上层做的——interests_to_epoll 里第一句就是 let mut kind = EPOLLET;(src/sys/unix/selector/epoll.rs:115),kqueue 那边对应 EV_CLEAR(src/sys/unix/selector/kqueue.rs:125),用户没有开关可调。所以任何基于 mio 的运行时都吃这套 ET 语义,Poll 的文档也就必须把 "Draining readiness"(读到 WouldBlock 为止)写成硬性契约。
9.10 为什么 mio 不用 io-uring
io-uring 是 Linux 5.1(2019)引入的新一代异步 I/O 接口:completion-based、通过共享内存环批量提交与收割,能大幅减少系统调用次数。
那 mio 为什么不用?几个结构性原因:
- io-uring 只有 Linux 5.1+ 有,而 mio 的
Poll文档里那张 "OS / Selector" 表(src/poll.rs:233-src/poll.rs:244)列了十个操作系统(Android、DragonFly BSD、FreeBSD、iOS、illumos、Linux、NetBSD、OpenBSD、Windows、macOS)——它没法把一个平台独有的机制当默认 - io-uring 是 completion-based,和 mio 的 readiness API 不匹配——要在它之上模拟 readiness,就是把 Windows 上那套 AFD 的活再干一遍,而且会丢掉 io-uring 的主要优势
- io-uring 要求 buffer 在整个操作期间保持有效(内存所有权模型完全不同),这会传染到 mio 的整个 API 面——违背 mio "薄"的哲学
- 它的可用性要在运行时探测(内核版本、被容器 seccomp 策略禁用等情况都存在),这类"能不能用要看环境"的依赖对一个所有人都在用的基础库来说是负担
Tokio 的解决方案:推出独立的 tokio-uring crate——不混进主 Tokio,作为一个专门 opt-in 的运行时。想要 io-uring 性能的用户显式依赖它。主 Tokio 继续用 mio + epoll,保持跨平台。
这是一个典型的"守住原则 + 另辟蹊径"策略——mio 不破坏自己的薄抽象原则,同时提供另一条路让追求极致性能的用户走。工程上比"全 rewrite 换 io-uring"好太多。
9.11 读 mio 源码能学到什么
mio 源码是读 Rust 系统编程的绝佳教材——src/ 下一共约 1.2 万行(含大量文档注释),完整(涵盖三大操作系统外加一串小众平台)、成熟(2014 年至今十年打磨)、并且聚焦(只做一件事:跨平台事件抽象)。读 mio 源码能系统地学到:FFI 的正确姿势、unsafe 代码的最小化策略、跨平台 #[cfg] 的组织、错误处理的工业级做法、CI 矩阵怎么覆盖多平台。如果你想成为 Rust 系统编程高手、mio 是必读的练习。
前面 7 节把 mio 的结构和核心实现讲完了。mio 作为一个 "做好一件事" 的典范,读它的代码能学到几类超出异步运行时范畴的东西:
学习 1:如何做跨平台抽象
mio 的 sys 模块结构值得借鉴:
src/
├── poll.rs # 统一 API
├── waker.rs # 统一 API
└── sys/
├── unix/
│ ├── selector/
│ │ ├── epoll.rs # Linux 实现
│ │ ├── kqueue.rs # BSD/macOS 实现
│ │ └── poll.rs # poll(2) 兜底实现
│ └── waker/
│ ├── eventfd.rs # Linux
│ ├── kqueue.rs # BSD/macOS
│ └── pipe.rs # 兜底
├── windows/
│ ├── selector.rs # IOCP 实现
│ └── waker.rs
└── wasi/...
约定:每个平台实现暴露完全相同的 Rust API(同名类型、同名方法、同签名),上层 use sys::Selector 拿到当前平台的那份。#[cfg(target_os = ...)] 决定用哪个 sys 模块。
这是 Rust 生态做跨平台抽象的标准布局——std::fs / std::net / std::process 内部都这样组织。写自己的跨平台库时直接借鉴。
学习 2:薄抽象 vs 胖抽象
mio 刻意只做一件事:fd 事件注册 + 等待 + 唤醒。不做 timer、不做 scheduler、不做 connection pool。
这种"刻意的最小化"让 mio:
- 代码总量可控(
src/约 1.2 万行,其中相当一部分还是文档注释) - 几乎没有性能开销
- 长期稳定(API 几乎不变)
- 被上层反复复用(Tokio 的整条 I/O 栈、mio-aio 这类扩展 crate 等)
胖抽象(big API)的代价:Node.js 的 libuv 是"big"抽象——timer、DNS、file I/O、subprocess、thread pool 都有。代价是体量和维护面都大得多,各块功能绑在同一个事件循环里,很难单独替换其中一块。
薄抽象的赢家案例:mio、sqlite、zlib、BLAS——"只做一件事、做到底、做好"的库往往跨越几十年仍在用。
学习 3:性能和可读性不冲突
mio 的代码异常好读。没有 metaprogramming 迷宫、没有复杂的类型技巧、没有隐藏的全局状态。多数函数 10-30 行、多数 unsafe 块旁边都有注释说明为什么安全。
好的低层代码不是"神秘"的——好的低层代码是把复杂性压缩到系统调用那一条线里,让 Rust 代码自己看起来清晰。mio 的每一段都是这个原则的体现。
9.12 mio 之外的替代品
历史上 Rust 生态有过几个 mio 的替代方案或竞争者,值得提一下:
polling crate:smol 生态用的轻量级替代品,API 思路和 mio 接近(注册 fd + 等事件),但接口更小。被 async-io / smol 使用。
io-uring / rio:直接绑定 Linux io-uring 的 Rust crate。底层不抽象,直接暴露 io-uring API。tokio-uring 构建在其中的 io-uring crate(tokio-rs 组织维护)之上。
calloop:Wayland 生态的 event loop,专注 UI 场景,不追求 mio 的跨平台广度。
为什么 mio 最终占主流:历史先发(2014 年就有了,比其他都早)+ Tokio 绑定(整个 Tokio 生态都依赖 mio)+ 跨平台成熟(Linux / macOS / FreeBSD / Windows / WASI / Android / iOS 全覆盖)。生态引力自我强化——mio 是 Tokio 一系事实上的标准事件基础设施。
除非你有非常具体的理由(比如嵌入式、特定平台优化),在 Tokio 生态里,事件原语的默认选择就是 mio,这和运行时默认选 Tokio 是同一个道理。
9.13 mio 把"真实世界的混乱"写进了文档
"底层库要处理真实世界的混乱"这句话,最好的证据是 src/poll.rs 里那几百行 doc comment——它有一大半在讲"什么不保证"。挑三条最有价值的:
第一条:readiness 是提示,不是承诺。
所有平台上唯一有保证的 readiness 只有
readable和writable。其余(error/read_closed/write_closed)可能有假阴性,应当被视为 hints。……可移植的程序即使显式检查这些位,也只应把它当优化,并且在真正执行读操作时始终能处理错误或关闭的情况。
这解释了第 8 章 poll_readiness 那种写法的由来:拿到 Ready 也必须真的去 read 一次,不能凭 readiness 位就下结论。
第二条:"Draining readiness"——必须读到 WouldBlock 为止。
收到一次 readiness 事件后,对应操作必须反复执行直到返回
WouldBlock。否则不保证还会送来下一次 readiness 事件,哪怕真的又收到了数据。
这就是 9.9 节讲的 edge-triggered 语义的用户侧契约,mio 把它写死在文档里。
第三条:绕过 mio 直接操作 fd 是"未指定行为"。
通过裸 fd 在 mio 之外执行 I/O 操作行为未指定……当前 epoll 和 kqueue 的实现允许绕过 mio 直接操作 fd,但 Windows 需要所有 I/O 都经过 mio,否则它无法正确更新内部状态、不会再产生事件。而且这段描述不是保证,随时可能变,不要依赖它。
Windows 那句正是 9.6 节讲的 AFD 机制的直接后果——mio 必须知道你什么时候拿到了 WouldBlock,才能重新发一次 IOCTL_AFD_POLL。这也是 IoSource::do_io 这个 API 存在的原因:所有 I/O 都要从它走一遍,mio 才有机会更新内部状态。
mio 的处理方式体现了薄库的克制:它不去替你猜、也不加昂贵的运行时检查,而是把契约写进文档,只在 debug_assertions 下用 SelectorId 挡住一类最常见的误用(同一个 source 被注册到两个不同的 Poll)。
"能用文档和类型解决的,就别用运行时检查"——这是薄抽象库的一贯选择。代价是用户必须读文档:Poll 那几百行注释不是废话,是它把复杂度交还给你的地方。
9.14 跨平台测试基础设施:mio 的 CI 矩阵
光看 src/sys/ 目录下的分支就知道 mio 要照顾多少目标:unix/selector/ 里有 epoll.rs、kqueue.rs,还有一个纯 poll.rs(用古老的 poll(2) 兜底,给那些既没 epoll 也没 kqueue 的平台);windows/ 一整套 AFD + IOCP;此外还有 shell/(功能全关时的空实现)和 wasi 分支。CHANGELOG 里 1.0 那一版新增支持的平台就有 QNX、Haiku、Apple visionOS,1.0.1 又加了 Fuchsia 和 GNU/Hurd。
这些平台不可能靠人肉验证——只能靠 CI 矩阵在每个 PR 上全跑一遍。仓库根目录的 CI 配置就是这套基础设施,它是跨平台库能持续演进的核心。
这也是为什么 mio 敢做一些激进的系统调用包装——只要 CI 绿了、各平台都通过就敢改。没有这么宽的 CI,跨平台库不敢大改——每次都得担心"这个改动在 FreeBSD 13 上会不会炸"。
工程教训:投入 CI 基础设施的时间 = 可以自信重构的时间。代码质量 ∝ 测试覆盖度 × 测试环境多样性。
9.15 和这个系列的其他专栏的关联
本章讲的跨平台抽象 + 底层系统调用主题,和 《Rust 编译器与运行时揭秘》专栏第 13 章(FFI:与 C 世界的桥梁) 直接相关。那章讲的 extern "C" / libc / 不同平台的 ABI 差异,在 mio 里全是实战应用——syscall! 宏、#[cfg(target_os)] 分支、epoll_event 这些 C struct 的 Rust 表示、RawFd / c_int 类型——都是 FFI 的日常工具。
mio 的 "尽量薄 + 跨平台统一" 哲学,和 《Vite 设计与实现》专栏第 4 章(插件系统) 里讲的插件 hook 设计同构——都是"定义最小核心 + 让外部扩展"。最好的基础设施都长这样。
9.16 数一数 mio 每个操作到底做了什么
不给纳秒数(那要看内核版本、CPU、有没有 spectre 缓解),只把每个操作拆到系统调用和用户态动作——这份清单在任何机器上都成立:
| 操作 | 系统调用 | 用户态动作 |
|---|---|---|
Poll::new |
1 × epoll_create1 |
建 OwnedFd、debug 下取一个 id |
Registry::register |
1 × epoll_ctl(ADD) |
填一个 epoll_event(两个字段) |
Registry::reregister |
1 × epoll_ctl(MOD) |
同上 |
Registry::deregister |
1 × epoll_ctl(DEL) |
无 |
Poll::poll 返回 N 个事件 |
1 × epoll_wait |
events.clear() + set_len(N),没有逐个拷贝 |
Waker::wake |
1 × write 到 eventfd |
把 1u64 转成 8 字节 |
每一行都是"1 个系统调用 + 几乎为零的用户态工作"——这就是"薄"在源码层面的确切含义。特别注意 Poll::poll 那一行:返回 N 个事件并不产生 N 次拷贝或 N 次分配,内核直接写进复用的 Vec 缓冲,mio 只改一下长度。
想要你自己机器上的绝对数字,就自己写个最小压测:Poll::new + 注册 N 个 socket + 循环 poll,用 strace -c 数系统调用、用 perf 看用户态占比。
当你下次面对"要不要再加一层抽象"的设计决策时,想想这张表——每加一层,就是往每一行里再塞点东西,而且是永久的。
9.17 本章小结
本章带你从 mio 的 API 表面走到了它的最底层——每一个 epoll_create1 / kevent / GetQueuedCompletionStatusEx 系统调用。读完你应该能回答三个问题:(1)mio 的 API 为什么能做到"跨平台一套、各平台一份实现"?(2)epoll / kqueue / IOCP 的编程模型差异有多大?(3)Tokio 把 mio 当"黑箱"用合理吗?
答案:(1)mio 把平台差异封装在 sys::Selector 里、通过 Rust #[cfg(target_os)] 在编译期选择实现;(2)epoll 和 kqueue 的"readiness 模型"是同源的、IOCP 的"completion 模型"完全不同、需要 mio 做额外模拟;(3)对 Tokio 来说 mio 是合适的抽象层——它不把平台细节泄漏到上层、但在薄度和稳定性上又接近不可再压缩的极限。
带走三件事:
- mio 是 epoll/kqueue/IOCP 的跨平台统一 API——顶层
Poll(src/poll.rs:266)+Registry(src/poll.rs:271)+sys::Selector三层,Selector 是 per-OS 的。Linux 那份src/sys/unix/selector/epoll.rs全文只有 231 行(含event子模块里那一堆 flag 判断) - Linux Selector 的核心是 3 个系统调用:epoll_create1 / epoll_ctl / epoll_wait。每个函数的 mio 实现都是 10-30 行——极致的薄。这份薄让 mio 成为 Rust 生态的事实标准 I/O 基础设施
- mio::Waker 在三平台用三种完全不同机制:Linux eventfd、macOS/FreeBSD EVFILT_USER、Windows PostQueuedCompletionStatus。API 统一、实现分离——跨平台抽象的教科书案例
读完本章的收获不止 Tokio
上面说过这一章的收获超出 Tokio 本身。具体说,你应该已经在以下维度长进:
- 系统调用层面的直觉:epoll / kqueue / IOCP 的机械差异 vs 抽象相似
- 跨平台抽象的架构:sys 模块 + cfg 分支 + 统一 API 的布局模式
- 薄抽象的设计哲学:为什么"只做一件事"是基础设施库的最高美德
- FFI 和 syscall 的实战:syscall! 宏、libc 绑定、errno 处理
- 跨平台 waker 的三种实现:eventfd / EVFILT_USER / IOCP post
这些知识在 Rust 之外也适用——写 C / C++ 的系统编程、读 Linux kernel 的 net 子系统、理解 Node.js libuv 的 I/O 层——思维工具是通用的。
最后一个维度:mio 如何应对 Linux 内核 bug
操作系统内核本身也有 bug。mio 作为"薄抽象"不会去修这些 bug(那是内核的事)、但它在文档和测试里记录已知问题,并在必要时加 workaround。
具体做法:
- Poll 的 doc comment 里明确列出已知的 OS 行为差异
- CI 跑 Linux / macOS / FreeBSD / Windows 多平台测试
- 对 edge case 加单独测试(
tests/下有close_on_drop.rs、regressions.rs这类专门的文件) - 遇到内核 bug,在代码里加 workaround 并注明原因——比如
poll(2)兜底实现里那句 "A bug in kernels < 2.6.37 makes timeouts larger than LONG_MAX / CONFIG_HZ ... effectively infinite on 32 bits architectures"(src/sys/unix/selector/poll.rs:503),随后就把超时截到一个安全上限;前面 kqueue 的EPIPE注释(附 issue 链接)也是同一种做法
这种"记录 + 透明 + 保守修补"的态度是底层基础设施库的标准做法。和应用代码不同——底层库不能"快速演进",每一次改动都可能影响几千个上游项目。
读 mio 的 issue tracker 有时候像在看Linux 内核行为的"边界文档"——你能学到很多 Linux 内核冷知识。
下一章回到 Tokio 本身——看 TcpStream 和 UdpSocket 的源码。你会看到这两个最常用的 I/O 类型如何在 ScheduledIo + mio 的基础上实现,如何把 AsyncRead / AsyncWrite trait 和 poll_readiness 串起来。读过 9 章的积累在第 10 章会一次爆发——你会把所有前面的概念在一个具体的 I/O 资源实现里一次看全。一个 TcpStream 从构造到使用,涉及前 9 章里的所有组件:Future trait(第 2 章)、Waker(第 3 章)、Runtime(第 4 章)、Task(第 6 章)、ScheduledIo(第 8 章)、mio 底层(本章)——第 10 章把它们全串起来。
延伸阅读
- mio 源码:
tokio-rs/mio - epoll 手册:
man 7 epoll、man 2 epoll_wait - kqueue 手册:
man 2 kqueue(macOS / FreeBSD) - Linux
eventfd手册:man 2 eventfd - 《Rust 编译器与运行时揭秘》专栏第 13 章:FFI:与 C 世界的桥梁
- tokio-rs/mio 仓库 —— mio 的源码与设计文档