Tokio 源码深度解析
第8章 Reactor 与 I/O Driver 架构
本章要点
- Tokio I/O Driver 是 mio 库的薄封装——
Driverstruct 只有 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 的 handleevents: 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_waitmetrics——可观测性
一个典型的部署模式
Runtime 启动时:
Driver::new(nevents)构造出一对(Driver, Handle)- Driver 同一时刻只被一个线程持有——multi_thread runtime 下它藏在
Parker的TryLock<Driver>里(第 5 章 7 节讲过),哪个 worker 先try_lock到就由谁去epoll_wait,不是固定某个 worker;current_thread runtime 里 Driver 直接放在Core.driver字段上,就是主线程自己跑 - 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.
拆成两条:
- 地址稳定 + 一直有人持有——
ScheduledIo装在Arc里,而 I/O Driver 自己就持有一份Arc(挂在RegistrationSet的侵入式链表上),所以哪怕用户那边的TcpStream已经 drop 了,这块内存也不会被释放 - 释放要等 Driver 确认——
PollEventeddrop 时会调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 theScheduledIois 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 做了两件事最小化锁的代价:
- 锁持有时间极短——任何进锁的代码路径都是"几行 load/store",不做任何可能阻塞的事
- 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 步逻辑:
- 取 reader / writer slot:如果 ready 包含 readable → 取 reader 的 Waker;类似对 writer
- 批量扫描 list 里的其他 waiter:过滤出"兴趣匹配"的 waiter、取出它们的 Waker
- 关键优化:分批 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 可能触发任意副作用) - drop 锁后调 wake_all——所有 Waker 被调用,对应的 Task 被推回 scheduler
- 循环直到 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):
- 第一次检查:无锁读 readiness。如果已经 ready → 直接返回 Poll::Ready
- 如果没 ready:上锁,注册 Waker
- 注册完立刻再读一次 readiness:因为第一次读完到拿到锁之间,事件可能已经来过——Driver 先
set_readiness、再调wake拿锁去取 Waker;如果它的wake赶在我们登记 Waker 之前跑完,槽位里是空的,这次唤醒就落空了 - 如果第二次检查发现 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):
synced.lock()—— 一次 MutexArc::new(ScheduledIo::default())—— 一次堆分配(ScheduledIo带 cache line 对齐,x86_64 上 128 字节起)registrations.push_front—— 侵入式链表头插,O(1)registry.register(...)(tokio/src/runtime/io/driver.rs:225)—— 一次epoll_ctl(2)系统调用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返回
为什么需要这个?几种场景:
- 别的线程 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() - 注册了一个更早到期的定时器:epoll_wait 的超时是按"旧的最近到期时间"算的,新定时器更早到期时,Time Driver 要用 waker 打断 I/O Driver,让它按新的截止时间重新等(
tokio/src/runtime/time/mod.rs:441-449) - 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 |
两条值得记住的物理事实——
- 整个 I/O Driver 1370 行——印证 §8.21 take-home "I/O Driver 不是独立子系统、是 Waker 和 Scheduler 之间的翻译员"。作为参照:《Rust 编译器与运行时揭秘》 专栏第 10 章 §10.11.3 实测 std 里
Futuretrait 所在的future.rs只有 135 行,而支撑它的pin.rs有 2034 行——最重要的抽象往往最薄、复杂度藏在配套机制里,I/O Driver 也是同一个规律:它只做翻译,真正的重活在内核和 mio 里 scheduled_io.rs582 行 = 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。整条链路的每一环在前面章节单独讲过、本章把它们串成一条完整链路。
带走三件事:
- I/O Driver 是 mio 的薄封装——Driver struct 3 个字段、Handle struct 4-5 个字段。核心方法
turn()就是一次mio::Poll::poll+ 事件分发 - Token 编码为 ScheduledIo 堆地址——避开 HashMap 查找,一次事件处理只用 usize→指针的转换。这是 Tokio 性能关键秘密之一
- 每个 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.5incr_ready_count_by与 §8.15incr_fd_count那几个计数
第 17 章会更详细讲 tokio-console 的使用。本章先有个印象。
延伸阅读
- Tokio 源码:
tokio/src/runtime/io/driver.rs - Tokio 源码:
tokio/src/runtime/io/scheduled_io.rs - mio 仓库:
tokio-rs/mio - 《Rust 编译器与运行时揭秘》专栏第 12 章:unsafe 与指针 ↔ 整数转换的规则
- 《Vue 3 设计与实现》专栏第 12 章:响应式调度与 Tokio I/O 调度的同构对比