Skip to content

第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)),
        },
    })
}

三行干了什么

  1. sys::Selector::new() —— 调用 per-OS 的 Selector 构造器(9.3 节详解)
  2. 用返回的 Selector 包装出 Registry
  3. 用 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。

newepoll_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")] 单独处理——反映出跨平台库要覆盖的各种"真实世界的混乱"。

registerepoll_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);

opEPOLL_CTL_ADD(新增)/ EPOLL_CTL_MOD(改)/ EPOLL_CTL_DEL(删)之一。event 结构体包含两个核心字段

  • events:这个 fd 要关注什么事件(EPOLLIN 读、EPOLLOUT 写、EPOLLET edge-triggered 等)。mio 把 Rust 的 Interest(READABLE / WRITABLE)转成 epoll 的位组合
  • u64: data:一个 64-bit 用户数据,epoll 在事件返回时会原样还给你——这就是 Token!第 8 章讲的"Token = 指针"技巧在这里变成"把 token 存到 epoll_event.u64"

mio 把 token 存到 event.u64epoll_wait 返回事件时,这个 u64 原样回来——Tokio 的 I/O Driver 再 as *const ScheduledIo 转回指针。全链路无 HashMap 查找的秘密就在这里。

selectepoll_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);

三个精细细节

  1. timeout 向上取整到毫秒checked_add(Duration::from_nanos(999_999)) 后转毫秒。为什么 +999999 纳秒?因为 epoll_wait 的 timeout 是毫秒精度——如果你传 1_500_000 纳秒(1.5 ms),直接转 ms 是 1——会早于实际时间唤醒。向上取整到 2 ms 更安全(永远不早于你要求的时间醒)

  2. MAX_SAFE_TIMEOUT:防止 timeout overflow c_int。极端 case 保护。

  3. -1 表示无限等:如果 Rust 的 timeout 是 None,传 -1 给 epoll_wait。

  4. events.set_len(n_events)epoll_wait 返回写入了多少个事件到 events 缓冲。用 unsafe set_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;

dataunion——同一块内存可以解释成 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 的主要差异

维度epollkqueue
事件类型EPOLLIN / EPOLLOUT / EPOLLHUP 等通用 flagEVFILT_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 提供的 recv wrapper(实际底层是新的 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:

  1. 创建一个 eventfd
  2. 把它 register 到 Poll,用 TOKEN_WAKEUP
  3. wake() = write(eventfd, 1u64) —— 让计数器 +1
  4. epoll_wait 立刻发现 eventfd 可读 → 返回 → 主循环看到 TOKEN_WAKEUP → 清零计数

一次 wake = 一次 write 系统调用——大约 500-800 纳秒。不快不慢、但精确可靠。

macOS / BSD:EVFILT_USER

kqueue 支持 user-defined eventsEVFILT_USER)——一个完全不涉及 fd 或 IO 的 "虚拟事件",你可以用 kevent() 手动"触发"它。

mio 在 BSD / macOS 下用 EVFILT_USER 实现 Waker:

  1. 创建一个 EVFILT_USER 事件,attach 到 kqueue
  2. wake() = 调 kevent() trigger 这个 user event
  3. kqueue 的 kevent() 立刻返回这个事件 → mio 识别为 waker 事件

比 eventfd 更轻——EVFILT_USER 不涉及任何 fd 或 write,纯内核内部状态。

Windows:特殊的 IOCP post

Windows 下 mio 用 PostQueuedCompletionStatus API——手动往 IOCP 队列里 post 一个特殊的 completion packet

  1. 创建一个特殊 token 的 waker 对象
  2. wake() = PostQueuedCompletionStatus 往 IOCP 队列塞一个带特定 token 的包
  3. GetQueuedCompletionStatusEx 拿到这个包 → 识别为 waker token

三种机制语义完全不同、但效果完全一样:都是"从另一个线程让阻塞在 poll 上的 mio 立刻返回"。

eventfd vs pipe:为什么不用 pipe

在 Linux 上,另一个常见的"跨线程唤醒"方式是管道(pipe):写一个字节到管道,读端可以被 epoll_wait 唤醒。

为什么 mio 用 eventfd 而不是 pipe?几个原因:

  1. eventfd 只占 1 个 fd,pipe 占 2 个(读端 + 写端)—— eventfd 更节省 fd 配额
  2. eventfd 是 atomic counter,多次 wake 合并成一次(计数器累加),epoll 只返回一次事件。pipe 每次写都是一次独立字节、多次 wake 会累积多次 epoll 事件 —— 浪费
  3. eventfd 没有容量限制(64-bit counter),pipe 的 buffer 有限(16 KB 左右),高频 wake 可能把 pipe 填满阻塞
  4. 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 开销
Linuxeventfd + write~500-800 ns
macOS / BSDEVFILT_USER + kevent~400-700 ns
WindowsPostQueuedCompletionStatus~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_readUdpSocket::poll_recvUnixStream::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 为什么不用?几个原因:

  1. io-uring 只 Linux 5.1+,而 mio 要支持所有主流平台包括老 Linux
  2. io-uring 是 completion-based,和 mio 的 readiness API 不匹配——要模拟会损失性能优势
  3. io-uring 需要大量内核配合 + 内存 pinning,API 复杂度远超 epoll——违背 mio "薄"的哲学
  4. 历史上 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 cratesmol 的作者 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 本章小结

带走三件事:

  1. mio 是 epoll/kqueue/IOCP 的跨平台统一 API——顶层 Poll + Registry + Selector 三层,Selector 是 per-OS 的。整个库代码量 几千行,核心代码 几百行
  2. Linux Selector 只封装 3 个系统调用:epoll_create1 / epoll_ctl / epoll_wait。每个函数的 mio 实现都是 10-30 行——极致的薄。这份薄让 mio 成为 Rust 生态的事实标准 I/O 基础设施
  3. 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 章把它们全串起来。


延伸阅读

基于 VitePress 构建