Tokio 源码深度解析

第8章 Reactor 与 I/O Driver 架构

作者 杨艺韬 · 13,385 字 · 发布于 · 更新于

本章要点

  • Tokio I/O Driver 是 mio 库的薄封装——Driver struct 只有 3 个字段:signal_ready: bool、events: mio::Events、poll: mio::Poll
  • Handle 里管 mio::Registry(注册 fd 的接口)、RegistrationSet(所有活跃注册)、mio::Waker(让 turn 从外部被唤醒)
  • park() 的本质就是一次 mio::Poll::poll 系统调用 + 事件分发——每个 mio event 对应 Tokio 的一个 ScheduledIo
  • Token 编码:mio::Token(n) 在 Tokio 里被设计成直接存 Arc<ScheduledIo> 里那块堆内存的地址—— let ptr: *const ScheduledIo = token.0 as *const _。这避免了 hash map 查找
  • ScheduledIo 是每个 fd 的 per-resource 结构:readiness: AtomicUsize(又一个 bit packing:Tick + Readiness + Shutdown)+ waiters: Mutex<Waiters>
  • poll_readiness 和 wake 两个方法是 Future 侧和 Driver 侧的对接点——poll_readiness 让 Task 返回 Pending 并注册 Waker,wake 在 epoll 事件到达时唤醒所有满足条件的 Waker
  • 一次 TcpStream::read.await 的完整 9 步走位:用户调 .await → poll_readiness → Pending → worker park → epoll_wait → 内核 返回 → 匹配 Token 找到 ScheduledIo → set_readiness + wake → Task 被重新 schedule → 再次 poll → Ready

8.1 开始前的"前置小测验"

这一章是本专栏的分水岭——前 7 章都在讲"Task 怎么被调度",从本章起讲"Task 怎么和真实世界(OS、时间、I/O)对接"。读之前做个自测,看看你已经有多少心智模型:

1. 一个 TcpStream::connect("1.2.3.4:80").await 的 .await 让步那一刻,Waker 被存到哪里? 答:被存到某个 ScheduledIo 的 waiters.writer(因为 connect 完成的标志是"fd 可写")

2. 如果 ScheduledIo 已经 drop 了,Tokio 还会用第 3 章讲的 vtable 去 wake 吗? 答:不会——drop ScheduledIo 前会 deregister fd,epoll 以后不会再报事件

3. 一个 fd 的 ScheduledIo 从哪里分配? 答:RegistrationSet::allocate 里的 Arc::new(ScheduledIo::default()),堆地址被塞进 mio::Token

4. epoll_wait 等事件时,Tokio 的 worker 线程在做什么? 答:该线程没事做——它进入 OS 级的阻塞等待(不占 CPU),直到 epoll 有事件

5. 为什么 multi_thread runtime 的 scheduler 章节讲 LIFO slot 和 work-stealing 非常复杂,但 I/O 这一章看起来相对简单? 答:因为 I/O Driver 本质上委托给 mio + OS。Tokio 只负责"把 mio 事件翻译成 Waker 调用"——大部分复杂逻辑在 OS 内核里(TCP/IP 栈、epoll 实现)。Scheduler 是 Tokio 自己的"原创",代码复杂度自然高

如果这 5 题你能答上 3 题以上,你已经是合格的 Tokio 使用者了。读完本章后应该能全部答对,并且能解释"为什么这样"。


8.2 I/O Driver 在 Tokio 里的定位

I/O Driver 是 Tokio 和操作系统打交道的那一层——它把 epoll(Linux)、kqueue(BSD/macOS)、IOCP(Windows)这些 OS 级事件机制包装成统一接口、让上层代码不用关心具体是哪个平台。这一层的重要性怎么强调都不过分:所有异步 I/O 最终都穿过这里和 OS 交换事件。

前 7 章讲完了"任务如何被调度"——Scheduler 和 Task 的故事。但还有个硬核问题没回答:tokio::net::TcpStream::read().await 这一行代码,具体怎么做到"有数据时唤醒任务、没数据时挂起"的?

答案是 I/O Driver——Tokio 运行时的"外部事件管道"。它的职责极其明确:

  • 接收 Future 的"关注请求":某个 Task 在 .await 一个 socket 可读 → Driver 记下"这个 Task 在等这个 fd 的读事件"
  • 主动等 OS 事件:调 epoll_wait(Linux)/ kqueue(macOS)/ IOCP(Windows)等系统调用
  • 翻译事件:OS 说"fd X 现在可读了"→ Driver 找到"在等 fd X 的 Task"→ 调 Waker 唤醒

整个 Tokio 的 I/O Driver 是 mio 库的薄封装。mio 做跨平台的 poll 抽象(epoll/kqueue/IOCP 统一成一套 API),Tokio 做"从 mio 事件到 Task Waker"的那一层桥接。

"Reactor" 是个老派的设计模式名字——它指的就是这种"等事件 + 分发到关注者"的架构。Tokio 用了这个名字是向传统致敬,核心逻辑是几十年来 event-driven 编程的集大成。Node.js 的 libuv、Python asyncio、Netty、libevent——底层都是这个模式。


和常见的 Reactor 教程讲法的区别

市面上大多数 "写个异步 runtime" 教程会这样开头:

首先我们需要一个 Reactor,它监听 I/O 事件、通知 Executor ...

然后开始展开这个 Reactor。这种讲法会让你误以为 Reactor 是 runtime 的独立模块。

实际上 Tokio 的 I/O Driver 不是一个独立的 thread / service,它是某个 worker 线程在空闲时顺手做的事。每个 worker 的主循环(第 5 章)在 park 阶段会调 I/O Driver 的 turn——turn 里才真的调 epoll_wait。I/O Driver 是一个被动组件,不是主动的线程。

这个认知差异对理解 Tokio 很重要:

  • 跑 async 任务的线程数 = worker 数(multi_thread 是 N 个,current_thread 是 1 个;spawn_blocking 的阻塞线程池另算)
  • 没有"专门的 I/O 线程"
  • I/O 事件处理的延迟 = "某个空闲 worker 什么时候调到 turn"

这种设计让 Tokio 的线程资源开销最小化——不像某些事件驱动系统有"一个 I/O 线程 + N 个 worker 线程"的额外开销。


8.3 Driver 与 Handle 两件套

Driver 和 Handle 的分离是 Tokio 里反复出现的设计模式——和 Runtime/Handle、Scheduler/Handle 是同一个思路。Driver 负责"真正在某个线程上做事"、Handle 负责"可以被 clone 后到处发"。这种分离让"谁做事"和"谁请求做事"解耦、也让 Driver 可以独占某些不能共享的资源(OS file descriptor、unsafe 状态)、Handle 则只携带能安全共享的引用。

打开 tokio/src/runtime/io/driver.rs,核心结构只有两个 struct——极简。照源码贴出(省略了 struct 上方的文档注释和 waker 字段的一行 wasi 说明):

// 来源:tokio-rs/tokio · tokio/src/runtime/io/driver.rs:20(`Handle` 在 tokio/src/runtime/io/driver.rs:32)

pub(crate) struct Driver {
    /// True when an event with the signal token is received
    signal_ready: bool,

    /// Reuse the `mio::Events` value across calls to poll.
    events: mio::Events,

    /// The system event queue.
    poll: mio::Poll,
}

pub(crate) struct Handle {
    /// Registers I/O resources.
    registry: mio::Registry,

    /// Tracks all registrations
    registrations: RegistrationSet,

    /// State that should be synchronized
    synced: Mutex<registration_set::Synced>,

    /// Used to wake up the reactor from a call to `turn`.
    #[cfg(not(target_os = "wasi"))]
    waker: mio::Waker,

    pub(crate) metrics: IoDriverMetrics,
}

两个 struct 的分工:

Driver 是 "主动的轮询循环者"——持有 mio::Poll 实例,每次 park() 都会用它调 epoll_wait。3 个字段:

  • poll: mio::Poll——封装 epoll/kqueue/IOCP 的 handle
  • events: mio::Events——复用的事件缓冲区(避免每次 poll 都分配一次事件数组;容量默认 1024,可用 Builder::max_io_events_per_tick 调)
  • signal_ready: bool——是否有 signal 事件待处理(signal handling 是 I/O Driver 的兼职工作)

Handle 是 "注册管理员"——对外提供注册新 fd、唤醒 Driver、查统计的接口。4-5 个字段:

  • registry: mio::Registry——从 poll clone 出的注册 handle(可以跨线程注册新 fd)
  • registrations: RegistrationSet——Tokio 自己维护的所有注册记录
  • synced: Mutex<Synced>——需要互斥的注册状态
  • waker: mio::Waker——关键一手:一个特殊的注册项,让其他线程可以通过调 waker.wake() 打断 Driver 正在运行的 epoll_wait
  • metrics——可观测性

一个典型的部署模式

Runtime 启动时:

  1. Driver::new(nevents) 构造出一对 (Driver, Handle)
  2. Driver 同一时刻只被一个线程持有——multi_thread runtime 下它藏在 Parker 的 TryLock<Driver> 里(第 5 章 7 节讲过),哪个 worker 先 try_lock 到就由谁去 epoll_wait,不是固定某个 worker;current_thread runtime 里 Driver 直接放在 Core.driver 字段上,就是主线程自己跑
  3. Handle 被共享到所有需要注册 fd 的地方——它本身不实现 Clone,而是放在 runtime 的 scheduler::Handle(内部是 Arc)里,每个 Registration 都存一份这个 Arc(tokio/src/runtime/io/registration.rs:50);user code、Task 内部、其他系统(Time Driver、Signal Driver)都经由它拿到 I/O Handle

这种 "独占 Driver + 可共享 Handle" 的分离和 Tokio 其他地方完全一致——Runtime vs Handle、Scheduler Core vs Shared 都是同一套思路。


一个工程层面的观察:为什么 Driver 不持有 Handle

注意 Driver 和 Handle 是两个独立的 struct,不是 Driver 嵌套 Handle。这是一个有意识的架构决策:

  • Driver 独占使用——永远只有一个地方(某个 worker 或主线程)在 turn
  • Handle 可共享 + 跨线程——任何代码都能通过 Handle 注册新 fd、wake Driver

如果把两者揉成一个 struct,所有想注册 fd 的地方都得共享它,而 mio::Poll::poll 要的是 &mut self——独占和共享就撞在了一起。拆开之后,Driver 只在 &mut self 下被持有者独占着 turn(multi_thread 里靠 TryLock 保证同一时刻只有一个 worker 拿到它),Handle 则被要求 Send + Sync(tokio/src/runtime/io/driver.rs:83-87 的 _assert_kinds),可以放心地到处共享。

这种"分离可变量和不变量、分离独占和共享"的模式在 Rust 并发数据结构里反复出现——Tokio 源码每层几乎都这么组织。学会辨认它,你读任何复杂 Rust 并发代码都会顺畅。


8.4 Token:mio 和 Tokio 之间的桥

Token 是 mio 里一个看似琐碎但极其关键的概念——它是一个 usize,用户把 fd 注册到 mio 时同时给一个 token、mio 返回事件时把同一个 token 附带回来。Token 的作用就是"从 mio 事件找到用户数据"——mio 不关心 token 是啥(它只把 token 当不透明 id 传回)、由用户自行解释。Tokio 把 token 解释成 ScheduledIo 的地址——拿到 token 就能快速找到对应的 fd 状态机。这种"轻量级 id + 用户维护映射"是系统编程里的经典模式(文件描述符本身就是这种 id)。

mio::Registry::register(&mut source, token, interest) 的 token: Token 是一个 usize wrapper——它是你在调用 register 时给 mio 的"标签",mio 在 events 里返回事件时会把这个 token 还给你,让你找到"这是哪个 fd 的事件"。

其他 mio 用户(比如 pre-async Rust 时代的 rotor crate) 通常这么用:

// 传统 mio 用法
struct MyServer {
    connections: HashMap<Token, TcpStream>,  // 用 HashMap 做 token → 资源的映射
    next_token: Token,
}

fn on_event(&mut self, event: Event) {
    let stream = self.connections.get(&event.token()).unwrap();
    // ...
}

每次 event 处理都有一次 HashMap 查找——这对 hot path 来说不优。

Tokio 用了一个更激进的技巧:把 ScheduledIo 的堆地址直接塞进 Token。

看 turn 方法里的这段(tokio/src/runtime/io/driver.rs:162 起的事件分发循环,节选):

// 来源:tokio/src/runtime/io/driver.rs:162-181(节选:省去 TOKEN_WAKEUP /
// TOKEN_SIGNAL 两个分支)
for event in events.iter() {
    let token = event.token();
    // ...
    } else {
        let ready = Ready::from_mio(event);
        let ptr: *const ScheduledIo = token.0 as *const _;   // ← 关键:token 就是指针
        let io: &ScheduledIo = unsafe { &*ptr };
        io.set_readiness(Tick::Set, |curr| curr | ready);
        io.wake(ready);
        // ...
    }
}

token.0 as *const _——一次类型转换,不查表、不 hash、零额外开销。mio 的 Token 被 Tokio 用作"指针的不透明包装",编码方案分四步(涉及 tokio/src/runtime/io/registration_set.rs:53 的 RegistrationSet::allocate 和 tokio/src/runtime/io/scheduled_io.rs:189 的 ScheduledIo::token):

  • 注册一个 fd 时,RegistrationSet::allocate 里 Arc::new(ScheduledIo::default()) 分配在堆上

  • 取这块堆内存的地址:ScheduledIo::token() 的实现就一行——

    // 来源:tokio/src/runtime/io/scheduled_io.rs:189
    pub(crate) fn token(&self) -> mio::Token {
        // use `expose_addr` when stable
        mio::Token(self as *const _ as usize)
    }
  • 调 registry.register(source, token, interest.to_mio())

  • 后续 epoll 事件返回时,从 token 里直接拿地址,还原成 &ScheduledIo

为什么这安全?

源码里那段 unsafe 上面的安全性注释写得很清楚:

we ensure that the pointers used as tokens are not freed until they are both deregistered from mio and we know the I/O driver is not concurrently polling. The I/O driver holds ownership of an Arc<ScheduledIo> so we can safely cast this to a ref.

拆成两条:

  1. 地址稳定 + 一直有人持有——ScheduledIo 装在 Arc 里,而 I/O Driver 自己就持有一份 Arc(挂在 RegistrationSet 的侵入式链表上),所以哪怕用户那边的 TcpStream 已经 drop 了,这块内存也不会被释放
  2. 释放要等 Driver 确认——PollEvented drop 时会调 Registration::deregister,先从 mio 注销 fd,再把 ScheduledIo 的一份 Arc 塞进 Synced::pending_release(tokio/src/runtime/io/registration_set.rs:26)这个"待释放列表";真正的移除发生在 Driver 下一次 turn 开头的 release_pending_registrations()(调用点 tokio/src/runtime/io/driver.rs:143,函数体 tokio/src/runtime/io/driver.rs:262)——那一刻 Driver 确定自己没在 poll。源码注释直说了这条不变式:"This ensures the ScheduledIo is not freed while it can"、"still be included in an I/O event."(tokio/src/runtime/io/registration_set.rs:24-tokio/src/runtime/io/registration_set.rs:25)

RegistrationSet::deregister(tokio/src/runtime/io/registration_set.rs:71)里还有个细节:pending_release 攒够 NOTIFY_AFTER = 16(tokio/src/runtime/io/registration_set.rs:73)个就返回 true,让调用方 unpark() 一下 Driver,免得一堆待释放的 ScheduledIo 一直挂着。

这是 Tokio 源码最典型的 "用生命周期管理换性能" 的例子——一次 usize 到指针的转换,省掉了 hash map 查找和它带来的 cache miss。


8.5 park() 与 turn():一次 epoll_wait 的完整循环

park() 是 I/O Driver 的"心跳函数"——当所有 worker 都没事做时、调用 park() 就会调用底层 epoll_wait 进入阻塞状态、等 OS 有事件时返回。这个函数背后承载着"让整个进程安静下来不空转"的职责——没有它、Tokio 进程会在空闲时 CPU 占用 100%(一直空转等事件)。turn() 是 park() 和 park_timeout() 共用的内部实现,区别只在传给它的 max_wait: Option<Duration>——park() 传 None(无限等),park_timeout() 传 Some(d)。Time Driver 正是靠这个参数把"下一个定时器还有多久到期"塞给 epoll_wait,让定时器能准时唤醒。

Driver 的核心方法是 park(tokio/src/runtime/io/driver.rs:120)和 turn(tokio/src/runtime/io/driver.rs:140)。代码逻辑照搬源码,只删了空行,注释换成了本专栏的中文说明(原注释从略):

// 来源:tokio/src/runtime/io/driver.rs:120 与 tokio/src/runtime/io/driver.rs:140
pub(crate) fn park(&mut self, rt_handle: &driver::Handle) {
    let handle = rt_handle.io();
    self.turn(handle, None);
}

fn turn(&mut self, handle: &Handle, max_wait: Option<Duration>) {
    debug_assert!(!handle.registrations.is_shutdown(&handle.synced.lock()));

    handle.release_pending_registrations();

    let events = &mut self.events;

    match self.poll.poll(events, max_wait) {
        Ok(()) => {}
        Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
        #[cfg(target_os = "wasi")]
        Err(e) if e.kind() == io::ErrorKind::InvalidInput => {}
        Err(e) => panic!("unexpected error when polling the I/O driver: {:?}", e),
    }

    let mut ready_count = 0;
    for event in events.iter() {
        let token = event.token();

        if token == TOKEN_WAKEUP {
            // mio::Waker 被调用,唤醒这个 turn——不需要做其他事
        } else if token == TOKEN_SIGNAL {
            self.signal_ready = true;   // 标记有 signal 事件
        } else {
            let ready = Ready::from_mio(event);
            let ptr: *const ScheduledIo = token.0 as *const _;
            let io: &ScheduledIo = unsafe { &*ptr };

            io.set_readiness(Tick::Set, |curr| curr | ready);
            io.wake(ready);

            ready_count += 1;
        }
    }

    handle.metrics.incr_ready_count_by(ready_count);
}

这 30 行是 Tokio 的 I/O 心脏。每一次调用做四件事:

1. release_pending_registrations——先处理"等待释放"的队列。注册新 fd 是在调用方线程里直接调 registry.register 完成的(tokio/src/runtime/io/driver.rs:225),延迟的是注销后的释放:已 deregister 的 ScheduledIo 先挂在 pending_release 里(§8.4),Driver 在 turn 开头、确定自己没在 poll 时批量把它们从链表里摘掉。这是延迟批量处理的典型做法。

2. poll.poll(events, max_wait)——真正的系统调用。max_wait = None 表示无限等(直到有事件);Some(duration) 表示最多等多久(Time Driver 用的)。

底层 mio 在 Linux 上调 epoll_wait(2)、macOS 调 kevent(2)、Windows 调 GetQueuedCompletionStatusEx。这条系统调用是 I/O Driver 里唯一"能阻塞"的地方(没抢到 Driver 的 worker 则在自己的 condvar 上睡)——Tokio 的异步原语(Mutex、Semaphore、channel)都不阻塞线程,而是"挂到 Waker 上然后让出"。

3. Interrupted 错误处理——epoll_wait 可能被信号打断返回 EINTR。这不是错,继续跑就行。这是 Unix 系统调用编程的基础约定,Tokio 很标准地处理了。

4. 事件分发循环:

  • TOKEN_WAKEUP = 0(tokio/src/runtime/io/driver.rs:80),TOKEN_SIGNAL = 1(tokio/src/runtime/io/driver.rs:81)——这两个 token 是保留的
  • 其他 token 都是 *const ScheduledIo 地址——每个都对应一个活跃注册

对于普通事件:

  • 从 mio event 拿到 ready flags(readable/writable/error 等)
  • token 转指针 → ScheduledIo 引用
  • set_readiness 更新 AtomicUsize 里的 readiness 位
  • wake 唤醒正在等这个 fd 的所有 Waker

一次 turn 就是一个"等-分发"的闭环。multi_thread runtime 里,某个 worker 在 park 自己时会顺带调这个 turn 函数(第 5 章的主循环 maintenance 环节);current_thread runtime 里,主线程的 block_on 循环在没事做时调 turn。


8.6 ScheduledIo:每个 fd 的状态机

每个注册到 Tokio 的 fd 都有一个 ScheduledIo 实例管理它的状态。这个结构体要处理的复杂度包括:readiness 状态(当前 fd 能读 / 能写 / 错误)、哪个 Task 在等读 / 哪个 Task 在等写、边缘触发 vs 水平触发的语义差异、fd 被 close 后的清理。读这节你会看到 Tokio 如何用一个紧凑的状态机把这些复杂性统一管理。

打开 tokio/src/runtime/io/scheduled_io.rs:

// 来源:tokio/src/runtime/io/scheduled_io.rs:101(`Waiters` 在 tokio/src/runtime/io/scheduled_io.rs:113)
pub(crate) struct ScheduledIo {
    pub(super) linked_list_pointers: UnsafeCell<linked_list::Pointers<Self>>,
    readiness: AtomicUsize,
    waiters: Mutex<Waiters>,
}

#[derive(Debug, Default)]
struct Waiters {
    list: WaitList,
    reader: Option<Waker>,
    writer: Option<Waker>,
}

3 个字段:

  • linked_list_pointers——让 ScheduledIo 可以放进 RegistrationSet 的侵入式链表(第 6 章 Task 的 Trailer.owned 同款设计)
  • readiness: AtomicUsize——当前资源的就绪状态,用一个 AtomicUsize 编码多种信息(下节展开)
  • waiters: Mutex<Waiters>——等待这个 fd 的 Waker 们

Waiters 结构:

  • list: WaitList——通用的 "等待者链表",给 async 版本的 ScheduledIo::readiness(interest) 用(readable().await、ready().await、async_io 等走这条路),任意多个 Task 可以各带一个 Interest 同时挂在上面
  • reader: Option<Waker>——poll_readiness(Direction::Read) 专用的槽位,服务 AsyncRead 这类 poll 式接口
  • writer: Option<Waker>——同上,服务 AsyncWrite

为什么 reader/writer 是独立字段而不是放进 list?链表节点 Waiter 是嵌在 Readiness 这个 Future 里的、靠 Future 被 pin 住才能挂进侵入式链表(所以不用 Box 分配);而 poll_read / poll_write 这种 poll 式方法没有一个能跨 poll 存活的 Future 来放节点——源码注释说它们 "cannot use the async fn version",所以改用预留的 reader / writer 槽(tokio/src/runtime/io/scheduled_io.rs:321-325)。

代价是每个方向只有一个槽:后注册的 Waker 会覆盖前一个。所以 tokio/src/runtime/io/registration.rs:107-108 的注释要求调用方自行保证互斥,并写明 "Only the last task to call this function is notified"。


Mutex 的角色:必须但被最小化

waiters: Mutex<Waiters> 是 ScheduledIo 里唯一需要锁的字段。为什么这里非用 Mutex 不可?

因为 Waiters 是变长数据:

  • WaitList 可能有几百个 waiter
  • Option<Waker> 要原子地 take 出来

Atomic 变量处理不了变长链表——Mutex 是不可避免的。但 Tokio 做了两件事最小化锁的代价:

  1. 锁持有时间极短——任何进锁的代码路径都是"几行 load/store",不做任何可能阻塞的事
  2. fast path 绕开锁——poll_readiness 第一次检查 AtomicUsize(无锁),只有"没 ready 需要注册 Waker"才进 Mutex

也就是说,Future 侧只有"未就绪、要登记 Waker"时才进这把锁;Driver 侧每处理一个事件的 wake 都要拿一次,但锁内只做 take 和链表摘除,真正调用 Waker 放在锁外(§8.8)。

这种"尽量无锁,必须锁的地方把锁的范围压到最小"是 Tokio 到处都有的设计哲学。


8.7 readiness 的 bit packing:又一个 AtomicUsize

和第 3 章 Waker、第 6 章 Task State 一样、readiness 也是多个语义字段塞一个 AtomicUsize。Tokio 源码里这种"位打包"模式出现频率之高让人惊讶——几乎所有 hot path 上的状态都用这个模式。原因简单:一次 CAS 就能原子更新多个字段、避免多次读写之间的竞态窗口。这种风格的代价是可读性差——一串位运算代码需要仔细读才能理解。但对性能敏感系统来说,这个代价值得。

readiness 是 AtomicUsize,里面编码了三种信息:

  • Tick:一个递增的时钟(用来检测 "我保存的 Ready 是不是已经过期了")
  • Readiness flags:READABLE / WRITABLE / READ_CLOSED / WRITE_CLOSED 等位
  • Shutdown 标志:资源是否关闭

具体位分布只有三行——用的是 Tokio 自己的位打包小工具 crate::util::bit:

// 来源:tokio/src/runtime/io/scheduled_io.rs:170-174
const READINESS: bit::Pack = bit::Pack::least_significant(16);  // bit 0..16
const TICK: bit::Pack = READINESS.then(15);                     // bit 16..31
const SHUTDOWN: bit::Pack = TICK.then(1);                       // bit 31

也就是低 16 位放 readiness flags、接着 15 位放 tick、再一位放 shutdown,一共用掉 32 位——64 位平台上 AtomicUsize 还剩一半没用(只用 32 位,同一段代码在 32 位平台上也照样成立)。bit::Pack::then(n) 这个链式写法读起来就像在声明一张位域表,比手写一堆 1 << N 的常量清楚得多。

set_readiness 的 CAS 循环(节选:省略了开头的 debug_assert! 和源码注释,中文注释为本专栏所加):

// 来源:tokio/src/runtime/io/scheduled_io.rs:210
pub(super) fn set_readiness(&self, tick: Tick, f: impl Fn(Ready) -> Ready) {
    let mut current = self.readiness.load(Acquire);
    loop {
        let current_readiness = Ready::from_usize(current);
        let new = f(current_readiness);

        let new_tick = match tick {
            Tick::Set => {
                let current = TICK.unpack(current);
                current.wrapping_add(1) % (TICK.max_value() + 1)
            }
            Tick::Clear(t) => {
                if TICK.unpack(current) as u8 != t {
                    return;   // tick 已经变过,说明有新事件,不要清
                }
                t as usize
            }
        };

        let next = TICK.pack(new_tick, new.as_usize());
        match self.readiness.compare_exchange(current, next, AcqRel, Acquire) {
            Ok(_) => return,
            Err(actual) => current = actual,
        }
    }
}

这段代码精巧在哪:

Tick 的作用——防止用"过期的 Ready"去清掉新的 readiness。想象这个序列:

  • Task A poll → readiness 显示 fd readable(A 同时记下当时的 tick)→ A 去 read
  • A 把 buffer 读空,下一次 read 返回 WouldBlock
  • 就在 A 读空之后、清 readiness 之前,Driver 又收到一个 READABLE 事件(又来新数据),tick 加一
  • A 这时调 clear_readiness 想把 READABLE 清掉

如果没有 Tick,A 会把 Driver 刚置上的新 READABLE 一起清掉——在边沿触发下这个事件不会再报第二次,A 注册 Waker 后就可能一直等下去。有了 Tick,A 带着自己记下的旧 tick 去清,发现 tick 已经变了,这次清除直接放弃,新的 READABLE 得以保留。

每次 set_readiness(Tick::Set, ..)(由 Driver 在收到事件时调):tick += 1,并且是 wrapping_add(1) % (TICK.max_value() + 1)——在 15 位的空间里环绕。 每次 set_readiness(Tick::Clear(t), ..)(由 Future 消费完这次 Ready 后调):先比 TICK.unpack(current) as u8 != t,不等就直接 return,什么都不清——源码注释写着 "Trying to clear readiness with an old event!"。

注意 Tick::Clear(t) 里的 t 是 u8,而 TICK 字段有 15 位——比较时把 unpack 出来的值 as u8 截断了。这意味着理论上 tick 每递增 256 次就会撞上同一个值,但要在 Future 读到 tick 与调 clear_readiness 之间恰好发生 256 的整数倍次 Tick::Set 才会误判,实践中够用。

这是一个完整的 "producer-consumer 版本号协议"——比你想象的 "一个 atomic readiness flag" 精细得多。这类精细度是 Tokio 在"看似简单的 I/O 状态"上能达到工业级正确性的原因。


8.8 wake:把 mio 事件翻译成 Waker 调用

这一步是 I/O Driver 的核心转化点——它把 OS 层面的"fd X 现在可读"信号变成 Rust 异步层的"唤醒 Task Y"调用。OS 事件 → ScheduledIo 状态更新 → 唤醒在等这个 fd 的 Task——这个链路就是 Reactor 模式在 Tokio 里的具体落地。读这节你会看到 Tokio 在这个转化点上的精巧设计:一个事件可能唤醒多个 Task(读者和写者都在等)、多次 wake 要幂等(OS 可能对同一个 fd 多次触发边缘事件)——这些边界情况都要正确处理。

Driver 调 io.wake(ready) 时,具体做什么?下面这段和源码逐句对应,只删掉了空行与注释、把 None 分支压成了一行:

// 来源:tokio/src/runtime/io/scheduled_io.rs:259-309
pub(super) fn wake(&self, ready: Ready) {
    let mut wakers = WakeList::new();
    let mut waiters = self.waiters.lock();

    if ready.is_readable() {
        if let Some(waker) = waiters.reader.take() {
            wakers.push(waker);
        }
    }
    if ready.is_writable() {
        if let Some(waker) = waiters.writer.take() {
            wakers.push(waker);
        }
    }

    'outer: loop {
        let mut iter = waiters.list.drain_filter(|w| ready.satisfies(w.interest));
        while wakers.can_push() {
            match iter.next() {
                Some(waiter) => {
                    let waiter = unsafe { &mut *waiter.as_ptr() };
                    if let Some(waker) = waiter.waker.take() {
                        waiter.is_ready = true;
                        wakers.push(waker);
                    }
                }
                None => { break 'outer; }
            }
        }
        drop(waiters);
        wakers.wake_all();
        waiters = self.waiters.lock();
    }

    drop(waiters);
    wakers.wake_all();
}

5 步逻辑:

  1. 取 reader / writer slot:如果 ready 包含 readable → 取 reader 的 Waker;类似对 writer
  2. 批量扫描 list 里的其他 waiter:过滤出"兴趣匹配"的 waiter、取出它们的 Waker
  3. 关键优化:分批 wake——WakeList(tokio/src/util/wake_list.rs:12)是一个栈上的定长数组,NUM_WAKERS = 32(tokio/src/util/wake_list.rs:5),装满 32 个(can_push 在 tokio/src/util/wake_list.rs:28)就 drop 锁去 wake。避免在 Mutex 锁内调用外部代码(Waker 可能触发任意副作用)
  4. drop 锁后调 wake_all——所有 Waker 被调用,对应的 Task 被推回 scheduler
  5. 循环直到 list 遍历完

为什么要分批:第 6 章讲过"状态转移在原子里做,副作用在原子外做",放到锁上也一样——锁内不做有副作用的外部调用。Waker::wake 可能触发 schedule、spawn、甚至别的 wake——这些操作可能反过来需要我们现在锁着的 Mutex。在锁内调 wake = 可能死锁。

分批模式解决这个问题:收集 N 个 Waker → drop 锁 → wake_all → 重新拿锁继续扫。死锁隐患消失,性能也好(锁的持有时间短)。

这又是一个可迁移模式:写任何"锁内要触发外部回调"的代码时,把回调先收集到 list、drop 锁、再触发。这个模式在整个 async 生态(不只 Tokio)反复出现。


8.9 poll_readiness:Future 侧的对接

poll_readiness 是 Future 侧和 I/O Driver 对接的入口函数——所有 AsyncRead / AsyncWrite 实现最终都会调用它。这个函数的责任是:检查 fd 当前是否就绪、如果就绪返回 Ready、如果未就绪把当前 Waker 存到 ScheduledIo 里并返回 Pending。这个 pattern 在 Rust 异步里无处不在——poll_xxx 系列函数都遵循"就绪返 Ready / 未就绪登记 Waker 返 Pending"的契约。

Driver 负责"外部事件进来",Future 负责"读 Ready 状态"。两者的对接点是 poll_readiness(tokio/src/runtime/io/scheduled_io.rs:326):

// 来源:tokio/src/runtime/io/scheduled_io.rs:326(节选)
pub(super) fn poll_readiness(
    &self,
    cx: &mut Context<'_>,
    direction: Direction,
) -> Poll<ReadyEvent> {
    let curr = self.readiness.load(Acquire);
    let ready = direction.mask() & Ready::from_usize(READINESS.unpack(curr));
    let is_shutdown = SHUTDOWN.unpack(curr) != 0;

    if ready.is_empty() && !is_shutdown {
        // 还没就绪,注册 Waker
        let mut waiters = self.waiters.lock();
        let slot = match direction {
            Direction::Read => &mut waiters.reader,
            Direction::Write => &mut waiters.writer,
        };
        match slot {
            Some(existing) => {
                if !existing.will_wake(cx.waker()) {
                    existing.clone_from(cx.waker());
                }
            }
            None => {
                *slot = Some(cx.waker().clone());
            }
        }

        // 双重检查:上锁期间可能事件已经来了
        let curr = self.readiness.load(Acquire);
        let ready = direction.mask() & Ready::from_usize(READINESS.unpack(curr));
        // ...
        if ready.is_empty() {
            Poll::Pending
        } else {
            Poll::Ready(ReadyEvent { /* ... */ })
        }
    } else {
        Poll::Ready(ReadyEvent { /* ... */ })
    }
}

双检查模式(double-checked):

  1. 第一次检查:无锁读 readiness。如果已经 ready → 直接返回 Poll::Ready
  2. 如果没 ready:上锁,注册 Waker
  3. 注册完立刻再读一次 readiness:因为第一次读完到拿到锁之间,事件可能已经来过——Driver 先 set_readiness、再调 wake 拿锁去取 Waker;如果它的 wake 赶在我们登记 Waker 之前跑完,槽位里是空的,这次唤醒就落空了
  4. 如果第二次检查发现 ready 了:直接返回 Ready,不用等 wake(因为我们刚 miss 掉了)

这是"register & check" 模式的经典形态。Linux 的 futex、Java 的 park/unpark——都是这个模式。没有这个双检查,你就有竞态:在"看见未 ready + 注册 Waker"之间,事件到了,Waker 就永远不会被触发(因为它注册之前事件已经消费了)。

和第 3 章的 will_wake 呼应

看 existing.will_wake(cx.waker()) 这行——第 3 章讲过的 Tokio 全局 VTABLE 设计让这个比对能命中(借出的 Waker 和存下的 Waker 用同一张 vtable)。没有全局 VTABLE,will_wake 会假阴性,这里每次 poll 都会 clone 一次 Waker——高频 poll 的 socket 每次多一次原子加,日积月累就是可观的开销。

架构决策的连锁收益:第 3 章的设计在第 8 章显示了它的价值。


顺便看清 clone_from 这个细节

注意 slot 已经有 Waker 且 will_wake 为假时,源码用的是 existing.clone_from(cx.waker())。别以为它比 *existing = cx.waker().clone() 更省:标准库里 Waker::clone_from 的实现就是 if !self.will_wake(source) { *self = source.clone(); }(rust-1.96.0 library/core/src/task/wake.rs:660-664),同样要 clone 新的、drop 旧的,外加一次重复的 will_wake。真正省下开销的是外面那层 will_wake 判断——相同就什么都不做。

这类地方是读源码时最容易一眼扫过去的——will_wake、unsync_load、take() 而不是 clone(),每一个都在省一点点。fast path 的每一个操作都值得审视,尤其是看起来 O(1) 但实际涉及原子操作、memory barrier、cache miss 的地方。


8.10 一次 TcpStream::read.await 的完整 9 步走位

本节是整章的总集——把前面所有孤立讲的概念串成一条可追踪的完整事件链。一次 read().await 从用户代码到 OS syscall 再回到 Task 被唤醒要经过 9 步——每一步都涉及前面讲过的某个机制(Driver / Token / ScheduledIo / readiness / Waker 注册 / epoll_wait / wake 翻译 / Task 重调度)。把这 9 步用笔画下来、和第 3 章的 Waker 调用路径、第 5 章的调度器主循环对照看——你会对"Tokio 的 I/O 全链路"产生完整的心智模型。

现在把前面所有东西拼起来。一次典型的 read:

let n = stream.read(&mut buf).await?;

Step 1:TcpStream::read 构造 Future AsyncReadExt::read 返回一个 Read<'a, R> Future,它的 poll 调 stream.poll_read(cx, buf)(AsyncRead impl 在 tokio-1.40.0/tokio/src/net/tcp/stream.rs:1326,转手给 poll_read_priv,见 tokio-1.40.0/tokio/src/net/tcp/stream.rs:1285)。

Step 2:poll_read 检查 readiness 链路是 PollEvented::poll_read(tokio-1.40.0/tokio/src/io/poll_evented.rs:169)→ Registration::poll_read_ready(tokio-1.40.0/tokio/src/runtime/io/registration.rs:109)→ Registration::poll_ready(cx, Direction::Read)(tokio-1.40.0/tokio/src/runtime/io/registration.rs:144)→ self.shared.poll_readiness(cx, direction)——注意 Registration 里存 ScheduledIo 的字段叫 shared: Arc<ScheduledIo>(tokio-1.40.0/tokio/src/runtime/io/registration.rs:53)。第一次检查 readiness 这个 AtomicUsize—— READABLE bit 没置位(socket 当前没数据)。

Step 3:注册 Waker 到 waiters.reader poll_readiness 进入 "not ready" 分支,拿 waiters Mutex 锁,把 Waker 存到 waiters.reader(tokio-1.40.0/tokio/src/runtime/io/scheduled_io.rs:326-356)。这里有个和第 3 章呼应的细节:槽位里已经有 Waker 时走的是 existing.clone_from(cx.waker()),而且先用 will_wake 判断,相同就什么都不做——只有槽位为空才 cx.waker().clone()。

Step 4:双重检查 + 返回 Pending 注册完再读一次 readiness(tokio-1.40.0/tokio/src/runtime/io/scheduled_io.rs:359-361)——依然没 READABLE。返回 Poll::Pending。

Step 5:Task 让出,Scheduler 找下一个 Task 或 park Task 的 poll 结束,Task 被标记为 idle(state RUNNING → Idle)。如果 worker 没其他 Task,它就去 park:抢到 Driver 的那个 worker 自己调 Driver::park,没抢到的在 condvar 上睡,由持有 Driver 的 worker 替大家等 epoll(没有独立的 Driver 线程)。

Step 6:park 里 epoll_wait 被触发 Driver::park → self.turn(handle, None)(tokio-1.40.0/tokio/src/runtime/io/driver.rs:120-123),turn 里的 self.poll.poll(events, max_wait)(tokio-1.40.0/tokio/src/runtime/io/driver.rs:149)阻塞在 epoll_wait 系统调用上。CPU 让出给 OS。

Step 7:内核有数据 远端发数据到来 → 内核 TCP 栈把数据放进 socket 的 receive buffer → 内核在 epoll 的就绪列表里标记这个 fd READABLE → epoll_wait 返回。

Step 8:Driver 的分发循环

  • for event in events.iter() 拿到 mio Event(tokio-1.40.0/tokio/src/runtime/io/driver.rs:162)
  • let ptr: *const ScheduledIo = token.0 as *const _ 拿到 ScheduledIo 引用(tokio-1.40.0/tokio/src/runtime/io/driver.rs:172)
  • io.set_readiness(Tick::Set, |curr| curr | ready) 更新 readiness(tokio-1.40.0/tokio/src/runtime/io/driver.rs:180——第一个参数是 Tick::Set,闭包里 or 进去的是本次事件的 ready)
  • io.wake(ready)(tokio-1.40.0/tokio/src/runtime/io/driver.rs:181)拿 Mutex 锁 → waiters.reader.take() → drop 锁 → WakeList::wake_all()(tokio-1.40.0/tokio/src/runtime/io/scheduled_io.rs:259-308)
  • 调到 waker.wake() 的幕后(第 3 章路径)→ Task 被重新入 scheduler 队列

Step 9(回到 Task 侧):Task 被重新 poll Worker 从队列拿到 Task → transition_to_running → 再次 poll Read Future → poll_read → poll_readiness → 这次 readiness 包含 READABLE → 返回 Ready → Future 继续,PollEvented::poll_read 的循环体执行 self.io.as_ref().unwrap().read(b)(tokio-1.40.0/tokio/src/io/poll_evented.rs:188),即真正的 recv 系统调用 → 返回字节数。

整个流程 9 步——每一步都有具体的 Tokio 或内核代码对应。下次你写 .await 时,就是这 9 步在背后跑。

这条路径上真正花时间的地方

不给纳秒数(那要看你的 CPU、内核版本、有没有 spectre 缓解),只指出哪几步的成本量级不同:

  • Step 6 的 epoll_wait 是唯一会真正阻塞的一步,等多久完全取决于对端什么时候发数据——它不是"开销",是"等待"
  • Step 7 在内核里:TCP 栈收包、放进 socket 接收缓冲、把 fd 挂上 epoll 就绪队列、唤醒等在上面的线程。这一段 Tokio 完全不参与,也优化不了
  • Step 1-5 和 Step 8-9 是 Tokio 的部分,全是用户态操作:几次原子 load/CAS、一两次 Mutex 上下锁、一次 Waker 调用、一次入队。除了 Step 9 末尾真正读数据的那次 recv,没有系统调用(Step 8 里如果还要 unpark 别的 worker 来帮忙,那才多一次)

什么情况会让这条路径变慢

  • Step 3 拿 waiters 锁时遇到竞争:多个 Task 关注同一个 fd
  • Step 8 wake 要遍历大量 waiter:一个 fd 被很多 Task 关注时,WakeList(tokio-1.40.0/tokio/src/util/wake_list.rs)会分好几批、反复上下锁——scheduled_io.rs 里那段 drop(waiters); wakers.wake_all(); waiters = self.waiters.lock(); 的循环就是这么写的(tokio-1.40.0/tokio/src/runtime/io/scheduled_io.rs:279-303)
  • Step 9 Task 被调度到另一个 worker:跨核,cache 是冷的

但这些都是边缘情况。正常使用下(一个 fd 一个 reader 一个 writer)走的全是 fast path。


8.11 写自己的 AsyncRead:对接 poll_readiness 的实战模板

如果你想写自己的 AsyncRead 实现(比如一个自定义 I/O 资源),poll_readiness 是核心对接点。它和 Direction 都是 crate 内部的(pub(super)),外部代码碰不到;Tokio 给外部开的口子是 tokio::io::unix::AsyncFd——它的 poll_read_ready 走的正是 Registration::poll_read_ready → poll_readiness 这条路:

// 典型 AsyncRead 实现模板(Unix;tokio 1.40.0 开 net 特性,已用 cargo check 验证)
use std::io::{self, Read};
use std::pin::Pin;
use std::task::{ready, Context, Poll};
use tokio::io::unix::AsyncFd;
use tokio::io::{AsyncRead, ReadBuf};

// 例:把一个已设为非阻塞的 std::net::TcpStream 包成异步读
struct MyResource {
    inner: AsyncFd<std::net::TcpStream>,
}

impl AsyncRead for MyResource {
    fn poll_read(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<()>> {
        loop {
            // 1. 等 readiness:未就绪时登记 Waker、返回 Pending
            let mut guard = ready!(self.inner.poll_read_ready(cx))?;

            // 2. 就绪了,尝试读(非阻塞)
            let unfilled = buf.initialize_unfilled();
            match guard.try_io(|inner| inner.get_ref().read(unfilled)) {
                Ok(Ok(n)) => {
                    buf.advance(n);
                    return Poll::Ready(Ok(()));
                }
                Ok(Err(e)) => return Poll::Ready(Err(e)),
                // 3. WouldBlock:try_io 已清掉这次 readiness,回到循环等下一次事件
                Err(_would_block) => continue,
            }
        }
    }
}

关键点:

  • 先 poll_read_ready:未就绪 → 登记 Waker、向上返回 Pending,等 wake
  • 就绪 → 在 try_io 里做非阻塞读
  • 读到 WouldBlock → try_io 按 §8.7 的 tick 协议清掉这次 readiness,loop 回去重新等。这一步不能省:不清 readiness,poll_read_ready 会一直返回 Ready,循环就变成了忙等

这个模板和 Tokio 自己的 PollEvented::poll_read 是同一个骨架(tokio/src/io/poll_evented.rs:169 起:poll_read_ready → read → WouldBlock 时 clear_readiness;在 epoll/kqueue 上读到的字节少于缓冲区(0 < n < len)时也会提前 clear_readiness,见同文件 218 行与 tokio#5866)——TcpStream、UnixStream、Unix 上的 ChildStdout 都走它(tokio::fs::File 不在此列,它把阻塞读写丢给 spawn_blocking)。学会写它,你就能给任何自定义 I/O 资源做异步包装。


8.12 和这个系列的其他专栏的关联

本章讲的 "把堆地址塞进 usize Token" 技巧,和 《Rust 编译器与运行时揭秘》专栏第 12 章(unsafe) 里讲的指针 ↔ 整数相互转换直接相关。顺带一提,Tokio 源码在这两处都留了同一条注释——token() 里写 "use expose_addr when stable"、turn() 里写 "Use std::ptr::from_exposed_addr when stable"——说明作者很清楚这种往返在 provenance 模型下需要显式的 expose/from_exposed 才算严谨,只是那套 API 当时还没稳定。

readiness 的 Tick + Readiness bit packing,和第 6 章(Task State)的 bit layout 是同一个家族的模式。两章对比读,你会发现 Tokio 源码里"单 atomic 装多字段 + CAS 循环"这个模式至少出现 4-5 次——Task state、IO readiness、AtomicWaker、channel state ... 学会这个模式对整个源码的理解价值巨大。

《Vue 3 设计与实现》专栏第 12 章(生命周期与调度) 讲 Vue 响应式系统的 "标记 dirty → 入队 → 微任务 flush"——和 Tokio 的 "Driver set_readiness → wake → scheduler 重新 poll"同构。两边都是"producer 只标记状态、consumer 稍后统一处理"的模式,也都靠幂等的标记位让重复触发不出错。


8.13 RegistrationSet:跟踪所有活跃的 fd

Handle 里的 registrations: RegistrationSet 是什么?它是 Tokio 自己维护的一个 "活跃 ScheduledIo 链表"——记录所有当前注册到 mio 的 fd。

为什么需要这个?mio 本身不提供"列出所有注册"的 API——注册是单向的(注册了就是注册了,mio 记在它的内部数据结构)。Tokio 需要一个外部视图来管理:

  • Shutdown 时遍历所有 ScheduledIo:Driver::shutdown 从链表里把它们全部取出,逐个置上 SHUTDOWN 位并唤醒所有等待者(tokio/src/runtime/io/driver.rs:130-138、tokio/src/runtime/io/scheduled_io.rs:196-200),让还在等 I/O 的 Task 拿到"runtime 正在关闭"的错误
  • 替 Driver 持有一份 Arc:这就是 §8.4 说的"哪怕用户那边已经 drop,Token 指向的内存也还活着"——链表里的那份引用要等 release_pending_registrations 才放掉

(活跃 fd 数的统计不走这张链表,而是 add_source / deregister_source 里单独加减 metrics 计数,源码在那里留了个 TODO 说以后想挪进 RegistrationSet。)

RegistrationSet(tokio/src/runtime/io/registration_set.rs:10)内部是一个侵入式双向链表(用 ScheduledIo 的 linked_list_pointers 字段链接,tokio/src/runtime/io/scheduled_io.rs:102)——不需要额外分配,每个 ScheduledIo 既是数据又是链表节点。和第 6 章 OwnedTasks、第 7 章 LocalSet 的 owned list 是同一个家族。

为什么用侵入式链表而不是 Vec 或 HashMap

Vec 的问题:删除某个元素需要 O(n) 或 swap_remove(乱序)。Tokio 的 ScheduledIo 频繁增删,Vec 不合适。

HashMap 的问题:需要一个 key(id)——但 ScheduledIo 本身没有自然 id,如果为 HashMap 造一个 ID 还要维护 id → ScheduledIo 的映射,反而复杂。而且 HashMap 每次 insert / remove 都有 hash 计算 + 可能的 rehash,代价不低。

侵入式链表的优势:

  • 增:push_front 头插,O(1)
  • 删:通过已有指针 unlink,O(1)
  • 遍历:顺序遍历,O(n) 但对 shutdown 这种稀有操作够用
  • 零额外分配(链表节点直接嵌在 ScheduledIo 里)

这是 C 风格高性能数据结构在 Rust 里的标准做法。第 6 章、第 7 章、第 8 章三次看到侵入式链表——说明这是 Tokio 源码的 signature pattern。

一个微妙的内存安全问题

侵入式链表涉及 UnsafeCell<Pointers<Self>> + 大量 unsafe 代码——因为 "同一个 ScheduledIo 既被持有指针又被链表引用"。Rust 的借用规则原本不允许——但通过生命周期不变式 + unsafe 代码 + Loom 测试,Tokio 保证了它的正确性。

这类 unsafe 代码是 Tokio 源码里最不容易读懂的部分——初学者很难一眼看懂为什么它对。但如果你带着"链表的每一次增删都在 Handle 里那把 Mutex<Synced> 保护下进行"这个约束去看,会发现代码的每一步都合理。

本专栏不深入这段 unsafe——带着上面这条约束自己读 tokio/src/util/linked_list.rs 即可。


8.14 把 epoll readiness 模型和 Rust async 对齐

上面讲的 Tokio I/O Driver 有一个预设前提:epoll 的 readiness-based 模型和 Rust async 的 pull-based 模型天然对齐。这一节展开一下为什么。

epoll 的语义:

  • "告诉我什么时候 fd X 变成可读 / 可写"
  • 但 "可读"只是一个信号,不代表真的能读(可能内核提示多了、读实际返回 EAGAIN)
  • 应用需要主动 recv / read——epoll 只是提示

Rust async 的语义:

  • Future 被 poll 时要么 Ready 要么 Pending
  • Pending 意味着"我注册了 Waker,事件到了会叫醒我"
  • 被唤醒后再次 poll,还得重新尝试操作(因为状态可能变了)

两者天然匹配:

  • epoll "fd 可读" → wake Task
  • Task 被 poll → 尝试 read → 如果真读到就 Ready,如果 EAGAIN 就清掉 readiness、再 Pending(只是重新登记 Waker 等下一次事件,fd 不用重新注册)

完美的阻抗匹配。

对比 IOCP(Windows 的 completion 模型):

  • "提交一个 read 请求,告诉我什么时候完成"
  • 完成时数据已经在你的 buffer 里了,不用你再 read

这和 Rust async 的 pull 模型就不那么匹配——IOCP 完成时实际上是 "数据已经准备好",更像一个 push 模型。mio 在 Windows 上模拟 readiness 语义(把 IOCP 完成包装成"可以 read"事件),但这有额外开销。

这也是 monoio 这类运行时在 Linux 上选 io_uring 的出发点——io_uring 是 completion 模型,和 IOCP 一致,但可以用 completion 模式更激进地优化(比如零拷贝)。不过 Tokio 选择 readiness + epoll 作为默认,是为了跨平台一致性。

这是 Tokio 的一个主动的技术权衡:用 epoll 方案牺牲在 Linux 上的最后一点性能,换取 Windows / BSD / macOS 也能同样跑。对于它的"通用运行时"定位,这是对的选择。


8.15 数一数 I/O Driver 各条路径上的操作

与其编造纳秒数,不如数一数每条路径上到底做了几件事——这些计数是从源码直接读出来的,不依赖任何机器:

注册一个 fd(Handle::add_source,tokio/src/runtime/io/driver.rs:215):

  1. synced.lock() —— 一次 Mutex
  2. Arc::new(ScheduledIo::default()) —— 一次堆分配(ScheduledIo 带 cache line 对齐,x86_64 上 128 字节起)
  3. registrations.push_front —— 侵入式链表头插,O(1)
  4. registry.register(...)(tokio/src/runtime/io/driver.rs:225)—— 一次 epoll_ctl(2) 系统调用
  5. metrics.incr_fd_count()(tokio/src/runtime/io/driver.rs:236)—— 一次原子加

唯一的系统调用是第 4 步,其余都是用户态。

poll_readiness 已就绪的快路径:一次 readiness.load(Acquire)、两次 bit::Pack::unpack(纯位运算)、一次 Ready 掩码与——没有锁、没有分配、没有系统调用。

poll_readiness 未就绪的慢路径:上面那些,加上一次 waiters.lock()、一次 will_wake 比较(大概率命中,见第 3 章)或一次 Waker::clone_from、再加一次 readiness.load 做双检查。一次 Mutex,零系统调用。

一次 turn 处理 N 个事件:一次 epoll_wait(2) 系统调用,然后每个事件:一次 token.0 as *const _(零成本)、一次 set_readiness 的 CAS 循环(无竞争时一次成功)、一次 wake(一次 Mutex + 若干 Waker 调用)。

这个"数操作"的方法比记纳秒数有用得多——它在任何机器上都成立,而且能直接告诉你优化空间在哪:想让 I/O 更快,要么减少系统调用(这就是 io_uring 的思路),要么减少每个事件的用户态工作(Tokio 已经压到很薄了)。

至于绝对数字,仓库里有现成的基准:benches/rt_multi_threaded.rs、benches/copy.rs,cargo bench 跑一遍就是你自己机器上的真实数据。


8.16 mio::Waker 的跨线程唤醒机制

Handle 里有一个 waker: mio::Waker 字段。这是 mio 提供的一个特殊 "事件源"——允许其他线程通过它触发 Driver 的 epoll_wait 返回。

具体工作原理(Linux 下):

  • mio::Waker 底层是一个 eventfd(2) 或 pipe(2)
  • 注册到 mio::Poll 时用 TOKEN_WAKEUP
  • 其他线程调 waker.wake() → 往 eventfd 写入一个 8 字节的计数值(mio-1.0.1 的 src/sys/unix/waker/eventfd.rs:44)→ epoll 立刻发现这个 fd 可读 → epoll_wait 返回

为什么需要这个?几种场景:

  1. 别的线程 spawn / wake 了一个 Task:要唤醒的 worker 如果正抱着 Driver 阻塞在 epoll_wait 里(永久 block),新 Task 就没人跑——Unparker::unpark 发现对方处于 PARKED_DRIVER 状态时,走的就是 driver.unpark()(tokio/src/runtime/scheduler/multi_thread/park.rs:213),最终落到这个 waker.wake()
  2. 注册了一个更早到期的定时器:epoll_wait 的超时是按"旧的最近到期时间"算的,新定时器更早到期时,Time Driver 要用 waker 打断 I/O Driver,让它按新的截止时间重新等(tokio/src/runtime/time/mod.rs:441-449)
  3. Shutdown:关闭 runtime 时 Driver 需要停下来——用 waker + shutdown 标志位

mio::Waker 和 std::task::Waker 名字相似但完全不同。前者是 I/O Driver 的内部唤醒机制,后者是 async 的公开 API。容易混淆但不能混。

TOKEN_WAKEUP = 0 的分支

回到 turn 的事件循环:

if token == TOKEN_WAKEUP {
    // 什么都不做
}

对 WAKEUP event 什么都不做。因为唤醒的目的已经达到——Driver 从 epoll_wait 返回了。上层 loop 会继续处理 Tokio 调度的其他事情(比如新 Task 的注册、定时器的处理)。token 本身就是信号,不需要 event 携带数据。

这种 "事件即信号、无 payload" 的设计在 OS 事件系统里极常见——比较类似 Unix signal handler。


8.17 上下游的对接:I/O Driver 和 Scheduler 的协作

前面主要讲 I/O Driver 自己。下面看它和 Scheduler 怎么配合。

multi_thread runtime 的对接:

  • 每个 worker 在主循环的 "park" 阶段(第 5 章 7 节)调 Parker::park
  • 同一时刻只有一个 worker 真的在 epoll_wait——机制就是 Parker 里那个 shared: Arc<Shared> 中的 driver: TryLock<Driver>:谁 try_lock 成功谁进 PARKED_DRIVER 去驱动 driver,没抢到的进 PARKED_CONDVAR 在自己的 condvar 上睡
  • 驱动 driver 的那个 worker 回来后,TryLock 释放,下一个要 park 的 worker 就可能接手

current_thread runtime 的对接:

  • 主线程在主循环末尾(event_interval 满或队列空)调 park
  • park 直接走 I/O Driver 的 turn
  • 没有 leader/follower 问题——只有一个线程

Time Driver 和 I/O Driver 的协作:

  • Time Driver 有一个 "下一个定时器到期时间"
  • I/O Driver 的 turn(handle, max_wait) 接受 max_wait 参数——由 Time Driver 提供
  • 意思是:"我要 epoll_wait,但最多等 X 毫秒"——保证 X 毫秒内定时器到期能被处理

这种"I/O 事件和时间事件统一成一次 epoll_wait"的设计是现代事件循环的标准做法——避免 I/O 线程和 Time 线程分开(那样会增加同步开销)。


8.18 处理"信号"事件

看到 TOKEN_SIGNAL 那段了吗?

} else if token == TOKEN_SIGNAL {
    self.signal_ready = true;
}

Tokio 支持异步处理 Unix 信号(SIGINT、SIGTERM、SIGUSR1 等)。Signal Driver 往 I/O Driver 里注册一个特殊的 fd,事件 token 固定为 TOKEN_SIGNAL。

这个 fd 不是 signalfd(2),而是经典的自管道(self-pipe):全局那一对 UnixStream::pair()(tokio/src/signal/unix.rs:58)的读端被 register_signal_receiver 注册进 I/O Driver(tokio/src/runtime/signal/mod.rs:74),而真正装到内核上的信号处理函数只做两件 async-signal-safe 的事——记一个原子标志、往写端 write(&[1])(tokio/src/signal/unix.rs:242 的 fn action)。信号就这样被降级成一次普通的 fd 可读事件,epoll 那边完全不需要知道信号的存在。

为什么要固定 token?因为只有一个 Signal Driver、只用一个 fd。直接硬编码 token = 1 简单清晰。对比 ScheduledIo 的动态指针 token,SIGNAL 是静态的。

signal_ready = true 而不是立刻处理——典型的"把外部事件转化为内部标志位,主循环统一处理"模式。实际处理在 Signal Driver 的单独代码路径里,和 I/O Driver 不耦合。


8.19 I/O Driver 代码演化史简述

Tokio I/O Driver 不是一蹴而就的。简要演化:

Tokio 0.1 时代(2017-2018):基于 futures 0.1 + mio 0.6。I/O Driver 结构比现在复杂、使用一个中心化的 "Reactor" 对象、每个资源有 Token 索引。有一些性能问题(特别是注册 fd 时的锁竞争)。

Tokio 0.2-1.0(2019-2020):随多线程调度器一起重构,I/O Driver 并入 tokio 主 crate(tokio 0.2.0 的 #1792 把 slab 抽到了 tokio::util)。ScheduledIo 这个名字早在 2016 年就有了,这时 Token 仍是 slab 里的索引。

Tokio 1.x:持续细节优化。比如 ScheduledIo 从早期的 slab 索引改成 Arc + 侵入式链表(RegistrationSet),Token 也从此直接存堆地址(tokio 1.30.0,#5833)、引入 WakeList,用未初始化的栈上数组攒 Waker(tokio 1.11.0,#4055)、clear_wakers 修 waker 引用环(issue #3481,Registration::drop 里那段注释就是讲这个的,tokio/src/runtime/io/registration.rs:249)。核心架构没变——说明这一版设计是扎实的。

看得见的方向:io_uring。到 tokio 1.51.1 时,tokio/src/runtime/io/driver/uring.rs 已经作为实验性后端存在于主仓库里(见 §8.20 的实测)——但本专栏基准的 1.40.0 里还没有这个文件,默认后端仍然是 mio 的 epoll/kqueue/IOCP。

读这段历史的意义:软件系统的"最终设计"不是一天得到的。你今天看到的 Tokio I/O Driver 是 多年打磨的产物。如果你自己做一个类似系统,第一版做得像 Tokio 1.0 是几乎不可能的——允许自己从简单版本开始,逐步优化。


8.20 实测:tokio 1.51.1 I/O Driver 子系统 1370 行——比想象薄

把 §8.2-8.9 涉及的 I/O Driver 全部代码在 tokio-1.51.1 的 src/runtime/io/ 实测——

文件 行 角色
scheduled_io.rs 582 §8.6 主角——ScheduledIo 状态机 + Waiters + WaitList(LinkedList<Waiter> 的类型别名,tokio/src/runtime/io/scheduled_io.rs:110)+ readiness bit packing
driver.rs 334 §8.5 Driver::turn() + Handle 实现
registration.rs 259 Registration API:把 ScheduledIo 包成 Future-friendly 接口
registration_set.rs 149 所有活跃 fd 的侵入式链表
metrics.rs 24 I/O 指标
mod.rs 22 公共 export
epoll 路径合计 1370 以上六个文件
tokio/src/runtime/io/driver/uring.rs 309 io_uring 实验性后端(本章未展开)
tokio/src/runtime/io/driver/signal.rs 22 信号驱动
runtime/io/ 全模块 1701 含 uring 与 signal

两条值得记住的物理事实——

  1. 整个 I/O Driver 1370 行——印证 §8.21 take-home "I/O Driver 不是独立子系统、是 Waker 和 Scheduler 之间的翻译员"。作为参照:《Rust 编译器与运行时揭秘》 专栏第 10 章 §10.11.3 实测 std 里 Future trait 所在的 future.rs 只有 135 行,而支撑它的 pin.rs 有 2034 行——最重要的抽象往往最薄、复杂度藏在配套机制里,I/O Driver 也是同一个规律:它只做翻译,真正的重活在内核和 mio 里
  2. scheduled_io.rs 582 行 = I/O Driver 42%——是子系统最重单文件——印证 §8.6 标题"ScheduledIo 是每个 fd 的状态机"——每个 fd 的状态机(readiness AtomicUsize bit packing + Waiters 链表 + reader/writer fast path)需要 582 行表达;turn() 所在的 driver.rs 整个文件反而只有 334 行——按行数,fd 状态机约是驱动循环所在文件的 1.7 倍:驱动循环是薄的、复杂度在每个 fd 的状态机上

uring.rs 309 行的 io_uring 后端是本章未提的板块——Linux 5.1+ 的 io_uring 是 epoll 的下一代异步 I/O 接口、tokio 1.51.1 已加入实验性支持。

放到本专栏的坐标系里看——同一份 1.51.1 源码里,runtime/io/ 全模块 1701 行;作为对照,本专栏第 6 章拆的 runtime/task/ 和第 5 章拆的 runtime/scheduler/multi_thread/ 都是几千行量级。I/O Driver 是 tokio runtime 里相对小的一块——印证 §8.21 "I/O Driver 是薄的翻译员"这个判断。

8.21 本章小结

关键 take-home:I/O Driver 不是"独立的子系统"——它是 Waker(第 3 章)和 Scheduler(第 5 章)之间的一层"翻译员"。OS 事件 → Driver 翻译 → 找到对应 Task → 调用该 Task 的 Waker → Waker 把 Task 重新 schedule → Scheduler 下次循环 poll 这个 Task。整条链路的每一环在前面章节单独讲过、本章把它们串成一条完整链路。

带走三件事:

  1. I/O Driver 是 mio 的薄封装——Driver struct 3 个字段、Handle struct 4-5 个字段。核心方法 turn() 就是一次 mio::Poll::poll + 事件分发
  2. Token 编码为 ScheduledIo 堆地址——避开 HashMap 查找,一次事件处理只用 usize→指针的转换。这是 Tokio 性能关键秘密之一
  3. 每个 fd 有一个 ScheduledIo:readiness(AtomicUsize,Tick + Ready + Shutdown 的 bit packing)+ waiters(Mutex<Waiters>,poll 式接口用 reader/writer 两个槽,async 版 readiness() 用等待者链表)。Future 通过 poll_readiness 注册 Waker、Driver 通过 wake 唤醒 Waker

一个更大的 takeaway

读完前 8 章,你会注意到 Tokio 源码里反复出现几个签名模式:

  • 分离独占和共享(Driver/Handle、Core/Shared、Task/JoinHandle)
  • 单 AtomicUsize 装多字段(Task State、IO Readiness、Waker refcount)
  • 侵入式链表(OwnedTasks、LocalSet tasks、RegistrationSet)
  • vtable + 指针作标识(Task header、Waker vtable、Token)
  • fast path 的"借用语义"版本(WakerRef:poll 时借出 Waker 而不 clone)
  • CAS loop 封装成 helper(fetch_update_action、set_readiness)
  • 分批 wake 避开锁内副作用(Notify、batch_semaphore、ScheduledIo wake 共用的 WakeList)

这七个模式合起来就是 Tokio 的"语法"。一旦你识别它们,Tokio 源码里每一段新代码都变成"我之前见过的某个模式的变体"。这种模式库比任何单个功能的理解更值钱——它能让你在下一个运行时、下一个并发系统、下一个性能敏感的项目里,直接取出对应工具。

读完本章再去看一眼 tokio-console

先说清一个容易想当然的地方:tokio-console 的 "Resources" 视图只列 Tokio 插过桩的资源——1.40.0 里是 Sleep、Interval、Mutex、RwLock、Semaphore、Barrier、oneshot(全树 grep concrete_type = 只有这几处),TcpStream 这类 I/O 资源并不在里面。本章的东西要从两个侧面看:

  • Tasks 视图:每个 Task 的 wake 次数、poll 次数、忙/闲时间。一个读 socket 的 Task 如果"被 wake 很多次但 poll 后大多又返回 Pending",就值得怀疑是不是有 readiness 被反复置上又清掉,或者代码 bug
  • RuntimeMetrics(需 tokio_unstable):io_driver_fd_registered_count / io_driver_fd_deregistered_count / io_driver_ready_count 正是本章 §8.5 incr_ready_count_by 与 §8.15 incr_fd_count 那几个计数

第 17 章会更详细讲 tokio-console 的使用。本章先有个印象。


延伸阅读