阶段二 · 异步语言机制

异步运行时原理

一句话总结

Future 自己不会跑,需要**运行时(runtime)**来驱动.
运行时由两半组成:**执行器(executor)**负责调度任务,调用 poll;**反应器(reactor)**对接操作系统的 epoll,在 IO 就绪时通过 waker 唤醒对应任务.
这套东西,就是 Rust 版的"Go runtime",只不过是外置的库.

为什么 Future 需要运行时

前面反复强调:Future 是惰性的,不被 poll 就什么都不做.那么--谁来 poll 它?谁在 IO 就绪时把它重新唤醒?谁来管理成千上万个任务谁先谁后?

这些"驱动 Future 前进"的活儿,没有任何语言内建机制承担(回顾第 01 篇:Rust 故意不内建运行时).于是必须有一个外部组件来做,这就是异步运行时.Tokio 就是其中最主流的一个.

Future 是一份份"待办工单"(描述了要做什么,做到哪了),但工单不会自己执行.
运行时是调度中心 + 施工队:它拿起工单 poll(推进施工),工单卡在等材料(IO)时把它挂起,材料到货(IO 就绪)再喊它回来接着干.

运行时的两半:执行器与反应器

把运行时拆开,核心是分工明确的两个部件:

部件 职责 类比
执行器 Executor 持有任务队列,从中取出就绪任务并调用其 poll,推进状态机 调度中心 / CPU 侧
反应器 Reactor 对接 epoll/kqueue,登记 IO 关注,IO 就绪时触发对应任务的 waker IO 监听站 / 第 02 篇的事件循环

反应器其实就是第 02 篇讲的"事件循环 + epoll"在运行时里的化身;执行器则是新角色,负责"该 poll 谁,什么时候 poll".waker 是连接这两半的桥梁.

waker:把"就绪"翻译成"重新调度"

第 03 篇说过:Future 返回 Pending 前,要把 Context 里的 waker 登记到它等待的事件源上.现在可以看清这条链的全貌了:

1. 执行器 poll 任务 A

2. A 发起读 socket,未就绪 → 把 A 的 waker 登记给反应器(epoll)
│ 返回 Pending
3. 执行器把 A 搁置,转去 poll 其他就绪任务(不空等)

⋮ (一段时间后,socket 数据到达)

4. 反应器的 epoll_wait 返回 → 调用 A 的 waker.wake()

5. wake() 把任务 A 重新放回执行器的就绪队列

6. 执行器再次 poll A → 这次读成功 → 继续推进

所以 waker.wake() 的本质动作是:把这个任务标记为"可以再 poll 了",塞回执行器的就绪队列. 它就是"IO 就绪"这个事件,翻译成"请重新调度该任务"这个动作的转换器.

现在三篇串成了一条线

  • 第 02 篇:操作系统层的 epoll 就绪通知 → 对应反应器.
  • 第 03/04 篇:Future 状态机 + poll + waker 登记 → 被执行器驱动.
  • 本篇:执行器 + 反应器 + waker 三者协作,构成完整运行时.

最小心智模型:block_on

最简单的执行器只做一件事:反复 poll 一个 Future,直到它 Ready.这就是 block_on 的核心,伪代码如下(帮助理解,非真实实现):

fn block_on<F: Future>(mut fut: F) -> F::Output {
loop {
match fut.poll(cx) {
Poll::Ready(val) => return val, // 完成,返回结果
Poll::Pending => {
// 没就绪:在此挂起线程,直到 waker 通知"可以再 poll 了"
park_until_woken();
}
}
}
}

真实的执行器要复杂得多(要同时管理海量任务,跨线程调度),但内核思想不变:poll → 没好就等 waker → 被唤醒再 poll.第 04 篇的 #[tokio::main],做的就是"创建运行时,然后 block_on async main".

和 Go runtime 的对照

现在可以把 Rust 运行时与 Go runtime 精确对齐了:

能力 Go runtime(内建) Rust + Tokio(外置库)
任务调度 内建 G-M-P 调度器 Tokio 执行器(executor)
IO 就绪监听 内建 netpoller Tokio 反应器(reactor,基于 mio)
唤醒机制 runtime 内部完成 waker
是否可选/可换 不可,焊死在语言里 可选,可换 async-std,smol 等

Go 把发动机焊死在车里,拧钥匙就走,但换不了.
Rust 把发动机做成可拆卸模块(Tokio 是销量最大的那款),自己选,自己装--多一道安装手续,换来嵌入式到服务器的全场景适配能力.

一个直接的现实后果

因为运行时是外置的,Rust 异步生态里大量库会"绑定某个运行时".比如很多网络库基于 Tokio 编写,项目就得用 Tokio.选运行时是项目早期的一个真实决策.

好在 Tokio 已是事实标准,绝大多数场景跟着它走即可.本系列后续全部基于 Tokio.

单线程还是多线程?

执行器可以只用一个线程跑事件循环(适合任务少,或想避免跨线程开销),也可以用一个线程池,把任务分散到多个线程上,配合工作窃取(work-stealing)调度.后者能同时利用多核,是服务器场景的默认选择.

这正呼应第 01 篇的结论:异步与多线程是叠加的--多线程执行器 + 海量异步任务.Tokio 默认就是多线程执行器.具体怎么配置,怎么 spawn 任务,下一篇正式上手.

快速回顾

  • 为何需要运行时:Future 惰性,需有人 poll,唤醒,调度;Rust 不内建,故用外置运行时(Tokio).
  • 两半结构:执行器(调度 + poll 任务)+ 反应器(对接 epoll,IO 就绪触发 waker).
  • waker:把"IO 就绪"翻译成"将任务塞回就绪队列,重新 poll",连接执行器与反应器.
  • block_on 心智:poll → Pending 就等 waker → 被唤醒再 poll,直到 Ready.
  • 对照 Go:执行器 ≈ 调度器,反应器 ≈ netpoller,waker ≈ 内部唤醒;区别是 Rust 运行时可选可换.
  • 单/多线程:执行器可单线程或多线程池;服务器默认多线程,与异步叠加利用多核.

动手练习

  1. 描述职责:用自己的话分别描述执行器和反应器的职责,并指出 waker 在两者间的作用.
  2. 对应部件:把第 02 篇的"事件循环"对应到本篇的哪个部件,说明原因.
  3. 理解 block_on:照着 block_on 伪代码,解释"为什么它在 Pending 时不会忙轮询".
  4. Go 对照:列出 Go runtime 的三个组件,与 Tokio 的对应部件一一配对.
  5. 预习:查阅 Tokio 文档,找出"多线程运行时"和"当前线程运行时"的构造方式(为下一篇预习).