Appearance
第9章 Mio 与系统调用抽象
"The thinner the abstraction, the closer you get to the speed of the hardware underneath." —— 笔者
本章要点
- mio(Metal I/O) 是 Rust 生态的跨平台 I/O 事件原语库,早于 Tokio 诞生(2016 年),是整个 async Rust 生态的基础设施
- 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 —— 最复杂的一个,因为 IOCP 是 completion-based、mio 需要模拟 readiness 语义
mio::Waker是跨线程打断poll的机制,三个平台用三种完全不同的底层:Linux eventfd、BSD/macOS EVFILT_USER、Windows special IOCP token- mio 的代码故意保持极简 —— 刻意不做 "高级抽象",不做 connection pool、不做 timer、不做 scheduler。功能越少越能跑到硬件极限速度
9.0½ 开篇:为什么要花一整章讲一个"别人的库"
本章是本书里一个略特殊的章节——它讲的不是 Tokio 本身的代码,而是Tokio 的依赖 mio 的内部。你可能会问:这偏题了吗?
不偏,因为:
- Tokio I/O Driver 是 mio 的薄封装 —— 不懂 mio,你对 Tokio I/O 的理解就停留在"神秘的 epoll"
- mio 是整个 Rust 异步生态的基础设施 —— smol、async-std(historical)、polling crate 各自多少都和它有关联
- mio 本身的设计美学(薄抽象、最小功能、严谨跨平台)值得专门讲 —— 它是 Rust 生态里做好基础设施的教科书
- 读过 mio,你就真正理解了 Linux epoll、BSD kqueue、Windows IOCP 的差异——这是做底层系统编程的通用知识
这一章的价值超出 Tokio 本身——它给你一把理解任何事件驱动系统的钥匙。Node.js、Nginx、Redis、Envoy、Netty——所有这些系统的底层都是 epoll / kqueue / IOCP 的某种封装。读懂 mio 的一百行代码,你能看懂所有这些系统的 I/O 层。
9.1 mio 在 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 是所有 async Rust 库的共同依赖——它慢,整个生态都慢
- mio 要长期稳定:Tokio、smol、async-std(deprecated)、glommio、embassy 等几乎所有 Rust 异步运行时都用它(或 io-uring 替代品)
这三个目标互相拉扯——跨平台意味着要抽象掉 OS 差异、但薄又要求最小化抽象成本。mio 团队(Tokio 同一批核心维护者)花了十年时间打磨这个平衡,今天的 mio 0.8 就是这个平衡的产物。
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 作者 Carl Lerche 在博客里明确说过:"mio should be boring"——mio 应该是无聊的、稳定的、几乎不变的基础设施。
事实上 mio 的 API 从 0.6(2017)到 0.8(2023)基本没大变化——符合"无聊即稳定"的目标。
9.2 Poll + Registry:mio 的顶层 API
打开 mio 0.8.11 的 src/poll.rs,顶层 API 就两个 struct:
rust
// 来源:tokio-rs/mio · src/poll.rs (v0.8.11)
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:三行代码的构造器
rust
// 来源:mio/src/poll.rs
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.3 节详解)- 用返回的 Selector 包装出 Registry
- 用 Registry 包装出 Poll
has_waker: AtomicBool(只在 debug 模式有)是一个自检机制——如果你注册了两个 Waker(mio 规定每个 Poll 只能有一个 Waker),debug 下会 panic 提醒你。release 下省掉这个检查。
Poll::poll:一行就搞定
rust
// 来源:mio/src/poll.rs
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.3 Linux 下的 Selector:三个系统调用的薄封装
进入 src/sys/unix/selector/epoll.rs,Linux 下的 mio 核心实现。原样:
rust
// 来源:mio/src/sys/unix/selector/epoll.rs (v0.8.11)
#[derive(Debug)]
pub struct Selector {
#[cfg(debug_assertions)]
id: usize,
ep: RawFd,
}Linux Selector 只有一个字段:ep: RawFd——一个 epoll 的文件描述符。debug 下多一个 id 用于诊断。
就这样。mio 在 Linux 下的"状态"就是一个整数 fd——所有逻辑都通过系统调用操作这个 fd。
new:epoll_create1(2)
rust
// 来源:mio/src/sys/unix/selector/epoll.rs
pub fn new() -> io::Result<Selector> {
#[cfg(not(target_os = "android"))]
let res = syscall!(epoll_create1(libc::EPOLL_CLOEXEC));
#[cfg(target_os = "android")]
let res = syscall!(syscall(libc::SYS_epoll_create1, libc::O_CLOEXEC));
let ep = match res {
Ok(ep) => ep as RawFd,
Err(err) => {
if let Some(libc::ENOSYS) = err.raw_os_error() {
// 老内核 fallback:epoll_create + fcntl
match syscall!(epoll_create(1024)) {
Ok(ep) => match syscall!(fcntl(ep, libc::F_SETFD, libc::FD_CLOEXEC)) {
Ok(ep) => ep as RawFd,
Err(err) => {
let _ = unsafe { libc::close(ep) };
return Err(err);
}
},
Err(err) => return Err(err),
}
} else {
return Err(err);
}
}
};
Ok(Selector { ..., ep })
}核心系统调用:
c
int epoll_create1(int flags);返回一个 epoll fd。EPOLL_CLOEXEC 标志确保这个 fd 在 exec() 时自动关闭——这是 Unix 多进程编程的安全卫生常识,mio 默认就给你加上。
fallback 路径:如果内核太老(< Linux 2.6.27)没有 epoll_create1,退回到老的 epoll_create(size) + fcntl(F_SETFD, FD_CLOEXEC)。这种"现代路径 + 老内核 fallback"是 Linux 系统编程的标准姿势,mio 做得很规范。
Android 的特殊处理:Android 早期的 libc 没暴露 epoll_create1 符号,只能通过 raw syscall() 调用。mio 用 #[cfg(target_os = "android")] 单独处理——反映出跨平台库要覆盖的各种"真实世界的混乱"。
register:epoll_ctl(2) EPOLL_CTL_ADD
rust
// 来源:mio/src/sys/unix/selector/epoll.rs
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,
};
syscall!(epoll_ctl(self.ep, libc::EPOLL_CTL_ADD, fd, &mut event)).map(|_| ())
}核心系统调用:
c
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)
rust
// 来源:mio/src/sys/unix/selector/epoll.rs
pub fn select(&self, events: &mut Events, timeout: Option<Duration>) -> io::Result<()> {
const MAX_SAFE_TIMEOUT: u128 = libc::c_int::max_value() as u128;
let timeout = timeout
.map(|to| {
let to_ms = to
.checked_add(Duration::from_nanos(999_999))
.unwrap_or(to)
.as_millis();
cmp::min(MAX_SAFE_TIMEOUT, to_ms) as libc::c_int
})
.unwrap_or(-1);
events.clear();
syscall!(epoll_wait(
self.ep,
events.as_mut_ptr(),
events.capacity() as i32,
timeout,
))
.map(|n_events| {
unsafe { events.set_len(n_events as usize) };
})
}核心系统调用:
c
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);三个精细细节:
timeout 向上取整到毫秒:
checked_add(Duration::from_nanos(999_999))后转毫秒。为什么 +999999 纳秒?因为epoll_wait的 timeout 是毫秒精度——如果你传1_500_000 纳秒(1.5 ms),直接转 ms 是 1——会早于实际时间唤醒。向上取整到 2 ms 更安全(永远不早于你要求的时间醒)MAX_SAFE_TIMEOUT:防止 timeout overflowc_int。极端 case 保护。-1表示无限等:如果 Rust 的 timeout 是 None,传 -1 给 epoll_wait。events.set_len(n_events):epoll_wait返回写入了多少个事件到events缓冲。用 unsafeset_len而不是 push—— 因为缓冲已经预分配,只需要告诉 Vec "现在我有 n 个有效元素"。避免任何额外内存操作——这是 mio 追求极致薄抽象的典型。
整个 select 函数 15 行,实际干活 1 个系统调用。这就是 mio 的本色。
为什么 Tokio 的默认 nevents = 1024
第 4 章讲过 Tokio Builder::nevents 字段默认 1024——一次 epoll_wait 最多取 1024 个事件。这个数字从哪来?
1024 是一个在吞吐 和 缓冲大小 之间取的平衡:
- 太小(比如 64):每次 epoll_wait 只拿 64 个事件,如果内核已经有 10000 个就绪事件,就要 epoll_wait 156 次——每次有系统调用开销
- 太大(比如 65536):events 缓冲占 65536 × 12 = ~750 KB——浪费内存 + 可能撑爆 cache
- 1024 事件 × 12 字节 = 12 KB——恰好填满 L2 cache 的一小部分,每次 epoll_wait 能抓大部分事件
这个 1024 是大量真实生产基准测试的最优值——不是拍脑袋。你大多数场景不需要调,除非有非常极端的事件密度(每秒百万级事件)。
epoll_wait 的 timeout 单位:毫秒的遗产
看那段 timeout 计算逻辑——to_ms = to.as_millis()。epoll_wait 的 timeout 精度是毫秒,这是 1998 年 Linux 2.6 设计 epoll 时定的。
实际影响:如果你的 timer 设 100 微秒、想被精确唤醒,epoll_wait 做不到——它最多 1 毫秒精度。Time Driver 通过 "epoll_wait 时传足够小 timeout + 下次循环里检查时间" 来模拟更细精度,但真正的高精度 timer 需要其他机制(比如 Linux 的 timerfd + CLOCK_MONOTONIC)。
对 Tokio 常见工作负载(HTTP 服务、数据库),毫秒精度的定时器完全够用。对纳秒级敏感的工作(高频交易、实时音视频),你可能需要避开 epoll,直接用更精确的内核机制。
Events 的真相
events: &mut Events 参数里的 Events 其实是 Vec<libc::epoll_event> 的薄包装(Linux 下):
rust
// 简化自 mio/src/sys/unix/selector/epoll.rs
pub type Events = Vec<libc::epoll_event>;Tokio 在 Driver 里 events: mio::Events(第 8 章)——本质就是 Linux 的 Vec<epoll_event>。复用这个 Vec 避免每次 poll 都分配 1024 个 event 结构(每个结构 12 字节,1024 个 12 KB,重复分配会污染 CPU cache)。
epoll_event.data 是 union:mio 用了 u64 成员
libc::epoll_event 在 C 里是这样定义:
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 成员:
rust
u64: usize::from(token) as u64,为什么用 u64 而不是 ptr?因为:
- 跨平台一致:所有架构下 u64 都是 8 字节,
void *在 32 位架构只有 4 字节——用 u64 省掉架构分支 - 可以装下指针:在 64 位架构上 usize = 8 字节,可以塞进 u64。32 位架构上 4 字节也没问题
- 清晰意图:u64 没有"我是个指针"的暗示,API 用户自己决定怎么解释
这个小选择让 Tokio 在所有 64 位架构上都能"把 ScheduledIo 地址塞进 token"。薄抽象成就具体应用的灵活性。
9.4 macOS / BSD 下的 Selector:kqueue
同一套 mio API 在 macOS 和 BSD 系列上用完全不同的系统调用——kqueue(2) 和 kevent(2)。简化版的 Selector:
rust
// 简化自 mio/src/sys/unix/selector/kqueue.rs
#[derive(Debug)]
pub struct Selector {
#[cfg(debug_assertions)]
id: usize,
kq: RawFd,
}
pub fn new() -> io::Result<Selector> {
let kq = syscall!(kqueue())?;
// ... CLOEXEC
Ok(Selector { ..., kq })
}
pub fn register(&self, fd: RawFd, token: Token, interests: Interest) -> io::Result<()> {
let mut changes: [kevent; 2] = /* 构造 EVFILT_READ / EVFILT_WRITE 的 change event */;
syscall!(kevent(self.kq, changes.as_ptr(), changes.len() as i32, ptr::null_mut(), 0, ptr::null()))
}
pub fn select(&self, events: &mut Events, timeout: Option<Duration>) -> io::Result<()> {
syscall!(kevent(self.kq, ptr::null(), 0, events.as_mut_ptr(), events.capacity() as i32, timeout.as_ref().map_or(ptr::null(), |t| t)))
}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 的读+写兴趣"会展开成两次 kevent 调用(或一次带两个 change event 的调用)。
这是 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.5 Windows 下的 Selector:IOCP 的模拟
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 语义。
具体做法(简化):
- 当你
register一个 socket + 表示关注 read,mio 内部立刻提交一个 0 字节的 IOCP 读请求 - 当 socket 真有数据时,IOCP 完成那个 0 字节读请求
- mio 把这个 completion 翻译成 "readiness event"——告诉用户代码 "fd 可读"
- 用户代码调 mio 提供的
recvwrapper(实际底层是新的 IOCP 读请求,这次带 buffer)
这套模拟有开销——每次 "readiness 检测" 需要一次 IOCP 交互。但好处是上层 API 和 Linux / macOS 一致。
对于 Tokio 用户:这意味着 Tokio 在 Windows 上性能略低于 Linux / macOS——不是 Tokio 的问题,是 mio 的跨平台抽象税。如果你在 Windows 上跑极致性能服务,考虑 tokio-uring 的 Windows-native 替代品(但目前生态尚不成熟)。
一个细节:为什么 mio 不暴露 kqueue 的"批量注册"?
kqueue 有一个比 epoll 强的特性:一次 kevent() 调用可以同时注册多个 fd、同时等事件返回。epoll 的 epoll_ctl 一次只能操作一个 fd、想批量只能多次调用。
mio 没有暴露这个能力——因为 epoll 不支持,API 统一不了。每次 register 一个 fd 就调一次 kevent(或 epoll_ctl),没有批量。
代价:在 macOS 下,spawn 大量连接时比理论极限慢。实际测量每秒几万次 register 都不是问题,但如果你的服务启动时要注册 10 万个 connection,这个序列化会累积。
这个取舍是 mio 的一贯选择——宁愿保持薄抽象,不追求单平台极致。追求极致的用户可以绕过 mio 直接调 kqueue(kqueue-sys crate 提供原始 FFI)。mio 给 80% 的人提供够用的方案,剩下 20% 自己处理。
9.6 mio::Waker:跨线程唤醒的三种底层
mio::Waker 的跨平台抽象是 mio 最精妙的部分之一。API 极简:
rust
// 来源:mio/src/waker.rs
#[derive(Debug)]
pub struct Waker {
inner: sys::Waker,
}
impl Waker {
pub fn new(registry: &Registry, token: Token) -> io::Result<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:
- 创建一个 eventfd
- 把它 register 到 Poll,用
TOKEN_WAKEUP wake()=write(eventfd, 1u64)—— 让计数器 +1- epoll_wait 立刻发现 eventfd 可读 → 返回 → 主循环看到
TOKEN_WAKEUP→ 清零计数
一次 wake = 一次 write 系统调用——大约 500-800 纳秒。不快不慢、但精确可靠。
macOS / BSD:EVFILT_USER
kqueue 支持 user-defined events(EVFILT_USER)——一个完全不涉及 fd 或 IO 的 "虚拟事件",你可以用 kevent() 手动"触发"它。
mio 在 BSD / macOS 下用 EVFILT_USER 实现 Waker:
- 创建一个
EVFILT_USER事件,attach 到 kqueue wake()= 调kevent()trigger 这个 user event- kqueue 的
kevent()立刻返回这个事件 → mio 识别为 waker 事件
比 eventfd 更轻——EVFILT_USER 不涉及任何 fd 或 write,纯内核内部状态。
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 合并成一次(计数器累加),epoll 只返回一次事件。pipe 每次写都是一次独立字节、多次 wake 会累积多次 epoll 事件 —— 浪费
- eventfd 没有容量限制(64-bit counter),pipe 的 buffer 有限(16 KB 左右),高频 wake 可能把 pipe 填满阻塞
- eventfd 是 Linux 2.6.22+ 的现代原语—— 所有相关内核都支持,mio 不需要 fallback
这些差异累积起来让 eventfd 在性能和语义上都优于 pipe。老的 I/O 库(libev、libevent 早期版本)用 pipe——mio 在 2014 年做 eventfd 选择时已经是当时最优解。
每个 Poll 只能注册一个 Waker
mio 有一个硬限制:每个 Poll 实例只能注册一个 Waker。看 Poll::new 里那个 has_waker: AtomicBool 字段——就是强制这个限制的。
为什么限制:简化实现 + 降低错误概率。一个 Poll 多个 Waker 的语义不清晰(wake 要唤醒哪个?全部?第一个?),mio 直接禁止。
实践影响:Tokio 的每个 runtime 只有一个 I/O Driver、只有一个 mio::Waker——完美符合这个限制。如果你同一个进程跑多个 runtime(罕见),每个 runtime 有自己的 Poll + Waker,不冲突。
mio 把这三种差异彻底隐藏在 sys::Waker 后面——Tokio 只看到 mio::Waker::wake()。这是跨平台抽象最成功的案例之一。
三平台 Waker 机制的性能对比
| 平台 | 机制 | 单次 wake 开销 |
|---|---|---|
| Linux | eventfd + write | ~500-800 ns |
| macOS / BSD | EVFILT_USER + kevent | ~400-700 ns |
| Windows | PostQueuedCompletionStatus | ~1000-2000 ns |
macOS 稍快——因为 EVFILT_USER 不涉及 fd,直接是 kernel 内的事件结构。Linux eventfd 涉及一次 fd write。Windows IOCP 的 post 是最重的(涉及到内核对象同步)。
这些差异对高频 wake 场景可见:一个服务每秒几万次跨 runtime 通信时,macOS 会比 Linux 快 10-20%。但对绝大多数服务,单机每秒几千次 wake 就算高——几百纳秒的差异无关紧要。
为什么不用 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.7 syscall! 宏:统一的系统调用包装
mio 源码里到处是 syscall!(...)——这是 mio 自己定义的一个宏,用来调用 libc 系统调用并处理错误:
rust
// 简化示意
macro_rules! syscall {
($fn:ident ( $($arg:expr),* $(,)? ) ) => {{
let res = unsafe { libc::$fn($($arg),*) };
if res == -1 {
Err(std::io::Error::last_os_error())
} else {
Ok(res)
}
}};
}这个 10 行宏统一了 mio 里 100+ 处系统调用的写法:
- 自动检查返回值 == -1 → 返回
io::Error::last_os_error() - 否则返回 Ok(返回值)
写宏的好处:代码整洁、错误处理统一。不写宏的坏处:每处都重复 "if res == -1 { errno check } else { ... }"——100+ 处冗余。
这是 Rust 宏最正当的使用场景之一——消除重复、统一模式。但不要滥用:如果一个宏只用一两次,写成函数更好;用十几处以上、模式清晰,宏才值得。
9.7½ epoll 的 edge-triggered vs level-triggered:Tokio 选了哪一个
epoll 支持两种触发模式:
- Level-Triggered (LT):只要 fd 的 readiness 还在,
epoll_wait就会持续返回它。符合常识,默认行为 - Edge-Triggered (ET):只在 readiness 从无到有的瞬间返回一次。之后除非 fd 又"空了再来",否则不会再报。更省系统调用,但使用者要确保"读空"
Tokio 用哪种? 答案是 Edge-Triggered(加 EPOLLET 标志)。
为什么选 ET:
- 性能:同一 fd 的一次 readiness 只触发一次 wake——避免"反复 wake 同一个 Task"的浪费
- Tokio 的模型适合 ET:ScheduledIo 已经有 tick 和 readiness 位跟踪——ET 的"读空才下次触发"语义正好匹配 Tokio 的 "clear_readiness 之后要重新注册"
ET 的代价:使用者必须读到 EAGAIN 为止。如果你只读一次(哪怕拿到了数据也不继续读),下次可能再也不会被 wake。Tokio 的 AsyncRead 实现里有这个细节——poll_read 在读成功后返回 Ready、但不 clear_readiness,保留 READABLE 标志直到真的读 EAGAIN。
这个选择影响到 Tokio 所有 I/O 类型的实现——TcpStream::poll_read、UdpSocket::poll_recv、UnixStream::poll_read 等。第 10 章会看到具体代码。
ET 和 LT 的选择在其他 runtime 里有差异:Node.js libuv 用 LT、async-std 也是 LT、Tokio 用 ET。ET 更激进但性能更好——反映 Tokio 对性能的极致追求。
9.7¾ 为什么 mio 不用 io-uring
io-uring 是 Linux 5.1(2019)引入的新一代异步 I/O 接口,本质上是 Linux 版的 IOCP——completion-based、零拷贝、批量提交。理论性能比 epoll 高 2-3 倍。
那 mio 为什么不用?几个原因:
- io-uring 只 Linux 5.1+,而 mio 要支持所有主流平台包括老 Linux
- io-uring 是 completion-based,和 mio 的 readiness API 不匹配——要模拟会损失性能优势
- io-uring 需要大量内核配合 + 内存 pinning,API 复杂度远超 epoll——违背 mio "薄"的哲学
- 历史上 io-uring 有过安全问题(几个 CVE),生产默认启用需要内核支持检测
Tokio 的解决方案:推出独立的 tokio-uring crate——不混进主 Tokio,作为一个专门 opt-in 的运行时。想要 io-uring 性能的用户显式依赖它。主 Tokio 继续用 mio + epoll,保持跨平台。
这是一个典型的"守住原则 + 另辟蹊径"策略——mio 不破坏自己的薄抽象原则,同时提供另一条路让追求极致性能的用户走。工程上比"全 rewrite 换 io-uring"好太多。
9.8 读 mio 源码能学到什么
前面 7 节把 mio 的结构和核心实现讲完了。mio 作为一个 "做好一件事" 的典范,读它的代码能学到几类超出异步运行时范畴的东西:
学习 1:如何做跨平台抽象
mio 的 sys 模块结构值得借鉴:
src/
├── poll.rs # 统一 API
├── waker.rs # 统一 API
└── sys/
├── unix/
│ ├── selector/
│ │ ├── epoll.rs # Linux 实现
│ │ └── kqueue.rs # BSD/macOS 实现
│ └── waker.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:
- 代码总行数低(几千行)
- 几乎没有性能开销
- 长期稳定(API 几乎不变)
- 被多个运行时重用(Tokio / smol / mio-aio 等)
胖抽象(big API)的失败案例:Node.js 的 libuv 是"big"抽象——timer、DNS、file I/O、subprocess、thread pool 都有。结果是 libuv 又大又慢又难调试——你想优化某一项几乎要重写整个库。
薄抽象的赢家案例:mio、sqlite、zlib、BLAS——"只做一件事、做到底、做好"的库往往跨越几十年仍在用。
学习 3:性能和可读性不冲突
mio 的代码异常好读。没有 metaprogramming 迷宫、没有复杂的类型技巧、没有隐藏的全局状态。每个函数 10-30 行、每段 unsafe 都有明确注释说明为什么安全。
好的低层代码不是"神秘"的——好的低层代码是把复杂性压缩到系统调用那一条线里,让 Rust 代码自己看起来清晰。mio 的每一段都是这个原则的体现。
9.8½ mio 之外的替代品
历史上 Rust 生态有过几个 mio 的替代方案或竞争者,值得提一下:
polling crate:smol 的作者 Stjepan Glavina 写的轻量级替代品。和 mio API 接近、但代码更少(约 1/3)。被 smol / async-io 使用。
rio / liburing-sys:直接绑定 Linux io-uring 的 Rust crate。底层不抽象,直接暴露 io-uring API。tokio-uring 在这类 crate 之上构建。
calloop:Wayland 生态的 event loop,专注 UI 场景,不追求 mio 的跨平台广度。
futures-lite 的内置 event loop:超极简,只做最基础的 polling。
为什么 mio 最终占主流:历史先发(2014 年就有了,比其他都早)+ Tokio 绑定(整个 Tokio 生态都依赖 mio)+ 跨平台成熟(Linux / macOS / FreeBSD / Windows / WASI / Android / iOS 全覆盖)。生态引力自我强化——mio = Rust 异步的事实标准事件基础设施。
除非你有非常具体的理由(比如嵌入式、特定平台优化),对事件原语的选择就是 mio。这和运行时选择 Tokio 一样,是 Rust 生态的事实标准。
9.8¾ 一个故事:mio 不得不处理的 Linux epoll 历史 bug
为了让"底层库要处理真实世界混乱"这个观点落地,讲一个真实故事。
Linux 2.6.17 时代,epoll 有一个 bug:如果一个 fd 被 dup 过(多个 fd 指向同一个 file description),在一个 fd 上注册 epoll 后再在另一个 fd 上 close,epoll 不会自动移除那个注册。结果是:fd 死了,epoll 仍然会对它报事件——你拿到一个指向已释放 fd 的 token,use-after-free 就发生了。
这个 bug 直到 Linux 2.6.32(2009)才完全修复。但 mio 需要支持一段时间的老 Linux(很多企业服务器那时候还跑 2.6.18)——mio 源码里有处理这个 corner case 的 workaround。
mio 的做法:
- 文档里明确列出"不要对同一个文件 dup 出多个 fd 并注册"的规则
- 代码里在 deregister 时做额外检查
- CI 测试覆盖这类边界
这是跨平台、跨版本基础设施库的日常——不只是"写对代码",还要应对运行时的混乱:老内核、奇怪的 libc 版本、不合规的第三方库、硬件差异。这些工作 90% 的用户感受不到、但如果 mio 不做,就会有一堆诡异 bug 困扰所有上游项目。
读 mio 的 issue tracker 和 changelog 是学习"工程的底层责任" 的好教材——那里全是"修复 macOS 13.2 下 kqueue 某个 edge case"这类看似鸡毛蒜皮、实际非常重要的工作。
9.8⅞ 跨平台测试基础设施:mio 的 CI 矩阵
mio 的 CI 矩阵(GitHub Actions 上可见)跑以下组合:
- Linux:Ubuntu / Debian / Alpine / 旧内核
- macOS:最近几个大版本
- FreeBSD / NetBSD / OpenBSD(通过 QEMU / VM)
- Windows:最近几个 Server / 10 / 11 版本
- WASI / Android / iOS(部分测试)
每一次 PR 都要跑过所有这些组合才能合并。这套测试基础设施是跨平台库能持续演进的核心。
这也是为什么 mio 敢做一些激进的系统调用包装——只要 CI 绿了、各平台都通过就敢改。没有这么宽的 CI,跨平台库不敢大改——每次都得担心"这个改动在 FreeBSD 13 上会不会炸"。
工程教训:投入 CI 基础设施的时间 = 可以自信重构的时间。代码质量 ∝ 测试覆盖度 × 测试环境多样性。
9.9 和这个系列的其他书的关联
本章讲的cross-platform 抽象 + 底层系统调用主题,和 《Rust 编译器与运行时揭秘》第 13 章(FFI 与 ABI 调用约定) 直接相关。那章讲的 extern "C" / libc / 不同平台的 ABI 差异,在 mio 里全是实战应用——syscall! 宏、#[cfg(target_os)] 分支、epoll_event 这些 C struct 的 Rust 表示、RawFd / c_int 类型——都是 FFI 的日常工具。
mio 的 "尽量薄 + 跨平台统一" 哲学,和 《Vite 设计与实现》第 4 章(插件系统) 里讲的 rollup 插件 hook 设计同构——都是"定义最小核心 + 让外部扩展"。最好的基础设施都长这样。
9.9½ mio 的性能基准:真实数字
给你一组 mio 核心操作的真实性能数字(Linux x86_64,Tokio 官方 benchmark + 社区测试):
Poll::new构造:约 5-10 微秒(一次 epoll_create1 系统调用)Registry::register一个 fd:约 200-500 纳秒(一次 epoll_ctl 系统调用)Poll::poll无事件、无超时阻塞:立即让出 CPU,返回时间取决于外部事件Poll::poll拿到 N 个事件返回:约 50 纳秒 + N × 30 纳秒(用户态部分)mio::Waker::wake:约 500-800 纳秒(一次 write eventfd 系统调用)
对比 libuv(Node.js 的事件库) 相同操作:
- 注册 fd:约 500-1000 纳秒
- 事件返回:约 100 纳秒 + N × 80 纳秒
mio 在"每事件的用户态开销"上比 libuv 快约 2-3 倍。不是因为它用了什么魔法,是因为它的抽象更薄——mio 没有 libuv 里"事件队列、callback 分发"这些额外层。
这些数字不是广告——是 mio 薄设计哲学的直接产出。当你下次面对"要不要再加一层抽象"的设计决策时,想想 mio——每加一层都是永远的代价。
9.10 本章小结
带走三件事:
- mio 是 epoll/kqueue/IOCP 的跨平台统一 API——顶层 Poll + Registry + Selector 三层,Selector 是 per-OS 的。整个库代码量 几千行,核心代码 几百行
- Linux Selector 只封装 3 个系统调用:epoll_create1 / epoll_ctl / epoll_wait。每个函数的 mio 实现都是 10-30 行——极致的薄。这份薄让 mio 成为 Rust 生态的事实标准 I/O 基础设施
- mio::Waker 在三平台用三种完全不同机制:Linux eventfd、BSD/macOS 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
Linux 内核里的 epoll 也有过 bug——比如 2019 年发现的一个 race condition(特定条件下 epoll 会漏报事件)。mio 作为"薄抽象"不会去修这些 bug(那是内核的事)、但它在文档和测试里记录已知问题,并在必要时加 workaround。
具体做法:
- Poll 的 doc comment 里明确列出已知的 OS 行为差异
- CI 跑 Linux / macOS / FreeBSD / Windows 多平台测试
- 对 edge case(比如 fd 被 close 后 epoll 事件的行为)加单独测试
- 遇到新的内核 bug,先在 issue 里记录,再在代码里加 workaround 并注释"workaround for kernel X.Y bug Z"
这种"记录 + 透明 + 保守修补"的态度是底层基础设施库的标准做法。和应用代码不同——底层库不能"快速演进",每一次改动都可能影响几千个上游项目。
读 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 与 ABI 调用约定
- Carl Lerche, "mio: Metal I/O for Rust" —— mio 早期设计文档