Tokio 源码深度解析

第2章 Future trait 与 poll 模型回顾

作者 杨艺韬 · 11,664 字 · 发布于 · 更新于

本章要点

  • std::future::Future trait 的真实定义一共只有 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 一次

为什么要这么设计? 三个原因:

  1. 零成本抽象。Future 本身是一个栈上结构体,不需要堆分配、不需要引用计数、不需要 runtime 强制 wrap
  2. 运行时可选。既然 Future 是个被动的状态机,你可以在嵌入式、WASM、no_std 环境下自己实现一个最小的"poll 循环"
  3. 细粒度控制。拉式模型让运行时可以决定什么时候、在哪个线程、以什么优先级 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 poll it again."

"Once a future has completed (returned Ready from poll), calling its poll method 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 ()),
}

注意三个细节:

  1. Waker 是 #[repr(transparent)] 的单字段 struct,内存布局等同于 RawWaker(两个指针:data 和 vtable)——所以一个 Waker 总共就是 16 字节(在 64 位平台)
  2. Waker 的全部行为通过 vtable 间接调用——clone、wake、wake_by_ref、drop 都是函数指针。这让不同运行时可以有完全不同的 Waker 实现,但共用同一套接口
  3. 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 发送时把等待者的 Waker take() 进 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 线程栈)

具体差异带来什么?

  1. 内存开销:Python 的 await 链每层各有一个堆上的协程对象和帧;Rust 整条 await 链被编译进一个状态机结构体,大小在编译期确定(可以用 std::mem::size_of_val 直接查看)
  2. 栈溢出风险:Rust 异步没有"协程栈",风险主要来自"Future 太大"——一个 Future 大到 MB 级时,放在栈上就可能直接栈溢出。第 6 章会讲 Tokio 的 Box::pin 策略
  3. 可组合性:Rust Future 可以相互嵌套、被 Box<dyn Future> 包起来、被 select! 聚合,而且每一层都没有栈切换开销。Python 协程需要 event loop 显式管理
  4. 取消(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::read
  • tokio::time::sleep
  • tokio::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 本章小结

带走三件事:

  1. Future 是一个 2 行代码的 trait,但每个字都经过锤炼。type Output、Pin<&mut Self>、&mut Context<'_>、Poll<T>——这四个签名元素凑成的契约撑起了整个 Rust 异步生态
  2. Pending 是带尾巴的契约:返回 Pending 之前必须有外部的手拿到你的 Waker——要么注册给 Driver,要么交给 channel Sender。违反这个契约就是"Future 永久卡死"bug
  3. .await 不是 yield:它是编译器生成的状态机 yield + 向上返回 Pending + 依赖外部 wake 来重新 poll。这个机制决定了 Rust 异步的所有优势(零成本抽象、可嵌入、零成本取消)和所有代价(严格的 Pin / Waker 规矩)

延伸阅读