Skip to content

第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::Future trait 的真实定义一共只有 2 行有效代码type Output 和一个 poll 方法。本章会告诉你这 2 行是如何撑起整个 Rust 异步世界的
  • Poll 是一个二元枚举——Ready(T)Pending——但这二元的语义极端不对称:Ready 是承诺,Pending 是契约
  • Context 不等于 Waker,它是 Waker 的"信封"——现代 Rust(1.82 起)的 Context 里已经有 wakerlocal_wakerext 三个字段,这种"信封"设计让运行时可以悄悄加料而不破坏 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 selfPin<&mut Self> 的含义是"我给你一个可变引用,但你承诺不会 mem::swapstd::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 后续一切设计决策的源头。

市面上的语言对异步有好几种答案:

  • JavaScriptPromise——一个有状态的对象,内部已经在跑,.then() 是注册回调。这是推式(push)模型:值 ready 了 runtime 把它推给回调
  • Go:goroutine + channel——语言级运行时包办一切,用户代码看起来是同步的
  • Pythonasync 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
// 来源: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 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 —— 带"尾巴"的契约

比起 ReadyPending 的语义要重得多。它不是"我没好,你等会儿再来问"这种随口一说。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>;
}

还有对 ResultOption<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 ()),
}

注意三个细节:

  1. Waker#[repr(transparent)] 的单字段 struct,内存布局等同于 RawWaker(两个指针:datavtable)——所以一个 Waker 总共就是 16 字节(在 64 位平台)
  2. Waker 的全部行为通过 vtable 间接调用——clonewakewake_by_refdrop 都是函数指针。这让不同运行时可以有完全不同的 Waker 实现,但共用同一个 ABI
  3. unsafe fn:这些 vtable 函数都是 unsafe 的,因为调用者必须保证 data 指针的有效性。这把"Waker ABI 的安全边界"明确画在了运行时实现者身上

这个 vtable 设计完全等价于 C++ 的虚函数表、Rust 的 trait object(dyn Trait),但它手动实现而不是用 dyn Waker,有几个工程上的原因:

  • Waker 需要 Send + SyncClone,但 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 避免 clone
  • will_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 运行时的三角关系

  • FutureTickingCounter):被 poll 的状态机
  • DriverTime 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。

三个版本对照给你的肌肉记忆

版本行为问题用在什么场景
BrokenCounterPending 后无人 wake永久卡死❌ 绝对不要
Counter(自 wake)Pending 后立即自 wakeCPU 100% 忙轮询⚠️ 仅在"极少量迭代 + 想主动让出"场景
TickingCounterPending 后由 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 的字段里urlresp 这些在 .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 线程栈)

具体差异带来什么?

  1. 内存开销:Python 每个协程独立栈(即使小也是若干 KB);Rust 每个 Future 只占状态机结构体大小(常见几十到几百字节)
  2. 栈溢出风险:Rust 异步不会因为"协程太深"栈溢出(所有 Future 在堆或调用方栈里是线性展开的),但会因为"Future 太大"撑不下——一个 Future 超过 MB 级的话会警告甚至错误。第 6 章会讲 Tokio 的 Box::pin 策略
  3. 可组合性:Rust Future 可以相互嵌套、被 Box<dyn Future> 包起来、被 select! 聚合,而且每一层都没有栈切换开销。Python 协程需要 event loop 显式管理
  4. 取消(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::read
  • tokio::time::sleep
  • tokio::sync::oneshot::Receiver

这些 Future 的 poll 方法里,你会看到显式的 Waker 注册代码——cx.waker().clone() 然后存到某个 Driver 的等待队列里。叶子 Future 是"异步魔法"的真正发生地。

世界 B:组合 Future —— 被编译器生成的世界

组合 Futureasync 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 被塞进 VecHashMap 等容器:容器需要统一大小,所以必须 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 还活着的变量"装进状态机 variantdata.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)、ResumeTyGeneratorState 的内存布局。

看过之后你再回到这里,会发现"为什么 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 本章小结

带走三件事:

  1. Future 是一个 2 行代码的 trait,但每个字都经过锤炼type OutputPin<&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 规矩)

下一章我们把 Waker 拆到字节级——RawWaker 的 vtable 布局、Tokio 如何在 Task 内部手工实现这个 vtable、wake() 的一次调用在 Tokio 源码里走过的完整路径。理解 Waker 的 ABI,你才能理解 Tokio 调度器的起点。


延伸阅读

基于 VitePress 构建