Ch 12 · async/await 基础:Future、Pin、Waker
Rust 异步模型的内部机制
核心问题:Rust 的 Future 跟 C# 的 Task 哪里不一样?为什么需要 Pin?
C# 工程师写 async/await 写了十年,几乎从不考虑 Task 内部如何调度。这套抽象在 C# 工作得好,代价是有一个 .NET runtime。Rust 的设计哲学是"零成本 + 无强制 runtime",所以它的 async 比 C# 复杂得多——但理解之后,你能解释 Tokio、能写自定义 executor、能调试看似魔法的 async bug。
读完你应该能:
- 写出
Futuretrait 的签名,解释Poll::Ready和Poll::Pending各自含义 - 描述编译器把
async fn翻译成什么(状态机) - 解释 Rust Future 为什么是 lazy 的,以及它跟 C# Task 的核心差异
- 看到 Pin 不再发慌,知道它解决的是什么真问题
- 写一个 50 行的
block_on跑通自己的 Future
12.1 Future trait 定义
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> { Ready(T), Pending }
契约
poll 是 Future 唯一的方法。它的含义是:"试着推进一下,看看完成了没"。
- 返回
Ready(value):计算完成,这是结果 - 返回
Pending:还没完,但Future 已经记录了 Waker,等它能继续推进时会通知 executor
executor 不会忙等地反复 poll —— 它只在 Future 自己说"现在可以了"(通过 Waker.wake())时才再次 poll。这是整个 async 模型的核心。
跟 C# Task 对比
| 维度 | C# Task | Rust Future |
|---|---|---|
| 是否立即开始 | 是(hot:创建即调度) | 否(lazy:await 才推进) |
| 谁推进 | TaskScheduler(默认全局 ThreadPool) | 用户选择的 executor |
| 谁分配 | runtime(堆上 Task object) | 编译器(栈上 / 调用方分配状态机) |
| 取消 | CancellationToken,协作式 | Drop Future 即取消(更彻底) |
| 引用语义 | reference type,可多次 await | value type,move-only |
最大差别:Rust Future 是 lazy value,不创建运行时副作用。
12.2 async fn 是编译器生成的状态机
你写:
async fn fetch_two(url1: &str, url2: &str) -> (String, String) {
let a = fetch(url1).await;
let b = fetch(url2).await;
(a, b)
}
编译器翻译成等价(简化)的状态机:
enum FetchTwo<'a> {
Start { url1: &'a str, url2: &'a str },
AwaitingA { f: FetchFuture, url2: &'a str },
AwaitingB { a: String, f: FetchFuture },
Done,
}
impl Future for FetchTwo<'_> {
type Output = (String, String);
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
loop {
match *self {
Self::Start { url1, url2 } => {
let f = fetch(url1);
*self = Self::AwaitingA { f, url2 };
}
Self::AwaitingA { ref mut f, url2 } => match Pin::new(f).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(a) => {
let f2 = fetch(url2);
*self = Self::AwaitingB { a, f: f2 };
}
},
Self::AwaitingB { ref a, ref mut f } => match Pin::new(f).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(b) => {
let out = (std::mem::take(a), b);
*self = Self::Done;
return Poll::Ready(out);
}
},
Self::Done => panic!("polled after completion"),
}
}
}
}
每个 .await 是状态机的一个状态。poll 时:
- 看自己在哪个状态
- 推进:调对应子 Future 的
poll - 子 Future Pending → 自己也 Pending,记 Waker
- 子 Future Ready → 转下一个状态,继续 loop
用 cargo expand 真看一眼
cargo install cargo-expand
cargo expand --bin myapp | less
会看到编译器生成的状态机原貌。第一次看会被吓到,但你只需要看一次就能理解 "async fn 不是魔法,是 syntactic sugar"。
12.3 为什么 Rust Future 是 lazy 的
async fn do_work() {
println!("starting");
// ...
}
fn main() {
let f = do_work(); // ← 这里什么都没发生!不会打印 "starting"
// f 是 Future 值,放在那
}
Rust 选择 lazy 是因为:
- 零运行时:不需要"创建即调度",所以不需要全局调度器。你完全可以写没有 async runtime 的 Rust async 代码(虽然没法 await,但可以 build)
- 取消即 drop:不需要 cancellation token,你直接把 Future 丢掉就停了
- 组合性:lazy Future 可以被
select!/join!/timeout等组合,而不会"已经开始执行"
代价是初学者的迷思:"我的 async fn 没运行!" —— 因为你没 await,也没 spawn。
C# 风格的 hot Task
如果你想要 C# 那种"创建即开始",写:
let handle = tokio::spawn(do_work()); // ← spawn 之后立刻开始
// handle 是 JoinHandle,可以 await 等结果
spawn 把 Future 交给 runtime,runtime 立刻开始 poll。JoinHandle 自己也是 Future,await 它等任务完成。
12.4 .await 的解糖
let x = some_future.await;
约等于(伪代码):
let mut f = some_future;
loop {
match Future::poll(Pin::new(&mut f), &mut cx) {
Poll::Ready(v) => break v,
Poll::Pending => yield, // 把控制权让回 executor,等被唤醒再继续
}
}
yield 不是真关键字——编译器把这个 loop 编入它生成的状态机,实际上是 return Poll::Pending。
Waker 在哪儿注册
poll(cx) 的 cx: &mut Context<'_> 里携带 &Waker。Future 内部需要"等某件事发生"时,把 Waker 存起来,事件发生时调 waker.wake()。
例如 tokio::time::sleep:
- 第一次 poll:计算 deadline,把 (deadline, waker.clone()) 注册到 timer wheel,返回 Pending
- timer 到点:timer 线程取出 waker,
wake() - executor 重新 poll 该 Future:
Ready(())
12.5 Pin 解决的真问题
self-referential struct
考虑编译器生成的状态机:
enum Stateful {
Start,
Reading { buf: [u8; 1024], ref_into_buf: *const u8 }, // 内部自引用!
Done,
}
ref_into_buf 指向同一个结构体内的 buf。如果你 move 这个 struct,buf 在新地址,ref_into_buf 还指向旧地址 → dangling pointer。
这种 self-referential struct 在 async fn 里普遍出现——你 await 一个借用栈上 buffer 的子 Future,编译器生成的状态机就会自引用。
Pin 的设计
Pin<P>(P 是某种指针)的契约:被 Pin 起来后,值的内存地址不再变。
Pin<&mut T>给你&mut T,但你不能用它来mem::swap/mem::replace(会移动)- 一旦 Pin,直到 drop,值都待在原地
只有"地址不变"才让 self-referential struct 内部的指针保持有效。Future::poll 的签名是 self: Pin<&mut Self> 而不是 &mut self,就是因为状态机可能自引用。
Unpin:大部分类型不需要被 Pin
Unpin 是 auto trait:大部分类型实现它,意思是"我没有 self-reference,move 我没事"。u32 / String / 普通 struct 都是 Unpin。
只有"编译器生成的 async 状态机"以及一些手写 unsafe 类型(PhantomPinned 标记)不是 Unpin,需要小心处理。
业务代码里你看到的 Pin
fn do_thing<F: Future + Unpin>(mut f: F) {
let pin = Pin::new(&mut f); // 因为 F: Unpin,可以这样
}
// 大部分时候你这样
async fn outer() {
let f = inner();
f.await; // 编译器自动给你 pin,你完全不用想
}
90% 的应用代码不需要直接接触 Pin。只有写库、写 executor、写自定义 stream 时才需要。
12.6 Waker 与 Context
pub struct Context<'a> { /* ... */ }
impl<'a> Context<'a> { pub fn waker(&self) -> &'a Waker { /* ... */ } }
pub struct Waker { /* ... */ }
impl Waker {
pub fn wake(self); // 消耗 self
pub fn wake_by_ref(&self);
pub fn clone(&self) -> Waker; // 实现 Clone
}
Waker 是什么
Waker 内部是 RawWaker —— 一个 vtable + data 的组合,把"通知 executor 重新 poll" 的实现交给 executor。Tokio 的 Waker 会把当前任务标记 ready,放回任务队列。
谁负责调 wake
等异步事件的那一方——比如:
- timer:到点后唤醒
- IO source:fd ready 时唤醒
- channel:有消息时唤醒发送侧 Waker
如果你写自己的 Future,要在收到事件时主动调 waker.wake()。忘记 wake = Future 永远 pending = 任务僵死。这是写底层 async 库最常见的 bug。
12.7 手撸最简 executor
50 行跑通 block_on(单 Future,无 spawn):
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};
use std::thread;
struct Park(Mutex<bool>, std::sync::Condvar);
impl Wake for Park {
fn wake(self: Arc<Self>) {
*self.0.lock().unwrap() = true;
self.1.notify_one();
}
}
fn block_on<F: Future>(future: F) -> F::Output {
let park = Arc::new(Park(Mutex::new(false), std::sync::Condvar::new()));
let waker = Waker::from(park.clone());
let mut cx = Context::from_waker(&waker);
// 用 Box::pin 在堆上 pin Future
let mut future = Box::pin(future);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => {
let mut woken = park.0.lock().unwrap();
while !*woken {
woken = park.1.wait(woken).unwrap();
}
*woken = false;
}
}
}
}
fn main() {
let result = block_on(async {
// 任何不依赖 IO 的 async 代码都能跑
1 + 1
});
println!("{}", result);
}
支持 spawn / timer / IO 的 executor 还要几百行,Ch 20 一步步搭。
12.8 为什么 Rust 选择"无运行时 by default"
C# / Go / Node.js 都内置 async runtime——你写 async 代码就有一个调度器在跑。Rust 没有,你必须显式选 Tokio / async-std / smol。
设计动机:
- 嵌入式:无 std、无 alloc 的环境也要能写 async(WASM 部分场景)
- 多种 runtime 共存:Tokio 适合服务器,smol 适合 CLI,自定义 executor 适合特殊场景
- 零成本一致性:运行时是"你需要时才付费"
代价是生态分裂:不同 runtime 的 IO 类型不兼容(tokio::TcpStream ≠ async_std::TcpStream)。社区在用 async-io / Trait 抽象慢慢解决,但目前最实用的选择就是 all-in Tokio。
习题
- 用
cargo expand看async fn ten() -> i32 { 10 }编译后是什么。 - 实现一个
YieldFuture:第一次 poll 返回 Pending 并唤醒自己,第二次返回Ready(())。理解为什么这样做相当于"主动让出一次 CPU"。 - 改造 12.7 的
block_on加一个简单的spawn:把任务推到一个Vec<Pin<Box<dyn Future>>>,主循环 poll 所有任务。 - 写一个
SleepFuture:用std::thread起一个 timer 线程,到点 wake。在 12.7 的 executor 上验证。 - 给一个写错的 Future 例子(漏调
waker.wake_by_ref),解释为什么任务僵死。
本章一句话总结
Rust async 不是 magic——它是编译器把 async fn 翻译成状态机,加上一组接口契约(Future / Waker / Context / Pin)。理解这个,Tokio 内部就不再神秘。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |