Tokio 源码深度解析
第2章 Future trait 与 poll 模型回顾
本章要点
std::future::Futuretrait 的真实定义一共只有 2 行有效代码:type Output和一个poll方法。本章会告诉你这 2 行是如何撑起整个 Rust 异步世界的- Poll 是一个二元枚举——
Ready(T)和Pending——但这二元的语义极端不对称:Ready是承诺,Pending是契约 - Context 不等于 Waker,它是 Waker 的"信封"——今天
core里的 Context 已经有waker、local_waker、ext三个字段(后两个的访问方法至今仍是 unstable),这种"信封"设计让运行时可以悄悄加料而不破坏 API 稳定性 .await在表面上看起来像 Python 的yield,编译器内部也确实把它脱糖成一个带yield的 poll 循环,但这个yield最终被编译成"状态机跳转 + 返回 Pending",要等运行时重新 poll 才继续。理解这一点是理解 Tokio 的分水岭- 我们会手写一个最简单的 Future,跑通它,然后故意制造一个"忘记 wake"的 bug——只有亲手让 Future 卡死过,你才会真正明白 Waker 不是装饰
2.1 整个 Rust 异步世界,建立在一个 trait 上
Rust 选择了一个极简的切入点来建立它的异步世界:一个只有两行方法签名的 trait。这种"用最少的抽象提供最多的能力"的美学贯穿了整个 Rust 语言设计——Iterator trait 只要求实现一个 next 方法、Hash trait 只要求实现一个 hash 方法。Future trait 延续了这种传统——不设置任何多余要求,只规定"我可以被轮询、我会返回 Ready 或 Pending"这个最小契约。这个契约的简洁性反而让 Rust 异步很容易嵌入到各种场景——嵌入式、WASM、浏览器、服务端全都能用同一个 trait。
打开 library/core/src/future/future.rs:37,拨开所有的 #[stable]、#[lang]、#[diagnostic] 这些属性和文档,你会看到这个被所有人天天用、但极少人认真读过的 trait 真实的样子:
// 来源:rust-lang/rust · library/core/src/future/future.rs:37(poll 的签名在 :113)
// 移除了属性注解,保留全部有效结构
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 自己决定的。
// 如果 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-lang/rust · library/core/src/task/poll.rs:14
// 省略属性注解
pub enum Poll<T> {
Ready(T),
Pending,
}
两个变体。但这两个变体的语义重量不对称到令人惊讶的程度。
Ready(T) —— 一次性的终结承诺
当一个 Future 的 poll 返回 Ready(value),它在表达一个终结性的承诺:
"这个 Future 的任务已经完成。这是结果。以后不要再 poll 我了。"
标准库在 Future trait 的 doc comment 里写得明明白白(library/core/src/future/future.rs:54 与 :100):
"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 源码里高频出现,值得记住:
// 摘自 library/core/src/task/poll.rs 的关键方法签名(map 在 :49、is_ready 在 :74、is_pending 在 :95)
impl<T> Poll<T> {
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Poll<U>;
pub const fn is_ready(&self) -> bool;
pub const fn is_pending(&self) -> bool;
}
还有对 Result 和 Option<Result> 的特化:
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>>> {
// 这一组同样有自己的 map_ok / map_err(library/core/src/task/poll.rs:174、:204)
// 让 ? 能用的是两个 ops::Try 实现(同文件 :234 与 :264)
}
这两个 ops::Try 实现让 ? 操作符能直接作用在 Poll<Result<T, E>> 和 Poll<Option<Result<T, E>>> 上。与它配套的是 ready!(...) 宏(std::task::ready!,Rust 1.64 起稳定——library/core/src/task/ready.rs:48 那行 stable 标记写的是 ready_macro / 1.64.0;futures crate 里也有同名的一份),它本身适用于任何 Poll<T>,不依赖 Try。Tokio 源码里随处可见"先 ready! 再 ?"的写法:
// 伪代码示意
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! 宏展开后差不多是:
match expr {
Poll::Ready(v) => v,
Poll::Pending => return Poll::Pending,
}
这两个小工具在 Tokio 的 I/O 代码里随处可见(例如 tokio/src/fs/file.rs:612 的 ready!(Pin::new(rx).poll(cx))?)。记住它们的语义,你读后面章节的源码时就不会被这种"短路式控制流"绊住。
2.3 Context 与 Waker:双层抽象的秘密
Context 和 Waker 是 Rust 异步设计里最容易被低估的细节。表面上 Context 就是 Waker 的包装——但为什么不直接让 poll 接收 &Waker?这个"额外一层"的设计藏着 Rust 团队对未来扩展性的考虑——Context 留了空间,让未来可以在不破坏 poll 签名的前提下加入新字段(后来确实加进了 local_waker 和 ext,见下文)。
Future 的 poll 方法第二个参数是 cx: &mut Context<'_>。但你真正要用的其实是 Context 内部的 Waker。为什么多一层?
看一下 Context 今天的真实定义(下面按 rust-1.96.0 的 library/core/src/task/wake.rs:218 抄录;local_waker 字段是 1.78.0 加进来的、ext 是 1.79.0):
// 来源:rust-lang/rust · library/core/src/task/wake.rs:218
// 字段顺序与源码一致
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 变体,面向单线程执行器的优化路径。访问它的Context::local_waker()至今仍是 unstable(#[unstable(feature = "local_waker", issue = "118959")])ext:一个扩展槽,运行时可以往里塞任意&mut dyn Any,目的是给未来的 API 扩展留空间。访问它的Context::ext()同样仍是 unstable(#[unstable(feature = "context_ext", issue = "123392")])
注意:这两个字段虽然已经躺在 Context 的结构体里,但对稳定 Rust 的使用者不可见——它们正是"信封"设计要保护的那类演进。
而在 2019 年 Rust 1.36 刚把 Future / Poll / Context / Waker 这套 API 稳定下来时(futures_api,见 library/core/src/task/wake.rs:216;async/await 语法要到 1.39 才稳定),Context 只有一个字段 waker: &'a Waker。为什么当时不直接传 &Waker?
RFC 2592 合入时的正文里,poll 的第二个参数其实还是 waker: &Waker;Context 是稳定化前夕由 Taylor Cramer 的提交 "Future-proof the Futures API"(rust-lang/rust PR #59119,2019 年 4 月)补进来的。提交标题已经说明了意图:信封设计是为了 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 实现照常工作
这是一个典型的"用一层间接换取 API 稳定性"的设计。今天回头看,这个决策被完美验证了:Context 后来陆续长出了 local_waker 和 ext 两个字段(都还在 unstable 阶段孵化),Future::poll 的签名一次都没动过。整个生态的 Future 实现从来没被这些扩展碰到过。
Context 当前的主要方法:
// 摘自 library/core/src/task/wake.rs:232 起的 impl 块
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: feature = "local_waker"
pub const fn ext(&mut self) -> &mut dyn Any; // unstable: feature = "context_ext"
}
作为 Future 的实现者,你 99% 的时候只会用 cx.waker() 拿到 &Waker,然后 .clone() 它存起来。
Waker 的真实结构
Waker 本身是一个极薄的包装:
// 来源:rust-lang/rust · library/core/src/task/wake.rs:407(RawWaker 在 :22、RawWakerVTable 在 :109)
#[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 实现,但共用同一套接口 unsafe fn:这些 vtable 函数都是 unsafe 的,因为调用者必须保证data指针的有效性。这把"Waker 接口的安全边界"明确画在了运行时实现者身上
这个 vtable 设计完全等价于 C++ 的虚函数表、Rust 的 trait object(dyn Trait),但它手动实现而不是用 dyn Waker,有几个工程上的原因:
- Waker 需要
Send + Sync、Clone,但Clone不是对象安全(dyn-compatible)的 trait,dyn Waker + Clone这样的 trait object 根本写不出来 - 手动 vtable 不强制堆分配:
Box<dyn ...>同样是两个指针宽的胖指针,却要求数据在堆上,而RawWaker的data可以指向任何地方 - 可以在
#[no_std]环境下使用
Tokio 的 Task 结构体内部,就实现了自己的一套 RawWakerVTable(第 3 章会把这份 vtable 拆到字节级)。现在你只需要记住:Waker 是运行时 → Future 的回调句柄,它的实现是运行时私事,调用方(Future)只管用 waker.wake() / waker.clone() / waker.wake_by_ref() 三个安全接口。
Waker 的三个方法,对应三种使用场景
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:唤醒之后不再需要这份 Waker 时用。Tokio mpsc 接收端的AtomicWaker::wake先take_waker()把 Waker 取出、再waker.wake()(tokio/src/sync/task/atomic_waker.rs:303-307);broadcast 发送时把等待者的 Wakertake()进WakeList,再由wake_all()逐个按值 wake(tokio/src/sync/broadcast.rs:903、:931)wake_by_ref:Waker 还要留在原处时用,省掉"先 clone 一份再消耗"。1.40.0 的 oneshot 在Inner::complete里就是self.rx_task.with_task(Waker::wake_by_ref),Waker 仍留在rx_task里(tokio/src/sync/oneshot.rs:1133-1135,旁边还留着一行// TODO: Consume waker?)will_wake:这个方法极其重要。Future 每次poll收到的Context里的 Waker 可能是同一个 Task 的(运行时内部的),但在"Future 被 move 到另一个 Task 里"的罕见情况下可能不同。Future 应该在每次poll时比对cx.waker().will_wake(&stored_waker),如果不匹配就重新 clone 一份存起来
(这三个是用 Waker 时的全部接口,但 Waker 的稳定 API 不止三个:还有构造侧的 Waker::from_raw(unsafe)、Waker::noop()(1.85 稳定)以及取回底层两个指针的 data() / vtable()(1.83 稳定)——library/core/src/task/wake.rs:567、:576、:584。写业务 Future 用不到它们,写运行时才会。)
will_wake 的不匹配情况你在日常业务代码里几乎碰不到,但写一个正确的 Future 实现必须处理它——否则如果运行时把你的 Future 换了个 Task 跑,你保存的旧 Waker 会唤醒错的任务、新任务永远卡死。
Tokio 内置的叶子 Future 普遍处理了这个 case(例如 tokio/src/runtime/io/scheduled_io.rs:348、tokio/src/sync/batch_semaphore.rs:503)。手写 Future 时如果你不打算让它被"跨 Task 移动",可以忽略——但至少要知道这个坑在哪。
2.4 手写一个最小 Future:把抽象变成肌肉记忆
光讲理论没用。我们真的来写两个 Future,跑起来,并且故意制造 bug 让你看懂 Pending 契约的分量。
例 1:Ready<T> —— 最平凡的 Future
// 一个立即返回 Ready 的 Future
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct Ready<T>(Option<T>);
// 从不对 T 做结构化 pinning,所以无条件 Unpin——下面的 get_mut() 需要它
// (标准库 library/core/src/future/ready.rs:15 也是这么写的)
impl<T> Unpin for Ready<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 里就有一例:SocketAddr 实现 ToSocketAddrs 时,地址已经解析好,直接返回 future::ready(Ok(iter)),不走 DNS 解析(tokio/src/net/addr.rs:56-59)。
例 2:Counter —— 一个故意踩 Pending 陷阱的 Future
现在做一个需要多次 poll 才能完成的 Future。假设我们想实现"poll 三次后才 Ready":
// 第一版:**错误演示**——违反 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 转入空闲(idle),不再放回任何运行队列。它在等 Waker 被调用。但你的 Future 压根没把 Waker 存下来、也没告诉任何人"我在等什么"。于是 Task 就永久躺在 Tokio 的 Task 池里,不被 poll、不被 drop(除非 runtime 关闭)、占着一份内存——这是 Rust 异步最典型的"卡死"bug。
正确的写法必须遵守 Pending 契约——返回 Pending 之前要么能自驱、要么留下 Waker:
// 第二版:**正确**——立即 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 托付给一个定时器:
// 第三版:正确且实用——等 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 展开:编译器替你做的那一百行
本节是整章最"揭开黑箱"的部分——async fn 看起来是写普通函数、但编译器在背后把它展开成一个状态机。这个状态机是 Rust 零成本抽象的核心:所有 await 点被编译成同一个 enum 的不同变体、存放在同一块内存里,不需要为每次挂起单独分配。理解这个展开过程,才能回答"为什么 async fn 的 Future 不是 Unpin 的"这类问题。
到这里你可能想说:但我平时根本不手写 Future,我只写 async fn!
好消息是一切 async fn 都会被编译器展开成一个实现了 Future trait 的匿名状态机类型——也就是说,你写的每一个 async fn,在类型系统眼里都是一个 Future。2.1 节讲的契约全都适用。
考虑这个例子:
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)
}
编译器把它展开成什么?大致是这样一个状态机:
// 编译器生成的伪代码(示意,不能编译;真实产物见下方 MIR 实测)
// 变体名沿用 rustc 的叫法;注释里是 1.96.0 实测的判别值
enum FetchAndParseState {
Unresumed { url: &str }, // 0:还没被 poll 过
Returned, // 1:已经返回过 Ready
Panicked, // 2:poll 中途 panic 过
Suspend0 { http_fut: HttpGetFuture }, // 3:停在第一个 .await
Suspend1 { resp: Response, body_fut: ReadBodyFuture }, // 4:停在第二个 .await
}
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::Unresumed { url } => {
// 启动第一个 await 的子 Future
let fut = http_get(url);
self.state = FetchAndParseState::Suspend0 { http_fut: fut };
}
FetchAndParseState::Suspend0 { http_fut } => {
match Pin::new(http_fut).poll(cx) {
Poll::Pending => return Poll::Pending, // ← 关键
Poll::Ready(Err(e)) => {
self.state = FetchAndParseState::Returned;
return Poll::Ready(Err(e));
}
Poll::Ready(Ok(resp)) => {
let body_fut = resp.read_body(); // body_fut 借用着 resp
self.state = FetchAndParseState::Suspend1 { resp, body_fut };
}
}
}
FetchAndParseState::Suspend1 { body_fut, .. } => {
match Pin::new(body_fut).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(e)) => {
self.state = FetchAndParseState::Returned;
return Poll::Ready(Err(e));
}
Poll::Ready(Ok(body)) => {
let result = parse(body);
self.state = FetchAndParseState::Returned;
return Poll::Ready(result);
}
}
}
FetchAndParseState::Returned => panic!("`async fn` resumed after completion"),
FetchAndParseState::Panicked => panic!("`async fn` resumed after panicking"),
}
}
}
}
想看编译器真正生成了什么,cargo expand 帮不上忙——它只展开宏,看不到 async 状态机。要用 MIR:给上面的 http_get、read_body(&self)、parse 补上最简单的桩实现,在 1.96.0 上执行 RUSTC_BOOTSTRAP=1 rustc --edition 2021 --crate-type lib -Zunpretty=mir sm.rs,fetch_and_parse 的 poll 函数里能看到这几段(节选,省略号处省略):
fn fetch_and_parse::{closure#0}(_1: Pin<&mut {async fn body of fetch_and_parse()}>, _2: &mut Context<'_>) -> Poll<Result<Data, Error>> {
debug url => ((*_36).0: &str);
...
debug resp => (((*_36) as variant#4).0: Response);
...
debug __awaitee => (((*_36) as variant#4).1: {async fn body of Response::read_body()});
...
debug __awaitee => (((*_36) as variant#3).0: {async fn body of http_get()});
...
bb0: {
_36 = copy (_1.0: &mut {async fn body of fetch_and_parse()});
_35 = discriminant((*_36));
switchInt(move _35) -> [0: bb1, 1: bb37, 2: bb36, 3: bb34, 4: bb35, otherwise: bb7];
}
...
_5 = <{async fn body of http_get()} as IntoFuture>::into_future(move _6) -> [return: bb3, unwind: bb32];
...
bb36: {
assert(const false, "`async fn` resumed after panicking") -> [success: bb36, unwind continue];
}
bb37: {
assert(const false, "`async fn` resumed after completion") -> [success: bb37, unwind continue];
}
每次 poll 先读判别值、switchInt 跳到对应状态:0 是 Unresumed,1 是 Returned,2 是 Panicked,3、4 是两个 await 点 Suspend0、Suspend1。.await 脱糖时先调 IntoFuture::into_future(compiler/rustc_ast_lowering/src/expr.rs:832-846),子 Future 存进对应变体的 __awaitee 字段。
三个关键观察:
一、每一个 .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 之间需要跨越的本地变量,被编译器打包进状态机。比如 resp 要活过第二个 .await,它就被存进 Suspend1 变体(上面 MIR 里 variant#4 的 .0 字段);参数 url 则放在各变体共用的前缀字段里((*_36).0)。这就是为什么 async fn 展开后的状态机常常是自引用的:read_body(&self) 返回的 body_fut 内部持有指向 resp 的引用,而它和 resp 在同一个变体里(variant#4 的 .1 与 .0),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 转入 idle)
| |
| | 5. 内核 epoll_wait 返回
| | 对应 fd 可读
| | 6. Driver 查表找到 Waker
| | 7. Waker.wake() ──→ Task 入队
| |
| 8. Task 再次被 poll ─────→ | |
| | 9. 子 Future 读到了数据 |
| ←─── 10. 子 Ready(data) ────── |
| 11. 外层继续执行下一句 |
11 步,每一步都有具体的代码位置。后续章节会把每一步钻透:
- Step 2 的"注册 Waker 到 I/O Driver"—— 第 8 章
- Step 4 的"Task 转入 idle"—— 第 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——"挂起当前协程,把控制权交还给调度器"。rustc 内部确实把 .await 脱糖成一个"poll 一次、没好就 yield"的循环(compiler/rustc_ast_lowering/src/expr.rs:832-846),所以这个类比在感觉层是对的;但在机制层,两者的差别很大。
Python 协程(async def)的执行模型:
- 每个协程有自己的帧对象(frame),分配在堆上;await 链有几层,就有几个帧
yield/await是真的中途挂起——本地变量保留在帧里,下次恢复从挂起点继续- 协程之间切换是由 event loop 调度
Rust async fn 的执行模型:
- 没有独立栈。
async fn展开后是一个扁平的状态机结构体,所有跨.await的本地变量被打包进 enum variant .await不是真的挂起——它是一个状态机的状态转移 + 向上返回 Pending- 下次 poll 时,状态机从头运行
poll方法,根据 state 跳到上次的分支继续 - 同一个 runtime 的所有 Future 共享运行时分配的栈(worker 线程栈)
具体差异带来什么?
- 内存开销:Python 的 await 链每层各有一个堆上的协程对象和帧;Rust 整条 await 链被编译进一个状态机结构体,大小在编译期确定(可以用
std::mem::size_of_val直接查看) - 栈溢出风险:Rust 异步没有"协程栈",风险主要来自"Future 太大"——一个 Future 大到 MB 级时,放在栈上就可能直接栈溢出。第 6 章会讲 Tokio 的
Box::pin策略 - 可组合性:Rust Future 可以相互嵌套、被
Box<dyn Future>包起来、被 select! 聚合,而且每一层都没有栈切换开销。Python 协程需要 event loop 显式管理 - 取消(cancellation):Rust Future 的取消就是把它
drop——因为状态机结构体被释放,所有本地变量被正常析构。这是 Rust 异步最被低估的优势之一。Python 要做协程取消需要专门的异常机制。但 drop 只在.await让出点生效,一个操作被取消在半途会不会丢数据(cancel safety),要看各 API 文档的说明(第 14 章 §14.9 细讲)
理解这一点后,你对 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(...)组合器
前两类的 poll 方法是编译器生成的,组合器的 poll 则是库作者手写的;但它们内部都不直接操作 Waker——它们只是把 cx 透传给内部的子 Future(通常是叶子 Future 或更小的组合 Future)。
两种世界的分工:
- 世界 A 负责"实实在在地等某个外部条件"——它们是异步性的来源
- 世界 B 负责"把多个等待串起来"——它们是异步性的组合
Tokio 源码的大量篇幅在实现世界 A 的各种叶子 Future。本专栏后面大部分章节都在拆这些叶子 Future——第 10 章的 TcpStream、第 11 章的 sleep、第 13 章的 mpsc::Receiver、第 15 章的 JoinHandle——每一个都是"显式和 Waker / Driver 打交道"的经典例子。
在你的业务代码里,99% 写的是世界 B。这完全没问题——绝大多数时候你不需要手写叶子 Future。但你必须知道你依赖的那些 .await 点,底下都是世界 A 在干活。看不到、但存在。
2.9 Future 的 size:为什么 Tokio 有时要 Box::pin
async fn 展开出来的状态机结构体并不小。它需要装下所有跨 .await 的本地变量、所有子 Future、所有栈上分配的 buffer。一个朴素的 async fn 经过几层嵌套之后,状态机可能就膨胀到几 KB 甚至更大。
这在什么时候会变成问题?
- 递归
async fn:如果一个async fn递归地调用自己(比如遍历一棵树),状态机就得包含自己,大小无穷——编译器直接报error[E0733]: recursion in an async fn requires boxing,要在递归调用处写Box::pin(recur_fn()).await引入一层堆上的间接 - 不同类型的 Future 被塞进
Vec、HashMap等容器:每个async fn的 Future 类型都不一样,容器需要统一的元素类型,所以要Pin<Box<dyn Future<Output = T>>> - Future 在极端场景大到超过线程栈:少见但真实发生过,特别是含有大数组或大状态的复杂
async fn。这时tokio::spawn(Box::pin(fut))把状态机搬到堆上可以避免栈爆
Tokio 源码里时不时会出现 Box::pin(future) 的写法——这通常是"我不能静态知道 Future 的类型"或者"我需要把它装进某个统一的接口"。但日常业务代码绝大多数时候不需要 Box::pin:debug 构建下,tokio::spawn 和 Runtime::block_on(#[tokio::main] 就是经由它运行的)遇到大于 BOX_FUTURE_THRESHOLD(2048 字节,tokio/src/runtime/mod.rs:397)的 Future 会自己先 Box::pin(tokio/src/task/spawn.rs:172、tokio/src/runtime/runtime.rs:332)。
第 6 章讲 Task 结构时会看到具体布局:1.40.0 的 Header 只有四个指针宽的字段(state / queue_next / vtable / owner_id,见 tokio/src/runtime/task/core.rs:150;tracing_id 要 tokio_unstable 和 tracing feature 同时开才有),而 Task 内部托管的 Future 大小由用户代码决定,Tokio 对此不做假设、把 Future 存在紧随 header 之后的内存里,整个 Task 是一次堆分配。所以一个 Future 的 size 不仅影响栈,也影响 spawn 时的堆分配成本。这种"把 header 和 Future 放在同一块内存、用 unsafe + 精确的 offset 计算索引"的技巧在 Tokio 源码里是标志性设计,第 6 章会把这段 unsafe 代码逐行拆给你看。
2.10 Send + Sync + 'static —— Future 上最容易卡人的三重标记
这三个标记是 Rust 异步新手最容易撞墙的地方——async fn 一写、编译器一通报错"future cannot be sent between threads"、大家第一次看到都一头雾水。本节把这三重标记的来龙去脉讲清:Send 要求 Future 可跨线程移动、'static 要求 Future 不借用外部短生命周期引用,而 tokio::spawn 恰恰不要求 Sync。
如果你写过 Tokio 的业务代码,几乎肯定遇到过这种报错:
error: future cannot be sent between threads safely
--> src/main.rs:11:18
|
11 | tokio::spawn(bad());
| ^^^^^ future returned by `bad` is not `Send`
|
= help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `Rc<i32>`
note: future is not `Send` as this value is used across an await
或者 async move 块捕获了非 Send 的值:
error: future cannot be sent between threads safely
= help: within `{async block@src/main.rs:6:18: 6:28}`, the trait `Send` is not implemented for `Rc<Foo>`
note: captured value is not `Send`
(两段都是 rustc 1.96.0 + tokio 1.40.0 的实测输出节选;第一段对应下文的 bad() 例子。)
这类错误初学者普遍的第一反应是"Rust 太严苛了"。但这不是严苛,这是 Future trait 的 poll 契约在多线程场景下的自然推论。把这个推论讲透,你以后不会再被这类错误拦住,也不会写出"绕过它"的糟糕代码。
从 tokio::spawn 的真实签名开始
打开 Tokio 1.40 源码 tokio/src/task/spawn.rs:165,你会看到这样的签名:
// 来源:tokio-rs/tokio · tokio/src/task/spawn.rs:165(为了聚焦已删减属性和文档)
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 上被访问之后,又在 worker-3 上被访问——Task 在堆上的地址不变,但它的所有权从一个线程转交给了另一个线程。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(把一个 Future 变成可 clone、多个 Task 共享结果)这种特殊包装时,shared Future 的内部才需要 Sync——因为同一个 Future 会被多个 Task 并发读。Tokio 的日常 API 不走这条路,所以 spawn 不强加 Sync。
这是一个典型的"约束最小化"设计:只要求解决当前问题所必需的、不多加一个。Rust 类型系统里这种风格随处可见。
一个常见陷阱:Future 内部悄悄持有了非 Send 类型
// 看似无辜,但 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)- 改用
LocalSet(第 7 章)的spawn_local,它不要求 Send(tokio/src/task/local.rs:365-368);注意单换成new_current_thread()的单线程 runtime 不够,上面的tokio::spawn照样要求 Send - 重构代码让
data不跨越.await(见下一小节)
为什么报错总是指向"跨 .await"的变量?
你会发现,如果你的非 Send 变量不跨 .await,编译器不会报错:
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:
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(compiler/rustc_mir_transform/src/coroutine.rs)里做一次 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 和 CoroutineState(旧名 GeneratorState,Rust 1.75 起统一改叫 coroutine)的内存布局。
看过之后你再回到这里,会发现"为什么 Rc 让 Future 不是 Send"不再是一条经验法则——而是编译器 liveness + auto trait 推导两条机制自然叠加的后果。这种"从经验规则升级为可推导的机制"正是读源码的价值。
2.11 和这个系列的其他专栏的关联
本章涉及的编译期展开机制,《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.12 本章小结
带走三件事:
- 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 规矩)
延伸阅读
- 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 章:编译期侧的完整拆解