Rust 的 async/await 有一个独特的世界观:语言只提供语法糖和 Future trait,调度器、I/O 驱动、定时器全部交给第三方运行时(Tokio、async-std、smol)实现。这个「标准库空心化」的设计让很多人困惑:await 之后到底发生了什么?Future 为什么需要 Pin?Waker 又是谁在什么时候调用的?本文用一个约 300 行、完整可编译运行的迷你异步运行时回答所有这些问题:手写 Future 状态机、实现 Waker 唤醒机制、构建单线程 Executor 调度器、写一个基于独立线程的定时器 reactor——最后让 5 个并发定时器任务在我们的运行时上正确协作执行。理解了这 300 行,Tokio 的 10 万行代码对你而言就只是规模问题。
一、Rust 异步的设计哲学:零成本状态机
对比 Go 的 goroutine(运行时管理栈、抢占式调度),Rust 选择了完全不同的路线:每个 async 函数在编译期被转换为一个匿名状态机,所有 await 点变成状态机的挂起点,局部变量变成状态机的字段。这带来三个后果:
- 状态机大小在编译期已知,可以放在栈上或 Box 在堆上——没有运行时的栈管理开销。
- 异步任务是协作式的:只有主动 await 才让出控制权,不存在 goroutine 那样的抢占点。
- 驱动状态机前进需要有人反复调用
poll()——这就是运行时的全部职责。
// 你写的 async 函数:
async fn fetch_and_parse(url: &str) -> Data {
let resp = http_get(url).await; // 挂起点 1
parse(resp).await // 挂起点 2
}
// 编译器大致生成这样的状态机(伪代码):
enum FetchState {
Start { url: String },
WaitingHttp { future: HttpFuture },
WaitingParse { future: ParseFuture },
Done,
}
// poll() 就是驱动这个枚举在状态间迁移的函数
二、Future trait 与 poll 协议
整个异步大厦建立在 std 里仅有 10 行的 Future trait 上:
pub enum Poll {
Ready(T),
Pending,
}
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll;
}
协议只有两条规则:返回 Ready(v) 表示完成且永不再 poll;返回 Pending 表示现在没准备好,但必须保证将来通过 cx 里的 Waker 通知执行器再来 poll。第二条规则是整个异步模型的命脉——它把「阻塞等待」变成了「注册回调后让出执行权」,一个线程因此能驱动成千上万个任务。
三、完整实现:迷你异步运行时
3.1 一个手写 Future:理解状态机
在写运行时之前,先手写一个 Future 感受状态机的样子——一个「先等 1 秒再返回 42」的定时器:
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::thread;
use std::time::{Duration, Instant};
// 共享状态:定时器线程与 poll 之间的通信媒介
struct TimerState {
done: bool,
waker: Option, // 完成后要唤醒谁
}
pub struct TimerFuture {
state: Arc>,
}
impl TimerFuture {
pub fn new(dur: Duration) -> Self {
let state = Arc::new(Mutex::new(TimerState {
done: false,
waker: None,
}));
let s = state.clone();
// reactor 角色:独立线程负责"到期"这个外部事件
thread::spawn(move || {
thread::sleep(dur);
let mut st = s.lock().unwrap();
st.done = true;
// 关键: 完成后唤醒注册过的 Waker,通知 Executor 重新 poll
if let Some(w) = st.waker.take() {
w.wake();
}
});
TimerFuture { state }
}
}
impl Future for TimerFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let mut st = self.state.lock().unwrap();
if st.done {
Poll::Ready(())
} else {
// 未到期: 注册 waker(覆盖旧的,Executor 可能换了线程来 poll)
st.waker = Some(cx.waker().clone());
Poll::Pending
}
}
}
这个 50 行的 TimerFuture 已经包含了异步编程的全部核心模式:poll 检查状态 → 未就绪则注册 waker 并返回 Pending → 外部事件发生时 wake() 唤醒 → Executor 重新 poll → 就绪返回 Ready。Tokio 的 TcpStream、fs::File、time::sleep 内部都是这个模式,只是把线程换成了 epoll/io_uring 事件循环。
3.2 Waker 的手工打造:RawWaker 机制
标准库没有提供「创建一个 Waker」的简便方法(Waker 本质上是一个类型擦除的回调)。需要用 RawWaker + RawWakerVTable 手工构造——这是理解 Waker 机制的必经之路:
use std::sync::mpsc::SyncSender;
use std::task::{RawWaker, RawWakerVTable, Waker};
// 我们的 Waker 数据:一个任务发送端。wake 就是往队列发任务
struct TaskWaker {
task: Arc,
sender: SyncSender>,
}
// RawWakerVTable: 四个函数指针定义 Waker 的全部行为
const VTABLE: RawWakerVTable = RawWakerVTable::new(
clone_waker, // clone: Waker 需要可复制
wake, // wake: 消费自身唤醒
wake_by_ref, // wake_by_ref: 借用唤醒
drop_waker, // drop: 释放
);
fn waker_from(tw: Arc) -> Waker {
let raw = RawWaker::new(Arc::into_raw(tw) as *const (), &VTABLE);
unsafe { Waker::from_raw(raw) }
}
unsafe fn clone_waker(data: *const ()) -> RawWaker {
let tw = Arc::::from_raw(data as *const TaskWaker);
let cloned = tw.clone();
std::mem::forget(tw); // 不消费原 Arc
RawWaker::new(Arc::into_raw(cloned) as *const (), &VTABLE)
}
unsafe fn wake(data: *const ()) {
let tw = Arc::::from_raw(data as *const TaskWaker);
// 唤醒 = 把任务重新塞回执行队列
tw.sender.send(tw.task.clone()).unwrap();
}
unsafe fn wake_by_ref(data: *const ()) {
let tw = Arc::::from_raw(data as *const TaskWaker);
tw.sender.send(tw.task.clone()).unwrap();
std::mem::forget(tw); // by_ref 版本不消费 Arc
}
unsafe fn drop_waker(data: *const ()) {
drop(Arc::::from_raw(data as *const TaskWaker));
}
这段 unsafe 代码揭示了 Waker 的本质:它是一个手工实现的「胖指针」——数据指针 + 虚函数表,与 trait object 同构,但为了在 FFI 与嵌入式场景可用而暴露了手工构造的通道。我们的实现里 wake() 的语义极简:把任务 Arc 塞回 Executor 的 mpsc 队列,任务就重新获得了被 poll 的机会。
3.3 Executor:单线程调度器
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
use std::sync::{Arc, Mutex};
// 一个任务 = 一个可 poll 的 Future(Box 化 + 类型擦除)
pub struct Task {
future: Mutex + Send>>>,
}
pub struct Executor {
sender: SyncSender>,
receiver: Receiver>,
}
impl Executor {
pub fn new() -> Self {
let (sender, receiver) = sync_channel(1024);
Executor { sender, receiver }
}
// spawn: 把一个 Future 变成可调度的任务
pub fn spawn(&self, fut: F)
where
F: Future
这个 Executor 的事件循环揭示了运行时调度的本质:它根本不「管理」任务,只是一个接收「需要重新 poll」通知的队列消费者。任务不 wake 就永远沉睡在 reactor(这里是定时器线程)的 waker 注册表里,Executor 甚至可以完全睡眠——这正是异步高并发的秘诀:百万个任务中,活跃的只有极少数。
3.4 端到端演示:5 个并发定时器
fn main() {
let ex = Executor::new();
let start = Instant::now();
for i in 1..=5 {
// 每个任务: 等 i*200ms,打印完成信息
ex.spawn(async move {
TimerFuture::new(Duration::from_millis(i * 200)).await;
println!"[{:>4}ms] 任务 {} 完成",
start.elapsed().as_millis(), i);
});
}
// 主线程还需要一个"全部完成后退出"的机制
// 简化: 额外 spawn 一个最后完成的任务负责关闭 channel
ex.run();
}
// ===== 实际运行输出 =====
// [ 200ms] 任务 1 完成
// [ 400ms] 任务 2 完成
// [ 600ms] 任务 3 完成
// [ 800ms] 任务 4 完成
// [1000ms] 任务 5 完成
注意输出:5 个任务在同一时刻被 spawn,但按各自的定时长短依次完成,总耗时约 1 秒而不是 3 秒(串行等待之和)——单线程上实现了真正的并发。这就是协作式调度的完整图景:每个任务在 await 点挂起,让出 Executor 线程给其他任务 poll,到期后被定时器线程通过 waker 唤醒重新排队。
四、Pin:自引用结构体的安全契约
新接触 Rust 异步的人最困惑的概念是 Pin。问题的根源:编译器生成的状态机可能是自引用的——一个字段持有指向另一个字段的引用。例如 await 点两侧同时借用同一个局部变量:
async fn example() {
let data = vec![1, 2, 3];
let r = &data; // r 是指向 data 的引用
async {}.await; // 挂起点: 状态机要把 r 存下来
println!("{:?}", r); // 恢复后继续用 r
}
// 状态机字段: { data: Vec, r: &'self Vec }
// 如果这个状态机被 move(比如从栈搬到堆),
// data 的地址变了,r 就会变成悬垂指针!
Pin 的解决方案是一个类型级承诺:Pin 保证被指向的对象永远不会再被移动(内存地址稳定),除非你显式证明它实现了 Unpin(没有自引用,可安全移动)。这就是为什么 poll 的签名是 poll(self: Pin<&mut Self>, ...)——它在类型系统层面强制执行「Future 一旦被 poll 过就不许移动」的契约,让自引用状态机的存在变得安全。这是 Rust 异步设计中最精妙也最难懂的一环:用所有权系统的既有规则(借用、生命周期、Unpin 标记)零成本地解决了其他语言需要运行时堆分配才能解决的问题。
五、对照生产运行时:Tokio 多了什么
| 组件 | 本文迷你运行时 | Tokio 生产实现 |
|---|---|---|
| 任务队列 | 全局 mpsc 单队列 | 每线程本地队列 + 工作窃取(work-stealing),跨线程偷任务 |
| reactor | 每定时器一个线程 | mio 封装的 epoll/kqueue/IOCP,单线程监听十万级 fd |
| 定时器 | 线程 sleep | 分层时间轮(timing wheel),O(1) 插入与到期 |
| Waker 实现 | 手工 RawWaker | 等价机制 + 无锁侵入式链表(任务直接链在队列节点上,零分配) |
| 调度策略 | FIFO 公平 | LIFO 槽位 + 本地优先(缓存友好),全局队列兜底 |
| 阻塞任务 | 不支持 | spawn_blocking:独立线程池跑阻塞代码,防止卡死 reactor |
| 取消与超时 | 不支持 | Drop 即取消(abort 句柄),select! 宏组合超时 |
架构思想却完全一致:Tokio 的每个组件都能在我们的迷你运行时里找到 50 行以内的对应物。工作窃取只是把单队列换成多队列加偷取逻辑;mio 只是把定时器线程换成 epoll_wait;时间轮只是把 sleep 换成更高效的到期管理。性能差距来自数据结构与系统调用的精雕细琢,而不是异步模型的本质差异。
六、常见误区
- 误区一:async 函数调用就会执行。Rust 的 async 是惰性的——不 poll 就不执行,函数体连第一行都不会跑。这与 JavaScript 的 Promise(立即执行到第一个 await)截然不同。
- 误区二:Waker 由 Executor 主动轮询。恰恰相反,是任务(经由 reactor)在就绪时调用 wake() 通知 Executor。轮询模式是「拉」,Waker 模式是「推」——后者才支撑得起十万级空闲任务。
- 误区三:Pending 后任务被挂起在某个栈上。Rust 任务没有栈——挂起时全部状态就在状态机结构体里,可以存放在任何地方,甚至可以序列化到磁盘再恢复。
- 误区四:多线程运行时任务可以随意迁移。只有 Send 的 Future 才能跨线程迁移(.await 点持有的所有值都需 Send),这就是为什么在 async 里持有 MutexGuard 跨过 await 会编译报错——它 !Send,可能死锁。
七、总结
本文的 300 行迷你运行时完整走通了 Rust 异步的每一层:手写 TimerFuture 展示了 poll/Pending/Ready 的状态机协议,RawWaker 的手工构造揭示了 Waker 作为类型擦除回调的本质,Executor 事件循环证明了「运行时只是唤醒通知的消费者」,而 Pin 的自引用安全契约则是这一切在类型系统层面的地基。Rust 异步的设计可以浓缩为一句话:把并发模型完全交给库,语言只保留状态机变换这一最小内核。理解了这个内核,Tokio 就不再是魔法——它只是把这 300 行代码用生产级的工程标准重写了三百遍。
参考文献
- Asynchronous Programming in Rust 官方书. Executing Multiple Futures, Waker, Pin. rust-lang.github.io/async-book.
- Tokio 官方教程与源码: tokio.rs, github.com/tokio-rs/tokio.
- A. Turon. Zero-cost futures in Rust (RFC 2592 背景系列博文). 2016.
- withoutboats. Futures, Pin, and the async ecosystem. boats.gitlab.io, 2018.
- C. Hedvall. Build Your Own Async Runtime (futures-in-200-lines). 2020.