Appearance
第2章 Future trait 与 poll 模型回顾
"The Rust async model is a conspiracy between three minimal primitives. Understand the conspiracy, and the rest is engineering." —— 笔者
本章要点
std::future::Futuretrait 的真实定义一共只有 2 行有效代码:type Output和一个poll方法。本章会告诉你这 2 行是如何撑起整个 Rust 异步世界的- Poll 是一个二元枚举——
Ready(T)和Pending——但这二元的语义极端不对称:Ready是承诺,Pending是契约 - Context 不等于 Waker,它是 Waker 的"信封"——现代 Rust(1.82 起)的 Context 里已经有
waker、local_waker、ext三个字段,这种"信封"设计让运行时可以悄悄加料而不破坏 API 稳定性 .await在表面上看起来像 Python 的yield,但它本质上不是协程原语,而是一个"状态机跳转 + 重新 poll"的语法糖。理解这一点是理解 Tokio 的分水岭- 我们会手写一个最简单的 Future,跑通它,然后故意制造一个"忘记 wake"的 bug——只有亲手让 Future 卡死过,你才会真正明白 Waker 不是装饰
2.1 整个 Rust 异步世界,建立在一个 trait 上
打开 core/src/future/future.rs,拨开所有的 #[stable]、#[lang]、#[diagnostic] 这些属性和文档,你会看到这个被所有人天天用、但极少人认真读过的 trait 真实的样子:
rust
// 来源:rust-lang/rust · library/core/src/future/future.rs
// 移除了属性注解,保留全部有效结构
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}就这么多。2 行有效代码——一个关联类型 Output,一个方法 poll。
但每一个符号都是经过战争锤炼的。
让我们逐个拆解。
type Output
这是这个 Future 最终会产出的值的类型。比如:
tokio::fs::read_to_string(...)的返回类型是impl Future<Output = std::io::Result<String>>async fn foo() -> u32 { ... }展开后是一个impl Future<Output = u32>tokio::time::sleep(Duration::from_secs(1))是impl Future<Output = ()>
关联类型比泛型参数 Future<T> 更精确:一个 Future 只会产出一种类型,类型不应该是调用者指定的,而应该是 Future 自己决定的。
rust
// 如果 Future 用泛型参数 T,用户会以为可以"选"类型
// trait Future<T> { fn poll(...) -> Poll<T>; } // 这是反例
// 用关联类型:类型由 Future 实现决定,调用者无法选
trait Future {
type Output; // 这才是对的
fn poll(...) -> Poll<Self::Output>;
}这是一个在 Rust 类型系统里已经被讨论了十年的老话题:"用关联类型还是泛型"。对于 Future 这个 trait 来说,结论是明确的:输出类型是 Future 的属性,不是调用者可配置的选项。
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>
这一行里每一个细节都值得展开:
第一处:self: Pin<&mut Self> 这不是普通的 &mut self。Pin<&mut Self> 的含义是"我给你一个可变引用,但你承诺不会 mem::swap 或 std::mem::replace 这个 Future、不会把它从当前内存位置移走"。
为什么需要这个承诺?因为 async fn 展开后的状态机经常包含自引用(后面 2.5 节会细讲),一个自引用结构体一旦被 move,内部指针就悬垂了。Pin 是类型系统强加的"不准 move"协议。
第二处:cx: &mut Context<'_> 运行时(Tokio)调用 poll 时,会把一个 Context 传进来。Context 里最重要的东西是 Waker——Future 用来"告诉运行时我还没好、好了会通知你"的回调句柄。&mut 是因为 Context 可以被访问内部可变状态(后续版本可能扩展)。'_ 是一个匿名生命周期,表示 Context 不会比当前 poll 调用活得更久。
第三处:返回类型 Poll<Self::Output> 返回一个 Poll 枚举:要么 Ready(value)——我完成了,这是结果;要么 Pending——我没完成,运行时你先去干别的,我准备好了会通过 Waker 告诉你。
整个方法的语义压缩成一句话:
"运行时,你现在 poll 我一次。我能给你结果就立刻给(Ready);给不了我就把 Context 里的 Waker 存起来,完事了通过它通知你(Pending)。"
这 2 行代码就是 Rust 异步生态的根宪法。Tokio、smol、monoio、embassy——所有运行时都围绕这个 trait 展开。
为什么 Rust 选了这种模型
这个问题值得专门说一下,因为它解释了 Tokio 后续一切设计决策的源头。
市面上的语言对异步有好几种答案:
- JavaScript:
Promise——一个有状态的对象,内部已经在跑,.then()是注册回调。这是推式(push)模型:值 ready 了 runtime 把它推给回调 - Go:goroutine + channel——语言级运行时包办一切,用户代码看起来是同步的
- Python:
async def+ event loop——类似 JS,但语法上贴近同步代码;await是挂起点 - C#:
Task<T>+async/await——和 JS 类似,Task 是"正在计算的东西",await 是等待
Rust 走了一条和以上都不同的路——拉式(pull)模型:
- Future 不主动做任何事,它只是一个状态机
- 运行时主动 poll 这个状态机,问"你好了吗?"
- 如果状态机给
Pending,运行时拿到 Waker,等到有唤醒信号再 poll 一次
为什么要这么设计? 三个原因:
- 零成本抽象。Future 本身是一个栈上结构体,不需要堆分配、不需要引用计数、不需要 runtime 强制 wrap
- 运行时可选。既然 Future 是个被动的状态机,你可以在嵌入式、WASM、no_std 环境下自己实现一个最小的"poll 循环"
- 细粒度控制。拉式模型让运行时可以决定什么时候、在哪个线程、以什么优先级 poll 一个 Future。Tokio 的 work-stealing 调度正是建立在这个能力之上
这个选择的代价是学习曲线陡峭——因为 Future 是被动的,你写 async fn foo() { do_stuff().await; },直观上这像"foo 在某处开始跑了",但实际上 foo 什么都没跑。只有当某个 runtime.block_on(foo()) 或 tokio::spawn(foo()) 把它交给运行时 poll,它才开始"动"。
这个直觉反转是所有 Rust 异步 bug 的第一类根源。下一节我们用最简单的例子把这个反转建立牢固。
2.2 Poll:二元枚举里藏着整个契约
Future 的 poll 方法返回 Poll<T>。它的真实定义极其简洁:
rust
// 来源:rust-lang/rust · library/core/src/task/poll.rs
// 省略属性注解
pub enum Poll<T> {
Ready(T),
Pending,
}两个变体。但这两个变体的语义重量不对称到令人惊讶的程度。
Ready(T) —— 一次性的终结承诺
当一个 Future 的 poll 返回 Ready(value),它在表达一个终结性的承诺:
"这个 Future 的任务已经完成。这是结果。以后不要再 poll 我了。"
标准库在 Future trait 的 doc comment 里写得明明白白:
"Once a future has finished, clients should not
pollit again.""Once a future has completed (returned
Readyfrompoll), calling itspollmethod again may panic, block forever, or cause other kinds of problems."
这是一个契约,不是建议。Tokio 的 Task 内部会专门设置状态位防止 Ready 后再被 poll;tokio::select! 宏会从分支集合里移除已 Ready 的 Future;futures::future::FutureExt::fuse() 提供一个包装器把"多次 poll 一个已 Ready 的 Future" 变成一直返回 Pending 而不 panic——这些都是围绕"Ready 后就不该再 poll"这条契约展开的工程措施。
Pending —— 带"尾巴"的契约
比起 Ready,Pending 的语义要重得多。它不是"我没好,你等会儿再来问"这种随口一说。Pending 是一个带尾巴的契约:
"我现在不能给你结果。但是,在我说 Pending 之前,我已经确保:要么我能自己推进(比如注册到 I/O Driver、设置定时器),要么我已经把 Context 里的 Waker 克隆出来存好。当我能继续的时候,我会调用那个 Waker。 你(运行时)不需要轮询我——我会通知你。"
注意那个"要么... 要么..."。一个 poll 方法返回 Pending 但没有做任何上述事情,会导致这个 Future 永远不再被 poll——因为没有人会唤醒它。这类 bug 的症状是"程序卡住,CPU 占用 0%",在生产环境极难诊断。
我们在 2.4 节会故意制造一次这个 bug,让你亲手感受。
为什么不是三态?
一个自然的疑问:为什么 Poll 只有 Ready 和 Pending 两态?为什么不像有些异步语言一样,有第三态 Progress(部分完成)、Interrupted(被中断)、或 WouldBlock(类似 Unix 系统调用)?
答案是:这些状态在 Rust 的模型里都可以被编码进 Ready 的类型参数里。
- "部分完成" →
Ready(PartialResult),下次再 poll 一个新 Future 继续 - "错误" →
Ready(Err(...)),把 Result 放进 Output - "被中断" →
Ready(Err(InterruptedError)) - "WouldBlock" → 等价于
Pending——你告诉运行时 Waker,运行时等 I/O 可读/可写再叫你
二态是最小完备集。增加任何新态都会打破简洁性,而现有的类型参数 T 已经能承载所有复杂语义。
这种"把复杂塞进类型系统、把语义塞进枚举"的设计,是 Rust 类型系统的典型风格——也是和 Go runtime "把复杂藏进语言运行时"的风格最根本的差别。
Poll 的常用方法
Poll 虽然只是一个枚举,但它上面有一些方便的方法,在 Tokio 源码里高频出现,值得记住:
rust
// 摘自 library/core/src/task/poll.rs 的关键方法签名
impl<T> Poll<T> {
pub fn is_ready(&self) -> bool;
pub fn is_pending(&self) -> bool;
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Poll<U>;
}还有对 Result 和 Option<Result> 的特化:
rust
impl<T, E> Poll<Result<T, E>> {
pub fn map_ok<U, F: FnOnce(T) -> U>(self, f: F) -> Poll<Result<U, E>>;
pub fn map_err<U, F: FnOnce(E) -> U>(self, f: F) -> Poll<Result<T, U>>;
}
impl<T, E> Poll<Option<Result<T, E>>> {
// 支持 ? 操作符
}最后一个是为了让 ready!(...) 宏(来自 futures crate)和 ? 操作符能在 Poll<Result<T, E>> 上工作——Tokio 源码里无处不在地这样写:
rust
// 伪代码示意
fn poll_something(cx: &mut Context<'_>) -> Poll<io::Result<usize>> {
let n = ready!(self.inner.poll_read(cx))?; // 遇到 Pending 立刻返回 Pending;遇到 Err 用 ? 上抛
Poll::Ready(Ok(n))
}ready! 宏展开后差不多是:
rust
match expr {
Poll::Ready(v) => v,
Poll::Pending => return Poll::Pending,
}这两个小工具你一定会在 Tokio 每一个 I/O 函数里看到。记住它们的语义,你读后面章节的源码时就不会被这种"短路式控制流"绊住。
2.3 Context 与 Waker:双层抽象的秘密
Future 的 poll 方法第二个参数是 cx: &mut Context<'_>。但你真正要用的其实是 Context 内部的 Waker。为什么多一层?
看一下 Context 在 2026 年(Rust 1.82+)的真实定义:
rust
// 来源:rust-lang/rust · library/core/src/task/wake.rs
// 字段顺序与源码一致
pub struct Context<'a> {
waker: &'a Waker,
local_waker: &'a LocalWaker,
ext: AssertUnwindSafe<ExtData<'a>>,
_marker: PhantomData<fn(&'a ()) -> &'a ()>,
_marker2: PhantomData<*mut ()>,
}原来 Context 里不止有 Waker。它还有:
local_waker:一个不要求Send + Sync的 Waker 变体,用于单线程运行时(如 current_thread scheduler)里的优化路径。Rust 1.83 开始稳定ext:一个扩展槽,运行时可以往里塞任意数据。这个字段在 Rust 1.82 加入,目的是给未来的 API 扩展留空间——比如未来可能加"current runtime handle"之类的东西
而在 2019 年 Rust 1.36 刚稳定 async/await 时,Context 只有一个字段 waker: &'a Waker。为什么当时不直接传 &Waker?
答案在 Rust 异步稳定化过程的 RFC 里讲得很清楚:信封设计是为了 API 的前向兼容。
- 如果
poll签名是fn poll(self: Pin<&mut Self>, waker: &Waker) -> Poll<...>,那么将来如果运行时想传额外的东西(比如local_waker),就得改 trait 签名——这是一个生态级的 breaking change - 如果
poll签名是fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<...>,将来往Context里加字段只需要加方法,不改 trait 签名。现有的所有 Future 实现照常工作
这是一个典型的"用一层间接换取 ABI 稳定性"的设计。今天回头看,这个决策被完美验证了:Context 在 2022 年加了 local_waker,2024 年加了 ext,中间没有任何一次 breaking change。整个生态的 Future 实现从来没被动过。
Context 当前的主要方法:
rust
impl<'a> Context<'a> {
pub const fn from_waker(waker: &'a Waker) -> Self;
pub const fn waker(&self) -> &'a Waker;
pub const fn local_waker(&self) -> &'a LocalWaker; // unstable but usable
}作为 Future 的实现者,你 99% 的时候只会用 cx.waker() 拿到 &Waker,然后 .clone() 它存起来。
Waker 的真实 ABI
Waker 本身是一个极薄的包装:
rust
// 来源:rust-lang/rust · library/core/src/task/wake.rs
#[repr(transparent)]
pub struct Waker {
waker: RawWaker,
}
pub struct RawWaker {
data: *const (),
vtable: &'static RawWakerVTable,
}
pub struct RawWakerVTable {
clone: unsafe fn(*const ()) -> RawWaker,
wake: unsafe fn(*const ()),
wake_by_ref: unsafe fn(*const ()),
drop: unsafe fn(*const ()),
}注意三个细节:
Waker是#[repr(transparent)]的单字段 struct,内存布局等同于RawWaker(两个指针:data和vtable)——所以一个 Waker 总共就是 16 字节(在 64 位平台)- Waker 的全部行为通过 vtable 间接调用——
clone、wake、wake_by_ref、drop都是函数指针。这让不同运行时可以有完全不同的 Waker 实现,但共用同一个 ABI unsafe fn:这些 vtable 函数都是 unsafe 的,因为调用者必须保证data指针的有效性。这把"Waker ABI 的安全边界"明确画在了运行时实现者身上
这个 vtable 设计完全等价于 C++ 的虚函数表、Rust 的 trait object(dyn Trait),但它手动实现而不是用 dyn Waker,有几个工程上的原因:
- Waker 需要
Send + Sync、Clone,但 trait object 的dyn Waker + Send + Sync + Clone组合在早期 Rust 里受限 - 手动 vtable 可以更紧凑(两个指针 vs
Box<dyn ...>的胖指针 + 堆分配) - 可以在
#[no_std]环境下使用
Tokio 的 Task 结构体内部,就实现了自己的一套 RawWakerVTable(第 3 章会把这份 vtable 拆到字节级)。现在你只需要记住:Waker 是运行时 → Future 的回调句柄,它的实现是运行时私事,调用方(Future)只管用 waker.wake() / waker.clone() / waker.wake_by_ref() 三个安全接口。
Waker 的三个方法,对应三种使用场景
rust
impl Waker {
pub fn wake(self); // 消耗 Waker 并唤醒(常用于 oneshot 场景)
pub fn wake_by_ref(&self); // 不消耗 Waker 并唤醒(常用于循环场景)
pub fn will_wake(&self, other: &Waker) -> bool; // 判断两个 Waker 是否会唤醒同一个任务
}
impl Clone for Waker { /* ... */ }
impl Drop for Waker { /* 调 vtable.drop */ }wake:一次性场景。oneshot channel 的Sender::send拿走 Waker 后就直接 wake 掉——反正这个任务只等这一次wake_by_ref:broadcast channel、MPSC channel 这种"同一个任务可能被多次唤醒"的场景,用wake_by_ref避免 clonewill_wake:这个方法极其重要。Future 每次poll收到的Context里的 Waker 可能是同一个 Task 的(运行时内部的),但在"Future 被 move 到另一个 Task 里"的罕见情况下可能不同。Future 应该在每次poll时比对cx.waker().will_wake(&stored_waker),如果不匹配就重新 clone 一份存起来
will_wake 的不匹配情况你在日常业务代码里几乎碰不到,但写一个正确的 Future 实现必须处理它——否则如果运行时把你的 Future 换了个 Task 跑,你保存的旧 Waker 会唤醒错的任务、新任务永远卡死。
Tokio 的所有内置 Future 都处理了这个 case。手写 Future 时如果你不打算让它被"跨 Task 移动",可以忽略——但至少要知道这个坑在哪。
2.4 手写一个最小 Future:把抽象变成肌肉记忆
光讲理论没用。我们真的来写两个 Future,跑起来,并且故意制造 bug 让你看懂 Pending 契约的分量。
例 1:Ready<T> —— 最平凡的 Future
rust
// 一个立即返回 Ready 的 Future
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct Ready<T>(Option<T>);
impl<T> Future for Ready<T> {
type Output = T;
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
// 拿出内部的值;如果已经被 take 过说明被二次 poll 了
let this = self.get_mut();
let value = this.0.take().expect("Ready polled after completion");
Poll::Ready(value)
}
}
// 构造函数
pub fn ready<T>(value: T) -> Ready<T> {
Ready(Some(value))
}这个 Future 的特点:
- 永远不返回 Pending,因此不需要用 Waker
- 第一次
poll就返回Ready(value) - 第二次
poll会 panic——符合"Ready 后不要再 poll"的契约
这种 Future 在 Tokio 源码里极其常见,常用于快速路径:比如缓存命中时 async fn 可以直接返回 ready(cached_value),不走任何 I/O。
例 2:Counter —— 一个故意踩 Pending 陷阱的 Future
现在做一个需要多次 poll 才能完成的 Future。假设我们想实现"poll 三次后才 Ready":
rust
// 第一版:**错误演示**——违反 Pending 契约
pub struct BrokenCounter {
remaining: u32,
}
impl Future for BrokenCounter {
type Output = ();
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<()> {
let this = self.get_mut();
if this.remaining == 0 {
Poll::Ready(())
} else {
this.remaining -= 1;
Poll::Pending // ← 致命错误:没有注册 Waker,没人会再叫醒这个 Future
}
}
}如果你把这个 Future 放进 tokio::spawn,结果是:第一次 poll 返回 Pending,然后——永远卡住。
Tokio 的 scheduler 看到 Pending,就把这个 Task 从 ready 队列里移走了。它在等 Waker 被调用。但你的 Future 压根没把 Waker 存下来、也没告诉任何人"我在等什么"。于是 Task 就永久躺在 Tokio 的 Task 池里,不被 poll、不被 drop(除非 runtime 关闭)、占着一份内存——这是 Rust 异步最典型的"卡死"bug。
正确的写法必须遵守 Pending 契约——返回 Pending 之前要么能自驱、要么留下 Waker:
rust
// 第二版:**正确**——立即 wake 让自己下一轮再被 poll
use std::task::Waker;
pub struct Counter {
remaining: u32,
}
impl Future for Counter {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let this = self.get_mut();
if this.remaining == 0 {
Poll::Ready(())
} else {
this.remaining -= 1;
// 告诉运行时"我还没好,但我准备好了"——通过立即 wake 自己
cx.waker().wake_by_ref();
Poll::Pending
}
}
}这个版本的行为是:每次 poll 消耗一次 remaining,然后立刻 wake 自己让 runtime 下一轮再 poll——直到 remaining 归零返回 Ready。
但这个模式在真实代码里几乎一定是反例——它本质上是"主动忙轮询",等价于一个 while !done { yield; } 的死循环,CPU 会被占满。真实的 Future 应该等外部事件(I/O 可读、定时器到期、channel 有数据)才 wake——wake 不是 Future 自己调的,是 I/O Driver / Time Driver / channel 的 Sender 调的。
真正有用的 Counter 版本是把 wake 托付给一个定时器:
rust
// 第三版:正确且实用——等 10 毫秒再 wake 一次
use tokio::time::{sleep, Duration, Sleep};
pub struct TickingCounter {
remaining: u32,
sleep: Pin<Box<Sleep>>, // 托付给 Tokio 的 Time Driver
}
impl Future for TickingCounter {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let this = self.get_mut();
loop {
// 先 poll 当前的 sleep;没到时间就返回 Pending——
// Time Driver 在 10ms 后会 wake 我们(Waker 已被 sleep 注册到 Time Driver)
match this.sleep.as_mut().poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(()) => {
this.remaining -= 1;
if this.remaining == 0 {
return Poll::Ready(());
}
// 装一个新的 sleep,进入下一轮
this.sleep = Box::pin(sleep(Duration::from_millis(10)));
}
}
}
}
}这个例子第一次让你直面 Tokio 运行时的三角关系:
- Future(
TickingCounter):被 poll 的状态机 - Driver(
Time Driver):管理真实的定时器,负责"到时间了要叫醒某个 Task" - Scheduler:把被 wake 的 Task 重新安排给 worker 线程去 poll
wake 从来不是 Future 自己调的。wake 是 Driver 在某个外部事件发生时调的。Future 在返回 Pending 之前,要么自己 wake(退化为忙轮询,几乎总是错的),要么把 cx.waker().clone() 交给某个 Driver、某个 channel 的 Sender、某个 Mutex 的等待队列——让外部在合适的时机来 wake 这个任务。
Pending 契约的本质就是这一点:你返回 Pending 的同时,必须有一个"外部的手"拿着你的 Waker。
三个版本对照给你的肌肉记忆
| 版本 | 行为 | 问题 | 用在什么场景 |
|---|---|---|---|
BrokenCounter | Pending 后无人 wake | 永久卡死 | ❌ 绝对不要 |
Counter(自 wake) | Pending 后立即自 wake | CPU 100% 忙轮询 | ⚠️ 仅在"极少量迭代 + 想主动让出"场景 |
TickingCounter | Pending 后由 Time Driver wake | 真实的异步等待 | ✅ 绝大多数场景 |
把这张表刻在脑子里。以后每当你写出一个返回 Pending 的 poll 实现,立即反问自己:**这个 Waker 谁来调?什么时候调?**回答不出来,就是在写 BrokenCounter。
2.5 async fn 展开:编译器替你做的那一百行
到这里你可能想说:但我平时根本不手写 Future,我只写 async fn!
好消息是一切 async fn 都会被编译器展开成一个实现了 Future trait 的匿名结构体——也就是说,你写的每一个 async fn,在 LLVM IR 眼里都是一个 Future。2.1 节讲的契约全都适用。
考虑这个例子:
rust
async fn fetch_and_parse(url: &str) -> Result<Data, Error> {
let resp = http_get(url).await?; // 第一次 await 点
let body = resp.read_body().await?; // 第二次 await 点
parse(body)
}编译器把它展开成什么?大致是这样一个状态机:
rust
// 编译器生成的伪代码(示意,真实代码更复杂)
enum FetchAndParseState {
Start { url: &str },
WaitingHttp { http_fut: HttpGetFuture },
WaitingBody { resp: Response, body_fut: ReadBodyFuture },
Done,
}
struct FetchAndParseFut { state: FetchAndParseState }
impl Future for FetchAndParseFut {
type Output = Result<Data, Error>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<Data, Error>> {
loop {
match &mut self.state {
FetchAndParseState::Start { url } => {
// 启动第一个 await 的子 Future
let fut = http_get(url);
self.state = FetchAndParseState::WaitingHttp { http_fut: fut };
}
FetchAndParseState::WaitingHttp { http_fut } => {
match Pin::new(http_fut).poll(cx) {
Poll::Pending => return Poll::Pending, // ← 关键
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Ready(Ok(resp)) => {
let body_fut = resp.read_body();
self.state = FetchAndParseState::WaitingBody { resp, body_fut };
}
}
}
FetchAndParseState::WaitingBody { body_fut, .. } => {
match Pin::new(body_fut).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Ready(Ok(body)) => {
let result = parse(body);
self.state = FetchAndParseState::Done;
return Poll::Ready(result);
}
}
}
FetchAndParseState::Done => unreachable!("polled after completion"),
}
}
}
}三个关键观察:
一、每一个 .await 是一个"可能的返回点" 编译器把 async fn 的每个 .await 切成状态机的状态转移边界。poll 到 .await 时,如果子 Future 返回 Pending,整个 async fn 立刻返回 Pending;下次这个 async fn 被 poll,从保存的 state 恢复、从上次 yield 的位置继续。
二、Pending 会向上冒泡 子 Future 的 Pending 直接成为外层 Future 的 Pending。整条调用链上任何一个节点 Pending,整条链都 Pending。但 Waker 不需要传递——因为 cx 本身是一路传下去的,子 Future 把 Waker 存下来之后,wake 的时候直接唤醒最外层 Task,Task 再从头 poll 整条链。
三、本地变量被编译器装进了 enum 的字段里url、resp 这些在 .await 之间需要跨越的本地变量,被编译器打包进状态机的某个 variant。比如 resp 要活过第二个 .await,它就被存进 WaitingBody variant。这就是为什么 async fn 展开后的状态机常常是自引用的:如果 resp 内部有个指向 body_fut 的指针(比如 body_fut 是从 resp.read_body() 拿到的),这个指针和 resp 自己在同一个 enum variant 里,move 一下整个 Future 就指向野地了。Pin 因此而存在。
这套展开机制是 Rust 异步的真正魔法。它被详细拆解在**《Rust 编译器与运行时揭秘》第 9 章(async/await 的状态机展开)和第 10 章(Pin、Waker、Future 的运行时协作)里,从 AST → HIR → MIR 的完整路径都画了出来。如果你想看编译器的具体实现**(MIR coroutine transformation pass 做了什么、状态机大小是怎么算的、为什么 async fn 展开后偶尔会有惊人的大小),请翻那两章。本书在运行时侧假定你已经理解"async fn ≈ 一个实现了 Future 的匿名状态机",不重复拆解编译期。
2.6 .await 在更大的图里
把 2.5 的状态机视角和 2.4 的 Waker 契约拼起来,你就能看清一次 .await 的全景:
async fn 外层 子 Future 内层 运行时
| | |
| 1. poll 被 spawn 后调 ─────→ | |
| | 2. I/O 未就绪 |
| | 注册 Waker 到 I/O Driver ──→ (存下)
| | |
| ←─── 3. 子返回 Pending ──────| |
| |
| 4. 外层返回 Pending ──────────────────────────────────────→ (Task park)
| |
| | 5. 内核 epoll_wait 返回
| | 对应 fd 可读
| | 6. Driver 查表找到 Waker
| | 7. Waker.wake() ──→ Task 入队
| |
| 8. Task 再次被 poll ─────→ | |
| | 9. 子 Future 读到了数据 |
| ←─── 10. 子 Ready(data) ────── |
| 11. 外层继续执行下一句 |10 步,每一步都有具体的代码位置。后续章节会把每一步钻透:
- Step 2 的"注册 Waker 到 I/O Driver"—— 第 8 章
- Step 4 的"Task park"—— 第 6 章
- Step 5 的"epoll_wait"—— 第 9 章
- Step 6-7 的"Waker 激活 Task"—— 第 3 章
- Step 8 的"Task 再次 poll"—— 第 5 章
本章你只需要记住:.await 不是一个魔法。它是"状态机 yield + Waker 注册 + 运行时调度"这三者精心协作的结果。每一个 .await 的时刻,都至少有一份 Waker 被运行时的某个角落保存着——理解这个,你就有了读 Tokio 源码的钥匙。
2.7 为什么 .await 表面像 yield,本质完全不同
学过 Python 的读者常常把 .await 类比成 yield——"挂起当前协程,把控制权交还给调度器"。这个类比在感觉层是对的,但在机制层会误导你。
Python 协程(async def)的执行模型:
- 协程本身是一个独立的栈(实际上是 generator frame 链)
yield/await是真的中途挂起——本地变量保留在协程栈里,下次恢复从挂起点继续- 协程之间切换是由 event loop 调度
- 每个协程有独立的 Python 帧栈
Rust async fn 的执行模型:
- 没有独立栈。
async fn展开后是一个扁平的状态机结构体,所有跨.await的本地变量被打包进 enum variant .await不是真的挂起——它是一个状态机的状态转移 + 向上返回 Pending- 下次 poll 时,状态机从头运行
poll方法,根据 state 跳到上次的分支继续 - 同一个 runtime 的所有 Future 共享运行时分配的栈(worker 线程栈)
具体差异带来什么?
- 内存开销:Python 每个协程独立栈(即使小也是若干 KB);Rust 每个 Future 只占状态机结构体大小(常见几十到几百字节)
- 栈溢出风险:Rust 异步不会因为"协程太深"栈溢出(所有 Future 在堆或调用方栈里是线性展开的),但会因为"Future 太大"撑不下——一个 Future 超过 MB 级的话会警告甚至错误。第 6 章会讲 Tokio 的
Box::pin策略 - 可组合性:Rust Future 可以相互嵌套、被
Box<dyn Future>包起来、被 select! 聚合,而且每一层都没有栈切换开销。Python 协程需要 event loop 显式管理 - 取消(cancellation):Rust Future 的取消就是把它
drop——因为状态机结构体被释放,所有本地变量被正常析构。这是 Rust 异步最被低估的优势之一。Python 要做协程取消需要专门的异常机制
理解这一点后,你对 Tokio 的很多设计决策会豁然开朗:
tokio::spawn(future)返回一个JoinHandle,而不是像 Go 那样 "go foo()" 没有返回值——因为 spawn 后你仍然需要一个句柄可以取消、可以等结果tokio::select!可以同时等多个 Future,一个 Ready 了,其他的 Future 被 drop(取消)——Rust Future 的"一次 drop 完成取消"是这个语法成立的前提tokio::time::timeout(duration, future)本质上是一个"如果定时器先 Ready 就 drop 里面的 future"——没有栈切换,没有异常
这套模型的优势是运行时可以极其轻薄、状态机结构体可以放进 no_std 环境、取消是零成本的 drop。代价是写手写 Future 时必须严格遵守 Pin 和 Waker 契约——因为运行时不会替你处理任何细节。
2.8 Future 的两种"世界"
合上本章之前,留给你一个心智模型——Future 实现者眼里的两种世界:
世界 A:叶子 Future —— 被动等外部事件的世界
叶子 Future 是直接和运行时 / I/O / 定时器打交道的 Future:
tokio::net::TcpStream::readtokio::time::sleeptokio::sync::oneshot::Receiver
这些 Future 的 poll 方法里,你会看到显式的 Waker 注册代码——cx.waker().clone() 然后存到某个 Driver 的等待队列里。叶子 Future 是"异步魔法"的真正发生地。
世界 B:组合 Future —— 被编译器生成的世界
组合 Future 是 async fn 展开出来的状态机:
- 你写的每一个
async fn fetch_user(id: u64) -> User { ... } async { ... }代码块future.map(...).and_then(...)组合器
这些 Future 的 poll 方法是编译器生成的,它们内部不直接操作 Waker——它们只是把 cx 透传给内部的子 Future(通常是叶子 Future 或更小的组合 Future)。
两种世界的分工:
- 世界 A 负责"实实在在地等某个外部条件"——它们是异步性的来源
- 世界 B 负责"把多个等待串起来"——它们是异步性的组合
Tokio 源码的 90% 在实现世界 A 的各种叶子 Future。本书后面大部分章节都在拆这些叶子 Future——第 10 章的 TcpStream、第 11 章的 sleep、第 13 章的 mpsc::Receiver、第 15 章的 JoinHandle——每一个都是"显式和 Waker / Driver 打交道"的经典例子。
在你的业务代码里,99% 写的是世界 B。这完全没问题——绝大多数时候你不需要手写叶子 Future。但你必须知道你依赖的那些 .await 点,底下都是世界 A 在干活。看不到、但存在。
2.8½ Future 的 size:为什么 Tokio 有时要 Box::pin
async fn 展开出来的状态机结构体并不小。它需要装下所有跨 .await 的本地变量、所有子 Future、所有栈上分配的 buffer。一个朴素的 async fn 经过几层嵌套之后,状态机可能就膨胀到几 KB 甚至更大。
这在什么时候会变成问题?
- 递归
async fn:如果一个async fn递归地调用自己(比如遍历一棵树),状态机类型是递归的——Rust 编译器直接报错"cyclic type",除非你用Box::pin(recur_fn())做类型擦除 - Future 被塞进
Vec、HashMap等容器:容器需要统一大小,所以必须Box<dyn Future<Output = T>> - Future 在极端场景大到超过线程栈:少见但真实发生过,特别是含有大数组或大状态的复杂
async fn。这时tokio::spawn(Box::pin(fut))把状态机搬到堆上可以避免栈爆
Tokio 源码里时不时会出现 Box::pin(future) 的写法——这通常是"我不能静态知道 Future 的类型"或者"我需要把它装进某个统一的接口"。但日常业务代码绝大多数时候不需要 Box::pin——#[tokio::main] 和 tokio::spawn 自己会处理好。
第 6 章讲 Task 结构时会看到一个具体数字:Tokio 的 Task header 大约 64 字节(引用计数、状态位、vtable 指针等),而 Task 内部托管的 Future 大小由用户代码决定,Tokio 对此不做假设、把 Future 存在紧随 header 之后的内存里,整个 Task 是一次堆分配。所以一个 Future 的 size 不仅影响栈,也影响 spawn 时的堆分配成本。这种"把 header 和 Future 放在同一块内存、用 unsafe + 精确的 offset 计算索引"的技巧在 Tokio 源码里是标志性设计,第 6 章会把这段 unsafe 代码逐行拆给你看。
2.9 Send + Sync + 'static —— Future 上最容易卡人的三重标记
如果你写过 Tokio 的业务代码,几乎肯定遇到过这种报错:
error: future cannot be sent between threads safely
|
| tokio::spawn(async move { ... });
| ^^^ future is not `Send`
note: future is not `Send` as this value is used across an await或者更绕一点的版本:
error: `Rc<Foo>` cannot be sent between threads safely
= help: within `impl Future`, the trait `Send` is not implemented for `Rc<Foo>`这类错误初学者普遍的第一反应是"Rust 太严苛了"。但这不是严苛,这是 Future trait 的 poll 契约在多线程场景下的自然推论。把这个推论讲透,你以后不会再被这类错误拦住,也不会写出"绕过它"的糟糕代码。
从 tokio::spawn 的真实签名开始
打开 Tokio 1.40 源码 tokio/src/task/spawn.rs,你会看到这样的签名:
rust
// 来源:tokio-rs/tokio · tokio/src/task/spawn.rs(为了聚焦已删减属性和文档)
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
// ...
}三个 bound:Future + Send + 'static,外加 Output: Send + 'static。每一条都有明确的工程理由。
为什么要 Send? multi_thread runtime 下,一个 Task 可能在 worker-0 上被 poll 一次(返回 Pending),然后在 worker-3 上被再 poll 一次(通过第 5 章要讲的 work-stealing)。这意味着 Future 的内存可能从 worker-0 的线程局部位置"move"到 worker-3 上。Send 是"可以跨线程安全传递"的类型系统标记——缺了它,编译器根本不让你 spawn。
为什么要 'static? Task 一旦被 spawn,它的生命周期由 Tokio 管理,外部代码无法保证"这个 Future 内部引用的东西会活得和 Task 一样久"。为了避免悬垂引用,Tokio 强制 Future 不能持有任何非 'static 的借用。这不是 Tokio 吹毛求疵——如果允许 spawn(fut: &'a F) 的话,一旦 caller 线程退出、a 活到头,Task 就持有一个悬垂指针。类型系统在这里用 'static 堵死了这条路。
为什么 Output 也要 Send + 'static? 因为 JoinHandle<F::Output> 可能被另一个任意线程 .await——Task 的返回值必须能跨线程传递、且不能借自 spawn 时的临时上下文。
为什么不要 Sync?
细心的读者会发现 spawn 要求 Send 但不要求 Sync。原因简洁:一个 Task 同一时刻只被一个 worker poll——没有两个线程同时持有它的 &mut,也就不存在并发读 &T 的情况。Sync("多个 &T 可以并发读")这个条件对于一个被独占 poll 的 Future 是多余的。
但当你用 futures::future::shared::Shared(把一个 Future 变成可 clone、多个 Task 共享结果)这种特殊包装时,shared Future 的内部才需要 Sync——因为同一个 Future 会被多个 Task 并发读。Tokio 的日常 API 不走这条路,所以 spawn 不强加 Sync。
这是一个典型的"约束最小化"设计:只要求解决当前问题所必需的、不多加一个。Rust 类型系统里这种风格随处可见。
一个常见陷阱:Future 内部悄悄持有了非 Send 类型
rust
// 看似无辜,但 spawn 会炸
use std::rc::Rc;
async fn bad() {
let data = Rc::new(42);
tokio::task::yield_now().await; // ← 关键:data 跨越了这个 await
println!("{}", data);
}
tokio::spawn(bad()); // ❌ 编译错误:the trait `Send` is not implemented for `Rc<i32>`根因:编译器展开 async fn bad 时,data 跨越了 .await,所以它被存进状态机的某个 variant(见 2.5 节)。状态机结构体自动推导出 Send 的前提是所有字段都 Send——而 Rc<i32> 不是 Send(Rc 的引用计数不是原子的),于是整个状态机不是 Send,于是 spawn 的 trait bound 不满足,于是报错。
修复选项:
Arc<i32>替代Rc<i32>(线程安全的原子引用计数,是 Send 也是 Sync)- 改用
tokio::runtime::Builder::new_current_thread()的单线程 runtime 或LocalSet(第 7 章),spawn_local 不要求 Send - 重构代码让
data不跨越.await(见下一小节)
为什么报错总是指向"跨 .await"的变量?
你会发现,如果你的非 Send 变量不跨 .await,编译器不会报错:
rust
async fn ok() {
{
let data = Rc::new(42);
println!("{}", data); // data 在 {} 里用完
} // ← data 在这里 drop
tokio::task::yield_now().await; // .await 时 data 已经不在作用域
}
tokio::spawn(ok()); // ✅ 编译通过原因在 2.5 节已经埋下伏笔:编译器只把"跨 .await 还活着的变量"装进状态机 variant。data 在 .await 之前就出作用域了,所以不会出现在状态机字段里——整个状态机 Send(因为没有任何非 Send 字段),spawn 通过。
这个机制给你一个合法的逃生通道:把非 Send 的工作封装在一个 block 里,block 结束前不 .await:
rust
async fn calc_and_wait() {
let result = {
let non_send = Rc::new(compute()); // Rc 是非 Send
process(&non_send) // 同步处理完
}; // ← Rc 在这里 drop
some_io().await; // .await 时只剩 result(Send)
use_result(result);
}这个模式很实用,尤其是遗留代码里掺着 Rc / RefCell 这类非 Send 类型时。
与《Rust 编译器与运行时揭秘》第 9 章的精确衔接
"状态机只装跨 .await 的变量"这句话在 MIR 层有精确的对应:编译器在 rustc_mir_transform::coroutine pass 里做一次 liveness analysis——只有在某个 .await suspension point 之后还被读取的变量才被"升级"为 coroutine state 的字段;只在两个 .await 之间使用、到下个 .await 前已经 drop 的变量不进状态机。
如果你想看这个 pass 的具体实现,翻 《Rust 编译器与运行时揭秘》第 9 章——那里有 HIR coroutine 到 MIR coroutine state machine 的完整 transformation pass 源码拆解,包括 liveness 分析算法、状态机大小优化(variants 之间的字段 overlap)、ResumeTy 和 GeneratorState 的内存布局。
看过之后你再回到这里,会发现"为什么 Rc 让 Future 不是 Send"不再是一条经验法则——而是编译器 liveness + auto trait 推导两条机制自然叠加的后果。这种"从经验规则升级为可推导的机制"正是读源码的价值。
2.10 和这个系列的其他书的关联
本章涉及的编译期展开机制,《Rust 编译器与运行时揭秘》的这两章讲得最详细:
- 第 9 章 async/await 的状态机展开:从 HIR coroutine 到 MIR coroutine state machine 的每一步,你会看到编译器如何收集跨
.await的本地变量、如何为每一个.await生成恢复点、如何压缩状态机的大小 - 第 10 章 Pin、Waker、Future 的运行时协作:Pin 的投影规则、
Pin::new_unchecked的安全边界、自引用结构体的具体内存布局
如果你已经读过这两章,本章对你是运行时侧的镜像:那两章讲"编译器为你准备了什么",本章讲"运行时如何使用编译器准备的东西"。两套视角合起来,你才能完整回答".await 到底在做什么"这个问题。
如果你还没读过,建议读完本章再回看——带着运行时侧的理解回去看编译期,你会发现那些 MIR transformation 的每一步都有了目的。
另外,如果你读过《Vue 3 设计与实现》的第 6 章 Alien Signals,可以对比这两种"细粒度唤醒"机制:Vue 3.6 的 signal 系统也是"依赖追踪 + 变化通知",和 Rust 异步的"Waker 注册 + wake 通知"在范式层面高度相似——都是"被动状态机 + 外部触发"。不同的是 Vue 跑在浏览器的事件循环上、状态机是虚拟 DOM / 渲染函数,而 Rust 跑在 Tokio 的 worker 线程上、状态机是 async fn。同一个问题在两种语言、两种运行时里的两种解答,对比阅读会让你对"异步编程的本质"有更深的感觉。
2.11 本章小结
带走三件事:
- Future 是一个 2 行代码的 trait,但每个字都经过锤炼。
type Output、Pin<&mut Self>、&mut Context<'_>、Poll<T>——这四个签名元素凑成的契约撑起了整个 Rust 异步生态 - Pending 是带尾巴的契约:返回 Pending 之前必须有外部的手拿到你的 Waker——要么注册给 Driver,要么交给 channel Sender。违反这个契约就是"Future 永久卡死"bug
.await不是 yield:它是编译器生成的状态机 yield + 向上返回 Pending + 依赖外部 wake 来重新 poll。这个机制决定了 Rust 异步的所有优势(零成本抽象、可嵌入、零成本取消)和所有代价(严格的 Pin / Waker 规矩)
下一章我们把 Waker 拆到字节级——RawWaker 的 vtable 布局、Tokio 如何在 Task 内部手工实现这个 vtable、wake() 的一次调用在 Tokio 源码里走过的完整路径。理解 Waker 的 ABI,你才能理解 Tokio 调度器的起点。
延伸阅读
- Rust 源码:
library/core/src/future/future.rs—— 本章引用的 Future trait 完整定义 - Rust 源码:
library/core/src/task/poll.rs—— Poll 枚举完整实现 - Rust 源码:
library/core/src/task/wake.rs—— Context、Waker、RawWaker、RawWakerVTable - Rust RFC 2592 ——
futures_api稳定化的设计讨论 - 《Rust 编译器与运行时揭秘》第 9、10 章:编译期侧的完整拆解