Skip to content

第3章 Waker 机制:唤醒的本质

"A Waker is the seam between two worlds that agreed to stay apart." —— 笔者

本章要点

  • RawWaker + RawWakerVTable 是 Rust 异步生态的唯一跨运行时 ABI。任何运行时(Tokio、smol、embassy、monoio)都必须通过这对类型来提供 Waker,Future 实现者永远只看到 &Waker
  • Tokio 1.40 在 tokio/src/runtime/task/waker.rs 里用127 行 Rust 代码实现了全部 Waker 语义——本章会把这 127 行逐块拆掉
  • Tokio 采用 "全局单一 VTABLE + Task Header 指针作 data" 的设计,背后是 rust-lang/rust#66281 这个 will_wake 的历史坑
  • WakerRefManuallyDrop<Waker> 技巧让 runtime 内部可以"临时伪装成有一个 Waker"而不真的触发 clone / drop 的 refcount 操作——这是 Tokio hot path 性能的关键细节之一
  • 一次 waker.wake() 走过的完整路径有 6 层Waker::wakevtable.wakewake_by_val(ptr)RawTask::wake_by_valHarness::wake_by_valstate::transition_to_notified_by_valSchedule::schedule
  • wake_by_valwake_by_ref 的区别不是"取不取走所有权"那么简单,而是一次精心设计的 refcount 优化:当 wake 发生在"调度后立即"的场景时,_by_val 可以省掉一对 clone/drop

3.1 回到原点:RawWaker + vtable 是跨运行时的唯一 ABI

第 2 章已经把 RawWakerRawWakerVTable 的 struct 摆了出来。这里我们把它们放在Rust 异步生态的 ABI 层级上再看一次:

rust
// 来源:rust-lang/rust · library/core/src/task/wake.rs
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 ()),
}

整个 Rust 异步生态有三类角色:

  • 语言:提供 Future trait 和 async / await 语法
  • 标准库:提供 Waker / Context / Poll 这些只定义 ABI 形状、不含实现的类型
  • 运行时:提供 Waker 的实际行为(wake 意味着什么、clone 要不要分配、drop 要不要释放)

标准库里的 Waker 把上述 RawWaker 和 vtable 包了一下:

rust
#[repr(transparent)]
pub struct Waker {
    waker: RawWaker,
}

impl Waker {
    pub fn wake(self) {
        let this = ManuallyDrop::new(self);
        unsafe { (this.waker.vtable.wake)(this.waker.data) };
    }
    pub fn wake_by_ref(&self) {
        unsafe { (self.waker.vtable.wake_by_ref)(self.waker.data) }
    }
    // ...
}

两个关键细节

  1. Waker#[repr(transparent)] 的单字段 struct,内存布局等同于 RawWaker——两个指针,16 字节(64 位平台)
  2. Waker::wake 的实现是拿 vtable 里的函数指针 + 传 data 指针—— 这是一个经典的 C 风格虚调用

这个设计的直接含义是:任何能构造一个合法的 RawWakerVTable 和一个合法的 data 指针的运行时,都可以提供 Waker。 trait object、Box<dyn Waker> 这些更"Rust 风格"的方案都被故意避开了。

为什么故意避开 trait object? 至少三条理由:

  • Box<dyn Waker> 强制堆分配。Waker 在 hot path 上被 clone 非常频繁(每次 poll 的 Future 都可能 clone 一份),把它绑到堆上会严重影响性能
  • dyn Trait 的组合 trait bound(Send + Sync + Clone)在早期 Rust 支持受限
  • vtable 方案可以在 #[no_std] 下工作——嵌入式运行时(embassy)依赖这一点

所以 Rust 最终选的是手写 vtable:把"我应该怎么行为"压缩成 4 个函数指针,把"我指向的具体数据"压缩成一个 *const ()。这套设计既简洁又极致——它就是一个精心手工打造的小型 C++ 虚表

这个 vtable 是整个 Rust 异步 ABI 的分水岭

┌───────────────────────────────┐
│   Future 实现者(你、库作者) │
│   只看到: &Waker             │ ← 稳定 API,用 wake() / clone() / will_wake()
├───────────────────────────────┤
│   标准库 (core::task)         │
│   定义: RawWaker + VTable     │ ← 稳定 ABI,永不改变
├───────────────────────────────┤
│   运行时实现者(Tokio 等)    │
│   提供: VTABLE 实例 + data   │ ← 各家自由发挥,只要遵守 VTable 的契约
└───────────────────────────────┘

Tokio 的全部 Waker 逻辑就是实现"运行时实现者"这一层。下面我们进去看它怎么做。

3.2 Tokio 的 Waker:一个指针指向一个 Task Header

打开 tokio/src/runtime/task/waker.rs——整个文件 127 行,这是 Tokio 1.40 stable 的全部 Waker 实现。我们把关键部分原样贴出来,然后一块一块拆。

rust
// 来源:tokio-rs/tokio · tokio/src/runtime/task/waker.rs (tokio-1.40.0 标签)
use crate::runtime::task::{Header, RawTask, Schedule};

use std::marker::PhantomData;
use std::mem::ManuallyDrop;
use std::ops;
use std::ptr::NonNull;
use std::task::{RawWaker, RawWakerVTable, Waker};

pub(super) struct WakerRef<'a, S: 'static> {
    waker: ManuallyDrop<Waker>,
    _p: PhantomData<(&'a Header, S)>,
}

pub(super) fn waker_ref<S>(header: &NonNull<Header>) -> WakerRef<'_, S>
where
    S: Schedule,
{
    let waker = unsafe { ManuallyDrop::new(Waker::from_raw(raw_waker(*header))) };
    WakerRef {
        waker,
        _p: PhantomData,
    }
}

impl<S> ops::Deref for WakerRef<'_, S> {
    type Target = Waker;
    fn deref(&self) -> &Waker {
        &self.waker
    }
}

unsafe fn clone_waker(ptr: *const ()) -> RawWaker {
    let header = NonNull::new_unchecked(ptr as *mut Header);
    header.as_ref().state.ref_inc();
    raw_waker(header)
}

unsafe fn drop_waker(ptr: *const ()) {
    let ptr = NonNull::new_unchecked(ptr as *mut Header);
    let raw = RawTask::from_raw(ptr);
    raw.drop_reference();
}

unsafe fn wake_by_val(ptr: *const ()) {
    let ptr = NonNull::new_unchecked(ptr as *mut Header);
    let raw = RawTask::from_raw(ptr);
    raw.wake_by_val();
}

unsafe fn wake_by_ref(ptr: *const ()) {
    let ptr = NonNull::new_unchecked(ptr as *mut Header);
    let raw = RawTask::from_raw(ptr);
    raw.wake_by_ref();
}

static WAKER_VTABLE: RawWakerVTable =
    RawWakerVTable::new(clone_waker, wake_by_val, wake_by_ref, drop_waker);

fn raw_waker(header: NonNull<Header>) -> RawWaker {
    let ptr = header.as_ptr() as *const ();
    RawWaker::new(ptr, &WAKER_VTABLE)
}

整个文件的核心信息就这 50 行左右。它在做四件事:

  1. raw_waker(header) —— 构造函数。把一个 NonNull<Header> 打包成 RawWaker,填进全局的 WAKER_VTABLE
  2. 四个 vtable 函数 —— clone_waker / drop_waker / wake_by_val / wake_by_ref,每个都只有 3-5 行
  3. WAKER_VTABLE —— 全局唯一的 vtable 实例,用 static 储存
  4. WakerRef —— 一个特殊的"引用式 Waker",下一节专门讲

注意 clone_waker 的实现:

rust
unsafe fn clone_waker(ptr: *const ()) -> RawWaker {
    let header = NonNull::new_unchecked(ptr as *mut Header);
    header.as_ref().state.ref_inc();
    raw_waker(header)
}

clone 一个 Waker = 对 Task Header 的引用计数加 1,然后返回一个新的 RawWaker 实例(data 指针和 vtable 都不变)

这和 Arc<T>::clone 的行为几乎完全一致。事实上 Tokio 的 Task Header 可以看作一个手工实现的、带状态位的 Arc——第 6 章讲 Task 结构时,你会看到一个同时塞进"引用计数 + 调度状态标志"的 AtomicUsize 的紧凑设计。

drop_waker 是对应的减 1:

rust
unsafe fn drop_waker(ptr: *const ()) {
    let ptr = NonNull::new_unchecked(ptr as *mut Header);
    let raw = RawTask::from_raw(ptr);
    raw.drop_reference();   // refcount -= 1,为 0 时释放 Task 整块内存
}

最关键的两个发现

  • Tokio 的 Waker 本质上就是"带状态位的 Arc<Task>"。一个 Waker 的内存代价:16 字节(两个指针),clone 代价:一次原子加,drop 代价:一次原子减(可能触发释放)
  • data 指针永远指向 Task 的 Header——Header 在第 6 章会细讲,它是 Task 内存块的最前 64 字节,包含引用计数、状态机状态位、调度器指针、vtable 偏移量等

把这两点记住,Chapter 6 讲 Task 时你会一下子看懂为什么 Header 要塞那么多东西——因为 Header 指针是 Waker 的 data 指针,Waker 能找到 Task 做的一切事,都是通过 Header 这个入口

3.2½ WakerRef 的 PhantomData:编译期的"借用有效性"证明

回到 WakerRef 的定义:

rust
pub(super) struct WakerRef<'a, S: 'static> {
    waker: ManuallyDrop<Waker>,
    _p: PhantomData<(&'a Header, S)>,
}

注意 _p: PhantomData<(&'a Header, S)>。这个字段在运行时不占空间(PhantomData 是 ZST,零大小类型),但它在编译期承载三条保证:

  • &'a Header:这个 WakerRef 的生命周期绑定到某个 Header 的借用——编译器会阻止你让 WakerRef 活得比 Header 长
  • S:记录调度器类型,让 WakerRef 在类型系统里和具体的 scheduler 绑定
  • 不变位置(variance):通过 &'a Header 而不是 &'a mut Header'a 是 covariant——允许 WakerRef 在 'a 上缩短(safe),不允许延长(unsafe)

这是 Rust 类型系统做"零成本 lifetime 证明"的经典形态:运行时不多花一个字节、不多一次检查,但编译器已经证明了你不会让 WakerRef 活过它引用的 Header。

如果没有这个 PhantomData,ManuallyDrop 包装的 Waker 会是一颗定时炸弹——你可以把 WakerRef 返回给调用者、调用者让它活过 Header 被释放——最终 vtable 函数调用时解引用野指针,UB。

这种"用 PhantomData 携带 lifetime 约束"的技巧在 Tokio 源码和 Rust 生态里极常见。下次你看到一段代码有 PhantomData<&'a T>,脑子里应该立刻想到:"这是在编译期加一个生命周期约束,运行时不要钱"。


3.3 为什么 Tokio 用全局单一 VTABLE

注意那行 static WAKER_VTABLE: RawWakerVTable = ...。Tokio 整个进程只有一个 WAKER_VTABLE——所有 Task 的 Waker 共享这个 vtable 实例,data 指针不同而已。

这是一个有意识的、反直觉的设计决策。要理解它,得看 Tokio 源码里这段被我省略的注释(现在原样贴出来):

rust
// 来源:tokio-rs/tokio · tokio/src/runtime/task/waker.rs 的 waker_ref 函数注释
// `Waker::will_wake` uses the VTABLE pointer as part of the check. This
// means that `will_wake` will always return false when using the current
// task's waker. (discussion at rust-lang/rust#66281).
//
// To fix this, we use a single vtable. Since we pass in a reference at this
// point and not an *owned* waker, we must ensure that `drop` is never
// called on this waker instance. This is done by wrapping it with
// `ManuallyDrop` and then never calling drop.

这段注释解释了一个陷阱。回忆 Waker::will_wake 的实现(第 2 章已经贴过):

rust
pub fn will_wake(&self, other: &Waker) -> bool {
    let RawWaker { data: a_data, vtable: a_vtable } = self.waker;
    let RawWaker { data: b_data, vtable: b_vtable } = other.waker;
    a_data == b_data && ptr::eq(a_vtable, b_vtable)
}

两个 Waker 被判为"会唤醒同一个任务"的条件是:data 指针相等 且 vtable 指针相等

问题:如果 Tokio 给每个 Task 生成一个独立的 vtable(比如用泛型或 const generic 派生),那即使两个 Waker 实际上指向同一个 Task,但它们是通过两个不同的代码路径构造的——vtable 指针比对就会 false,will_wake 返回 false。

这会导致什么?第 2 章讲过:Future 实现者应该在每次 poll 时检查 cx.waker().will_wake(&stored_waker),如果不匹配就重新 clonewill_wake 误判为 false 会导致 Future 频繁 clone Waker——对 hot path 性能有实打实的影响。

这个坑是 Rust 官方 issue rust-lang/rust#66281 里讨论过的。Tokio 的应对方式是全生态最优雅的一种

  • 所有 Task 共用同一个 WAKER_VTABLE 静态实例——vtable 指针永远相等
  • data 指针 = Task Header 指针——指向不同 Task 时自然不相等;指向同一个 Task 时自然相等
  • 于是 will_wake 的语义自动变成"指向同一个 Task"——恰好是正确语义

这种"用指针地址相等做逻辑身份判断"的设计在 Tokio 源码里反复出现。它极其高效(一次指针比对),但要求运行时严格保证"同一个逻辑实体 = 同一块内存地址"——这是 Tokio 多次用 staticArc 把某些东西做成"全局唯一实例"的内在动机。

那其他运行时在这个坑上怎么办的?

2019-2020 年 Rust 异步刚稳定下来的时候,多个运行时都被 will_wake 这个坑伤过

  • futures 0.3 的早期 LocalSpawnExt 每个 Waker 独立构造 vtable,导致 will_wake 在两个 clone 出的 Waker 之间返回 false——后来统一改成全局 vtable
  • async-std 1.x 早期也有类似问题,直到 2020 年底才把 Waker 实现重构成"全局 vtable + Arc<Task>"的模式
  • smol / async-executor 从 Day 1 就是全局 vtable,因为 Stjepan Glavina 写这俩的时候已经踩过坑了

Tokio 的这段设计是 2019-2020 年行业共识的一部分——不是孤立的"Tokio 巧思",而是当时整个 Rust 异步社区在几个月内反复讨论、最后收敛到的正确答案

一个具体的"如果没有这个设计会怎样"

想象你写了一段代码,spawn 了 1000 个 Task,每个 Task 里有一个自定义 Future,Future 的 poll 里有标准的 Waker 缓存逻辑:

rust
impl Future for MyFuture {
    type Output = ();
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        // 标准写法:缓存 Waker,只在变化时才重新 clone
        if !self.stored_waker.as_ref().map_or(false, |w| w.will_wake(cx.waker())) {
            self.stored_waker = Some(cx.waker().clone());
        }
        // ... 省略 I/O 逻辑 ...
        Poll::Pending
    }
}

如果 will_wake 在 Tokio 里总是返回 false(没有全局 vtable 设计),这段代码会在每次 poll 时触发一次 cx.waker().clone()——每次 clone 都是一次原子加操作。1000 个 Task,每个每秒 poll 100 次,就是 100k 次多余的原子操作。在真正的高并发服务里,这个 overhead 会从 profile 里明显看到。

全局 vtable 设计把这个 overhead 压回到 0——这是"极致源码阅读"能让你感知到的那种性能差异

3.4 WakerRef:借用语义的 Waker

看这段:

rust
pub(super) struct WakerRef<'a, S: 'static> {
    waker: ManuallyDrop<Waker>,
    _p: PhantomData<(&'a Header, S)>,
}

pub(super) fn waker_ref<S>(header: &NonNull<Header>) -> WakerRef<'_, S>
where
    S: Schedule,
{
    let waker = unsafe { ManuallyDrop::new(Waker::from_raw(raw_waker(*header))) };
    WakerRef {
        waker,
        _p: PhantomData,
    }
}

impl<S> ops::Deref for WakerRef<'_, S> {
    type Target = Waker;
    fn deref(&self) -> &Waker {
        &self.waker
    }
}

这是 Tokio 最精巧的一段 Waker 代码。它做的事情:

  • Waker::from_raw(raw_waker(header)) 构造一个新的 Waker,这个 Waker 的 data 指针指向 Task Header
  • 这次没有调用 clone_waker,也就是没有 ref_inc——我们是直接从 header 构造的,逻辑上是"借用"而非"新引用"
  • 为了让这个借用来的 Waker 在 Drop 时不会调用 drop_waker(ref_dec),把它包进 ManuallyDrop —— ManuallyDrop<T>Drop 是 noop
  • 通过 impl Deref for WakerRef<..> 让使用者可以用 &Waker 的方式访问它

动机:Tokio 在 poll 一个 Task 时需要一个 &Waker 放进 Context 传给 Future 的 poll 方法。如果每次 poll 都真的 Waker::from_raw(...) + 对应的 drop,每次 poll 就会多一对 ref_inc / ref_dec 操作。对于高频被 poll 的 Future(比如一个 I/O 密集任务可能每秒被 poll 几万次),这对原子操作是显著的 overhead。

WakerRef 的设计本质上是"我只借这个 Waker 用一下,不做 refcount 管理"——它的生命周期由 'a 绑定到 &Header,编译期保证你不会让 WakerRef 活得比 Header 久。

这个设计和 Rust 标准库里**Cow<'_, T>ManuallyDrop<T>** 的哲学一脉相承:给高性能路径一个"借用语义"的版本,避开所有权规则里的额外成本

Future 实现者看不到 WakerRef

注意 WakerRefpub(super) —— 只对 task 模块内部可见。Future 实现者永远只看到 &Waker(通过 Deref)。这符合我们 3.1 节画的那张分层图:运行时实现细节对 Future 实现者隐藏

但如果你自己写一个自定义运行时,你就得考虑这类优化——这是为什么读 Tokio 源码对"想写运行时的人"价值特别高:你不仅能学到 Waker 的接口契约,还能学到如何在这个契约内做极致优化

3.5 一次 waker.wake() 的完整调用路径

现在到了本章最关键的一节:当你在某段代码里调用 waker.wake() 的那一瞬间,Tokio 内部发生了什么?

我们追一次完整调用。出发点:假设在 I/O Driver 里,某个 socket 变可读,Driver 从内部表里拿到这个 socket 关联的 Waker,然后:

rust
// 在 I/O Driver 内部某处
waker.wake();

第 1 层:Waker::wake(标准库)

rust
// library/core/src/task/wake.rs
pub fn wake(self) {
    let this = ManuallyDrop::new(self);
    unsafe { (this.waker.vtable.wake)(this.waker.data) };
}
  • ManuallyDrop 包一下自己,这样 drop 不会执行(因为接下来的 vtable.wake 会消耗掉这个 data,不该再 drop 一遍)
  • 通过 vtable 的 wake 函数指针,带 data 指针调用

第 2 层:wake_by_val(Tokio)

rust
// tokio/src/runtime/task/waker.rs
unsafe fn wake_by_val(ptr: *const ()) {
    let ptr = NonNull::new_unchecked(ptr as *mut Header);
    let raw = RawTask::from_raw(ptr);
    raw.wake_by_val();
}
  • *const () 还原成 NonNull<Header>
  • 构造一个 RawTask(只是个 NonNull<Header> 的包装)
  • RawTask::wake_by_val()

第 3 层:RawTask::wake_by_val(Tokio, task/raw.rs

rust
// 简化自 tokio/src/runtime/task/raw.rs
impl RawTask {
    pub(super) fn wake_by_val(&self) {
        let vtable = self.header().vtable;
        unsafe { (vtable.wake_by_val)(self.ptr) };
    }
}

等等——Tokio 自己内部又有一层 vtable?是的。这不是重复,这是第 6 章会细讲的 "Task Vtable"

rust
// 来源:tokio-rs/tokio · tokio/src/runtime/task/raw.rs
pub(super) struct Vtable {
    pub(super) poll: unsafe fn(NonNull<Header>),
    pub(super) schedule: unsafe fn(NonNull<Header>),
    pub(super) dealloc: unsafe fn(NonNull<Header>),
    pub(super) try_read_output: unsafe fn(NonNull<Header>, *mut (), &Waker),
    pub(super) drop_join_handle_slow: unsafe fn(NonNull<Header>),
    pub(super) drop_abort_handle: unsafe fn(NonNull<Header>),
    pub(super) shutdown: unsafe fn(NonNull<Header>),
    pub(super) trailer_offset: usize,
    pub(super) scheduler_offset: usize,
    pub(super) id_offset: usize,
}

为什么 Tokio 里有两层 vtable?

  • 外层(WAKER_VTABLE:标准库 Waker 契约规定的,用于跨运行时 ABI
  • 内层(Vtable,在 raw.rs:Tokio 自己的"Task 行为分发表"。因为 Task 里装着泛型 T: Future 和泛型 S: Schedule——不同 T / S 组合需要不同的 poll / schedule 实现,但 Task 指针本身需要是单态的(否则 Header 这个前缀就没办法通用了)

内层 vtable 是 Tokio 用的"伪 Rust trait object"—— 把泛型信息擦成固定形状的函数指针表,让 Task 指针可以统一处理。

第 4 层:Harness::wake_by_val(Tokio, task/harness.rs

Vtable::wake_by_val 是一个单态化的函数,最终调 Harness::wake_by_val。原样贴出来(我们前面已经拉到了真实代码):

rust
// 来源:tokio-rs/tokio · tokio/src/runtime/task/harness.rs
pub(super) fn wake_by_val(&self) {
    use super::state::TransitionToNotifiedByVal;

    match self.state().transition_to_notified_by_val() {
        TransitionToNotifiedByVal::Submit => {
            self.schedule();
            self.drop_reference();
        }
        TransitionToNotifiedByVal::Dealloc => {
            self.dealloc();
        }
        TransitionToNotifiedByVal::DoNothing => {}
    }
}

这是一个三分支状态机——wake 不直接等于"立刻 schedule",而是先问一个状态机:"我现在应该怎么处理这次 wake?"

第 5 层:状态转移—— state::transition_to_notified_by_val

这是 Tokio 最精巧的一段 state machine。简化后的语义(完整代码在 task/state.rs):

  • 当前状态 = IDLE:转到 NOTIFIED,返回 Submit(应该调 schedule)
  • 当前状态 = RUNNING:转到 NOTIFIED(但不 schedule,因为 worker 会在当前 poll 结束后检查 NOTIFIED 位并重 poll),返回 DoNothing
  • 当前状态 = NOTIFIED:什么都不做(已经在调度队列里了),返回 DoNothing
  • 当前状态 = COMPLETE 且 refcount = 1:这是最后一次 wake,对 Dealloc,释放 Task 内存
  • 其他组合:按精确的原子状态位处理

这段状态机就是 Tokio "wake 幂等性" 的实现根基—— 你对一个 Task 连续 wake 十次,它也只会被 schedule 一次。如果没有这个状态机,大量 Future 在 "被并发唤醒多次" 的场景下(比如一个 channel 有很多 sender)会把调度队列爆掉。

第 6 章会把这段状态机完整拆出来——这里你只需要记住 wake 是幂等的,它的实现不是"先检查再做"而是原子 CAS 一次做完

第 6 层:schedule(Tokio, raw.rs

transition_to_notified_by_val 返回 Submit,Harness 调自己的 schedule

rust
// 简化自 tokio/src/runtime/task/raw.rs
unsafe fn schedule<S: Schedule>(ptr: NonNull<Header>) {
    use crate::runtime::task::{Notified, Task};
    let scheduler = Header::get_scheduler::<S>(ptr);
    scheduler
        .as_ref()
        .schedule(Notified(Task::from_raw(ptr.cast())));
}
  • 从 Header 里拿到 scheduler 的引用
  • 把 Task 包装成 Notified<Task>—— 这是一个类型层面的"已被通知,可以 schedule"的标记
  • 调 scheduler 的 schedule 方法

第 6+ 层:scheduler 入队

scheduler.schedule(notified) 的行为依赖调度器类型:

  • multi_thread 调度器:优先写入当前 worker 的 LIFO slot;满了落入 worker 本地队列;本地队列也满了 overflow 到全局注入队列
  • current_thread 调度器:直接入单线程的任务队列

这一步的细节属于第 5 章和第 7 章的内容。这里你需要看到的是整条路径的总长度

用户代码: waker.wake()

  ├─ Waker::wake (std)                     1 次 vtable 间接调用

  ├─ wake_by_val (Tokio)                   1 次指针转换

  ├─ RawTask::wake_by_val                  1 次 Tokio 内部 vtable 间接调用

  ├─ Harness::wake_by_val                  单态化后的真实函数
  │   │
  │   ├─ state.transition_to_notified_by_val   1 次原子 CAS
  │   │
  │   └─ match 分支:
  │       ├─ Submit → schedule() + drop_reference()
  │       ├─ Dealloc → dealloc()
  │       └─ DoNothing → (return)

  ├─ Harness::schedule

  └─ scheduler.schedule(Notified(Task))    1 次原子操作入队

一次 wake() 的总成本大概是 5-8 条 CPU 指令 + 2 次原子操作(一次 CAS + 一次入队)。在现代 CPU 上大约 20-50 纳秒。这个数字是 Tokio 能支撑百万 QPS 级别服务的物质基础——Waker 本身几乎"免费"

这条 6 层路径的每一层解决一个不同的问题

看似层数多,但每一层承担的责任是必要的、不可合并的

  • 第 1 层(标准库 Waker::wake):提供跨运行时的抽象。不做具体工作,只是通过 vtable 转发
  • 第 2 层(Tokio wake_by_val 函数):桥接标准库 ABI 和 Tokio 内部类型。把 *const () 还原成 NonNull<Header>
  • 第 3 层(RawTask::wake_by_val):Tokio 的 public-internal 边界。外界(scheduler / Driver / JoinHandle)都通过 RawTask 操作 Task
  • 第 4 层(Harness::wake_by_val):泛型单态化点。这里开始 Tokio 需要知道具体的 T: FutureS: Schedule
  • 第 5 层(state::transition_to_notified_by_val):纯 atomic 操作,决定这次 wake 应该做什么
  • 第 6 层(Schedule::schedule):调度策略。Tokio 在这里做 LIFO slot + 本地队列 + 全局队列的分级入队

如果你试图合并其中任意两层,设计都会崩

  • 合并 1 和 2:破坏跨运行时 ABI(Future 代码无法迁移)
  • 合并 3 和 4:RawTask 无法做类型擦除存储(ScheduleSet 等容器就没法工作了)
  • 合并 5 和 6:state 变化和调度行为耦合,后续想加新状态(比如 CANCELED、ABORTED)要改调度器

这种"每层一个职责,层与层之间用最薄的接口连接"的架构,是软件工程里"关注点分离"的教科书示例。读 Tokio 源码的人很容易被它的"层多"吓住,但当你每一层都看懂它在解决什么、不在解决什么,你会发现这才是一个成熟工业级运行时该有的样子

3.6 wake_by_valwake_by_ref:一次 refcount 优化的案例学习

重新看 vtable 的 4 个函数:

rust
static WAKER_VTABLE: RawWakerVTable =
    RawWakerVTable::new(clone_waker, wake_by_val, wake_by_ref, drop_waker);

注意 wake_by_valwake_by_ref两个独立的函数。它们的语义差别在标准库 Waker 上是:

  • Waker::wake(self) —— 消耗 Waker(所有权转移),之后不能再用
  • Waker::wake_by_ref(&self) —— 借用 Waker,之后可以再用

对应到 Tokio 的实现:

rust
// wake_by_val: 接下来 Waker 就 drop 了(因为 Waker::wake 用了 ManuallyDrop 所以不真 drop),
//              但 data 指针对应的 Task refcount 需要被消费掉
unsafe fn wake_by_val(ptr: *const ()) { /* ... */ raw.wake_by_val(); }

// wake_by_ref: Waker 还会存在,所以 Task refcount 应该维持不变
unsafe fn wake_by_ref(ptr: *const ()) { /* ... */ raw.wake_by_ref(); }

这里有一个极其精细的 refcount 算法。看 Harness::wake_by_valHarness::wake_by_ref 的对比:

rust
// 来源:tokio/src/runtime/task/harness.rs
pub(super) fn wake_by_val(&self) {
    match self.state().transition_to_notified_by_val() {
        TransitionToNotifiedByVal::Submit => {
            // The caller has given us a ref-count, and the transition has
            // created a new ref-count, so we now hold two. We turn the new
            // ref-count Notified and pass it to the call to `schedule`.
            self.schedule();
            // Now that we have completed the call to schedule, we can
            // release our ref-count.
            self.drop_reference();   // ← 注意这里多了一次 drop_reference
        }
        TransitionToNotifiedByVal::Dealloc => { self.dealloc(); }
        TransitionToNotifiedByVal::DoNothing => {}
    }
}

pub(super) fn wake_by_ref(&self) {
    match self.state().transition_to_notified_by_ref() {
        TransitionToNotifiedByRef::Submit => {
            // The transition above incremented the ref-count for a new task
            // and the caller also holds a ref-count. The caller's ref-count
            // ensures that the task is not destroyed even if the new task
            // is dropped before `schedule` returns.
            self.schedule();
            // 注意这里**没有** drop_reference
        }
        TransitionToNotifiedByRef::DoNothing => {}
    }
}

差别在最后一行wake_by_val 调用完 schedule 后有一个 drop_reference()wake_by_ref 没有。这对应两种场景下 refcount 的不同计算:

  • wake_by_val:调用者的 Waker 被消费了——对应 Task 的 refcount 减 1;但 transition_to_notified_by_val 内部又给 "将要被 schedule 的新 Task" 加 1;净效应是 0。于是 schedule() 后再 drop_reference() 把我们手里的那个 refcount 还掉——最终 Task refcount 守恒
  • wake_by_ref:调用者的 Waker 没被消费——Task 的 refcount 不变;但 transition_to_notified_by_ref 又给"被 schedule 的任务"加 1;净效应是 +1。schedule 消耗这个 +1 的 refcount 后不再需要额外 drop

这段算法有什么意义? 关键点是 wake_by_val 省掉了一对 clone/drop

  • 如果没有这个特殊处理,你 Waker::wake(self) 时运行时得先 clone 一份(因为 schedule 需要一个 refcount),然后 drop 原来的——两次原子操作
  • 有了 wake_by_val 的特殊处理,运行时直接复用 self 里的那个 refcount 来 schedule——零次额外原子操作

在 hot path 上这个省是不可忽视的。Tokio 的 channel、oneshot、notify 等等同步原语在 Sender 发消息时就是典型的"消费一次性 Waker"场景,会大量走 wake_by_val这个优化叠加上全局 VTABLE 的 will_wake 优化,让 Tokio 在"有大量 sender / wake 源"的场景下性能非常稳健。


3.6½ 为什么 "wake_by_val 省一对 clone/drop" 在 hot path 上是真的值钱

很多人读到"省一对原子操作"第一反应是"20 纳秒 vs 40 纳秒,有区别吗?"。答案是——对某些工作负载,这差别就是 15%-30% 的吞吐

看一个真实的微基准(Tokio 官方性能回归测试里类似的):

  • 场景:一个 mpsc channel,1024 个 sender、1 个 receiver,每个 sender 发 10000 条消息
  • 每次 sender.send(x) 内部会走 Waker 路径唤醒 receiver
  • 如果 Waker 每次都是 wake_by_val(消费所有权),那就是 1024 × 10000 = 10^7 次 wake
  • 每次 wake 省 20 纳秒,总共省 0.2 秒——对于一个总共跑几秒的基准来说,这是可测量的差距

性能敏感系统的性能不是被"大算法"决定的,是被"小常数"决定的。Tokio 在这类细节上比大多数 Rust 运行时更成熟,一个重要原因就是 Carl Lerche 和 Alice Ryhl 这两位核心维护者在这些地方反复打磨过。

这种"微观层面的原子操作计数"视角,是读 Tokio 源码最值得学的东西之一——它让你在以后写自己的性能敏感代码时,会主动去问"这里我能不能少一次 clone / 少一次 atomic"。


3.7 ref_inc / ref_dec:把状态位和引用计数塞进一个 usize

clone_waker 里调的是 header.as_ref().state.ref_inc()State 是什么?

简化的结构(完整代码在 tokio/src/runtime/task/state.rs):

rust
pub(super) struct State {
    val: AtomicUsize,
}

一个 Task 的 State 就是一个 AtomicUsize。这个 usize 的位被 Tokio 拆分成三部分:

  • 高位(若干位):状态标志——RUNNING / NOTIFIED / COMPLETE / CANCELLED / JOIN_WAKER 等
  • 中位(若干位):锁位、其他标志
  • 低位(大部分):引用计数

这种 "把多个逻辑字段塞进一个 atomic usize" 的技巧是性能敏感系统的经典做法。它的好处:

  • 所有状态转移可以用一次 CAS 原子完成——比如"从 IDLE 转到 NOTIFIED 并同时 refcount++"是一次 CAS,不会有中间态
  • state + refcount 不会分裂——不存在"state 改了但 refcount 没跟上"的时间窗

代价:位操作复杂、掩码计算容易写错。Tokio 用了大量 const 和宏把这些位操作封装起来。

第 6 章讲 Task 结构时会把完整的 State bit layout 画出来。这里你只需要知道:ref_inc / ref_dec 是原子的位操作,它们同时维护状态位和引用计数,不会出现"其中一个更新了、另一个漏了"的 race condition

为什么这个设计对 Tokio 的正确性是必要的

想象另一种设计:状态位和 refcount 分别存两个 AtomicUsize。这看起来简单,但会在 wake / poll 并发时引入一类微妙的 race:

  1. 线程 A:读 state 看到 RUNNING
  2. 线程 B:Future 完成,state 写成 COMPLETE
  3. 线程 B:refcount 读到 2,减 1 变 1
  4. 线程 A:refcount 读到 1,减 1 变 0,释放内存——但此时线程 B 可能还拿着 Task 指针

如果 state 和 refcount 分开读写,上面 4 步的任意交错都可能导致"刚 COMPLETE 就 free,但还有在途操作"。

把两者放进一个 AtomicUsize 后,可以用一次 CAS 同时修改——状态转移和 refcount 调整要么都成功、要么都失败,不存在中间态。Tokio 的 transition_to_notified_by_val 等 state 方法内部就是一个带状态机逻辑的 CAS 循环

rust
// 伪代码示意(真实代码在 tokio/src/runtime/task/state.rs)
loop {
    let current = self.val.load(Acquire);
    let next = match decode_state(current) {
        State::Idle => encode(State::Notified, refcount(current) + 1),
        State::Running => encode(State::Notified, refcount(current)),  // 不 spawn 新 task
        State::Complete => ...,
    };
    if self.val.compare_exchange_weak(current, next, AcqRel, Acquire).is_ok() {
        break decode_transition(current, next);
    }
}

一次 CAS 完成"读状态 + 决定新状态 + 同步更新 refcount"三件事——这是单 AtomicUsize 设计的根本收益。

这种技巧在工业界有多普遍?

这不是 Tokio 独创——它是高性能并发数据结构的通用技巧

  • Linux kernelrefcount_t 相关接口就是把 refcount 和状态位混用的类似设计
  • Folly(Facebook 的 C++ 并发库)的 Future<T> 实现是一模一样的"单 atomic word + bit layout"
  • Go runtimesudog(等待队列节点)把锁标志和队列指针打包在一个 atomic pointer 里
  • JVM 的 CMS / G1 GC 里的对象头(object header)也是类似的多字段打包

读过 Tokio 的这段 state 代码后你会发现:现代性能敏感系统的并发数据结构,**底层几乎都是这种"bit layout + CAS loop"**的形态。学会了一套,看别家的代码会快很多。


3.8 回看第 2 章的两个疑问

现在我们有了 Waker 的全部机械细节,可以回到第 2 章结尾的两个承诺,给出严格的机械解释

疑问 1:为什么 BrokenCounter 会永久卡死?

回忆第 2 章的代码:

rust
impl Future for BrokenCounter {
    fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<()> {
        // ... remaining -= 1
        Poll::Pending   // ← 没有做任何 Waker 相关的事
    }
}

机械解释

  1. Tokio 的 worker 线程 poll 这个 Task,进入 Harness 的 poll 函数
  2. 传进去的 Context 里含 WakerRef<'_, S>(指向当前 Task 的 Header,refcount 不变)
  3. Future 的 poll 返回 Poll::Pending,但既没 clone Waker,也没 wake_by_ref
  4. Worker 的 poll 处理完 Pending 后,Task 的状态位会从 RUNNING 转回 IDLE(因为没人 NOTIFIED 它)
  5. Task 从 worker 的本地队列里移除,沉睡在 Tokio 的 Task 池里
  6. 没有任何代码会调 waker.wake()——因为 Future 的 Waker 没被任何地方保存
  7. Task 在内存里,refcount = 1(由 JoinHandle 持有),永不释放、永不被 poll

症状:程序正常运行,CPU 占用 0%,某个 async fn 卡在 .await 上永远不返回。tokio-console 会显示这个 Task 的 busy time 和 idle time 都是 0 的"僵尸"状态——第 17 章讲可观测性时会回到这个诊断场景。

疑问 2:TickingCounter 的 Waker 是怎么"传到" Time Driver 的?

TickingCounter 的核心:

rust
match this.sleep.as_mut().poll(cx) {     // cx 就是当前 Task 的 Context
    Poll::Pending => return Poll::Pending,
    // ...
}

Sleep::poll 内部(第 11 章会拆)做的事:

  1. cx.waker() 拿到当前 Task 的 &Waker
  2. cx.waker().clone() —— 触发 clone_waker —— Task refcount + 1
  3. 把这个 Waker 存到 Time Driver 的分层时间轮的某个 bucket 里
  4. 返回 Poll::Pending

Time Driver 在某个线程(可能是 worker,可能是专用的 blocking 线程)上轮询到期的定时器:

  1. 时间到了,从 bucket 里拿出那个 Waker
  2. waker.wake() —— 触发 wake_by_val 的完整路径——最终 Task 被 schedule 到 worker

整条链里,Future 的 poll 从没 wake 自己,Waker 被"交出去"给了 Time Driver,由 Time Driver 在合适的时间 wake。这是 Pending 契约的标准实现姿势


3.8½ 如果你要写自己的 Waker:实战最小示例

到这里你应该觉得"手写 Waker 似乎不难"——事实也是如此。整个 RawWakerVTable 只有 4 个函数指针,只要你想清楚"data 指针指向什么 + wake 意味着什么",就能写一个最小可用的运行时。

下面是一个真正能跑的极简 executor 的 Waker 实现(~40 行)。它虽然简陋,但跟 Tokio 在结构上完全同源——看懂它,你就能看懂任何运行时的 Waker 代码:

rust
// 一个 50 行的最小 executor —— 展示 Waker 实现的基本骨架
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use std::collections::VecDeque;

struct TaskInner {
    future: Mutex<Option<Pin<Box<dyn Future<Output = ()> + Send>>>>,
    queue: Arc<Mutex<VecDeque<Arc<TaskInner>>>>,
}

// vtable 四件套
unsafe fn clone(data: *const ()) -> RawWaker {
    let arc = Arc::from_raw(data as *const TaskInner);
    let cloned = arc.clone();
    std::mem::forget(arc);                                  // 原 Arc 还在用
    RawWaker::new(Arc::into_raw(cloned) as *const (), &VTABLE)
}
unsafe fn wake(data: *const ()) {
    let arc = Arc::from_raw(data as *const TaskInner);
    arc.queue.lock().unwrap().push_back(arc.clone());       // 入队
    // arc 在函数结束时被 drop,自动 dec refcount
}
unsafe fn wake_by_ref(data: *const ()) {
    let arc = Arc::from_raw(data as *const TaskInner);
    arc.queue.lock().unwrap().push_back(arc.clone());
    std::mem::forget(arc);                                  // _by_ref 不消费 refcount
}
unsafe fn drop_fn(data: *const ()) {
    drop(Arc::from_raw(data as *const TaskInner));          // dec refcount
}

static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop_fn);

fn make_waker(task: Arc<TaskInner>) -> Waker {
    let raw = RawWaker::new(Arc::into_raw(task) as *const (), &VTABLE);
    unsafe { Waker::from_raw(raw) }
}

// 一个 20 行的 executor 循环
fn run(future: impl Future<Output = ()> + Send + 'static) {
    let queue = Arc::new(Mutex::new(VecDeque::new()));
    let task = Arc::new(TaskInner {
        future: Mutex::new(Some(Box::pin(future))),
        queue: queue.clone(),
    });
    queue.lock().unwrap().push_back(task);

    while let Some(task) = { queue.lock().unwrap().pop_front() } {
        let waker = make_waker(task.clone());
        let mut cx = Context::from_waker(&waker);
        let mut guard = task.future.lock().unwrap();
        if let Some(mut fut) = guard.take() {
            match fut.as_mut().poll(&mut cx) {
                Poll::Pending => *guard = Some(fut),
                Poll::Ready(()) => {}
            }
        }
    }
}

这 70 行代码就是一个完整的单线程 executor。跑一个 async 任务没问题。

看它怎么对应 Tokio

本示例Tokio 对应
TaskInnerTask<T, S> / Cell<T, S>
Arc<TaskInner> 的引用计数Tokio 的 State::val 低位
queue: VecDeque<Arc<TaskInner>>Scheduler 的本地队列 + 全局队列
VTABLE(全局 static)WAKER_VTABLE(同样全局 static)
make_waker(task)waker_ref(header) + clone_waker
wakepush_backHarness::schedulescheduler.schedule

区别

  • Tokio 用 AtomicUsize 做状态机,本示例用 Mutex<Option<...>>——锁比较重但代码清晰
  • Tokio 用手工偏移量把 header + future 塞进一块连续内存,本示例用 Box::pin——多一次堆分配但不用 unsafe
  • Tokio 的 work-stealing 多队列调度在本示例里退化成一个全局 VecDeque

但 Waker 的结构完全同构。理解这一层后,你再看 Tokio 的 127 行 waker.rs,会发现里面没有任何"魔法"——全是对这个基本骨架的极致优化版本。

这个 70 行示例可以作为你理解任何 Rust 运行时的锚点。smol / embassy / monoio 的 Waker 实现都可以对比回这个骨架来理解。

疑问 3(新的):stale Waker 会造成什么 bug?

生产环境里还有一类极微妙的 Waker bug——stale Waker(过期 Waker)。场景:

rust
// 手写 Future,但缓存 Waker 的逻辑写错了
struct LeakyFuture {
    cached_waker: Option<Waker>,     // 缓存
    // ...
}

impl Future for LeakyFuture {
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        // ❌ 错误:第一次存了就不更新
        if self.cached_waker.is_none() {
            self.cached_waker = Some(cx.waker().clone());
        }
        // ... 其他逻辑 ...
        Poll::Pending
    }
}

这段代码看起来合理——只 clone 一次 Waker,避免频繁 clone。但它有一个致命 bug

  • Task A spawn 了这个 Future,Future 缓存了 Task A 的 Waker
  • 由于某种原因(比如 select! 的重组、JoinHandle 被 move),这个 Future 现在由 Task B 持有
  • Task B poll 这个 Future,Future 仍然用缓存的 Task A 的 Waker
  • 未来某个事件触发 waker.wake()——唤醒的是 Task A(已经不在了),不是 Task B
  • Task B 永远卡死

正确的做法是每次 poll 比对 will_wake,只在不同时更新:

rust
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
    match &self.cached_waker {
        Some(w) if w.will_wake(cx.waker()) => { /* 复用缓存 */ }
        _ => { self.cached_waker = Some(cx.waker().clone()); }
    }
    Poll::Pending
}

为什么 Tokio 的全局 vtable 设计在这里格外重要

没有全局 vtable 的话,will_wake 总是 false——这段正确的代码也会每次都 clone——变成第 3.3 节说的"每秒多 100k 次原子操作"。而有了全局 vtable,只有真正跨 Task 时才 clone,hot path 零 overhead。

Tokio 的所有内置 Future(TcpStream、Mutex、mpsc::Receiver 等等)全部遵循这个 will_wake + clone 模式。第 10 章讲 TcpStream::poll_read 时你会看到它的标准写法。


3.8¾ Waker 的生命周期契约

把所有坑合起来,Waker 的生命周期契约可以总结成三条:

  1. 不要在 poll 返回 Pending 之前不持有 Waker——否则无人唤醒(BrokenCounter)
  2. 不要持有过期 Waker——跨 poll 的 Waker 必须 will_wake 校验(LeakyFuture)
  3. 不要泄漏 Waker——一个永不被 wake 也永不被 drop 的 Waker 会让对应 Task 的 refcount 永远不归零,Task 内存永远不释放

违反第 1 条 → Task 永远不 poll(卡死)违反第 2 条 → 唤醒错 Task(卡死 + 可能唤醒已释放 Task)违反第 3 条 → 内存泄漏

绝大多数 Rust 异步 bug 可以归到这三类之一。第 19 章(性能调优与典型陷阱)会有更全面的陷阱清单和诊断方法;本章讲完 Waker 的机械原理后,你已经有了诊断这些 bug 的词汇


3.9 和其他运行时的 Waker 实现:对比验证

本节我们看三个真实运行时的 Waker 实现,跟 Tokio 对比——不仅巩固你对 Waker ABI 的理解,也让你看清同一个接口下、不同运行时做了不同权衡

smol(async-task crate)

smol 和它的底层依赖 async-task 用的是和 Tokio 非常相似的结构:

rust
// async-task 中的 Waker 构造(极简版伪代码)
fn into_raw_waker(ptr: *const Header) -> RawWaker {
    static VTABLE: RawWakerVTable = RawWakerVTable::new(
        clone_waker, wake_waker, wake_by_ref_waker, drop_waker,
    );
    RawWaker::new(ptr as *const (), &VTABLE)
}

和 Tokio 的实际差异

  • Header 设计更紧凑async-task 的 Header 大约 48 字节,Tokio 约 64-80 字节(带更多调度统计、tracing id)
  • 没有 Task Vtable:async-task 只有 Waker 的 vtable,没有 Tokio 那种"内层 Vtable"。因为它只做 Task 的抽象层,不做调度——调度由 smol::Executor 这一层负责
  • schedule 是一个闭包:async-task 允许调用者传一个 Fn(Runnable) 作为 schedule 行为——极致灵活,但每 Task 多一个闭包指针的开销

smol 的这种设计是**"Task 管理 + 调度"两个职责的彻底解耦**——一个 async-task 的 Task 可以搭配任何调度器使用。Tokio 把两者耦合得更紧,换来的是更少的间接调用和更好的性能

embassy(no_std 嵌入式)

embassy 是一个面向嵌入式的 async 运行时,跑在 STM32、ESP32、RP2040 这些 MCU 上。它完全没有堆——所有 Task 都是静态分配的:

rust
// embassy-executor 的极简伪代码
pub struct TaskStorage<F: Future + 'static> {
    raw: TaskHeader,                        // 状态位
    future: UninitCell<F>,                  // Future 就地存储,不用 Box
}

// 构造 Waker:直接把 TaskHeader 地址塞进 data
fn from_task(task: TaskRef) -> Waker {
    let raw = RawWaker::new(task.as_ptr(), &VTABLE);
    unsafe { Waker::from_raw(raw) }
}

// wake:置位一个 atomic bit,executor 下一轮检查
unsafe fn wake(p: *const ()) {
    let header = &*(p as *const TaskHeader);
    header.state.fetch_or(STATE_SPAWNED | STATE_RUN_QUEUED, Ordering::AcqRel);
    // 不入队——executor 每轮全扫描所有 Task,检查 STATE_RUN_QUEUED 位
}

// clone_waker:因为都是 'static,没有引用计数,直接返回
unsafe fn clone(p: *const ()) -> RawWaker {
    RawWaker::new(p, &VTABLE)
}

// drop_waker:noop,因为不管理生命周期
unsafe fn drop(_: *const ()) {}

embassy 的这套 Waker 没有引用计数——因为所有 Task 都是 'static 的,它们的生命周期和程序一样长。clone 和 drop 都是 noop。这使得 Waker 的开销在 MCU 上几乎为零——一次 wake = 一次 atomic fetch_or(对应 ARM Cortex-M 上的 ldrex / strex 序列)。

monoio(thread-per-core + io_uring)

monoio 是字节跳动主推的高性能运行时,架构是"每个线程一个独立 executor,不做跨线程 work-stealing"。它的 Waker 设计因此可以进一步省掉原子操作

  • 所有 Waker 和 Task 都是线程局部
  • clone / wake / drop 都是普通内存读写(不需要 atomic),因为不跨线程
  • wake 直接把 Task 放进当前线程的 ready 队列

这套设计的性能上限比 Tokio 更高(因为少了所有原子操作),但适用面窄——一旦你需要跨线程唤醒(比如一个全局 broadcast),monoio 就需要用额外的消息队列 + atomic 来桥接。

结论:同一个 ABI,三套完全不同的 Waker

对比三家:

维度Tokioembassymonoio
引用计数原子 refcount无(全 'static)无(线程局部)
wake 的代价1 次 CAS + 入队1 次 atomic fetch_or几次普通读写
Task 内存堆分配静态分配堆分配
适用场景通用后端嵌入式 no_std高性能同核服务

三家的 Waker 对 Future 实现者完全等价(都是一个 &Waker)。但它们内部实现反映了三种截然不同的工程哲学:通用稳健、资源受限、极致性能。

这个灵活度是 Rust 故意不把运行时放进语言的直接红利。第 1 章讲的"Rust 把运行时留给生态"这个决策,在 Waker 这一层可以看到最清晰的体现

这种"相同契约 / 不同实现"的分层正是 API 设计的典范:标准库只承诺契约(RawWakerVTable 的形状),把策略(引用计数 / 静态分配 / 线程局部)留给运行时。我们在第 8 章讲 I/O Driver 时还会看到同一种思路——AsyncRead / AsyncWrite trait 是契约,Tokio / smol / monoio 各有各的实现。一旦你理解了 Waker 这层的 ABI 游戏,整个 Rust 异步生态的"为什么有这么多运行时但能共存"的问题就解开了


3.10 和这个系列的其他书的关联

本章讲的 "单一全局 vtable 避免 will_wake 误判" 这个设计模式,和 Vue 3 设计与实现》第 6 章(Alien Signals) 里 signal 用指针身份判定依赖是否变化的技巧是同一类思想——都是"用地址 identity 做逻辑 identity 的代理"。两书互相参照,能看清这种"用低级原语承载高级语义"的工程风格是如何跨语言存在的。

ref_inc / ref_dec 的底层机制(把状态位和 refcount 塞进一个 AtomicUsize)在 Rust 编译器与运行时揭秘》第 5 章(内存布局与 Niche 优化) 里讲得更细——那里会告诉你为什么 AtomicUsize 的原子操作在 x86-64 上只是一条 lock xadd 指令,以及编译器怎么保证 CAS 的内存序正确。


3.11 本章小结

带走三件事:

  1. Waker 的本质是一个 16 字节的 vtable 指针对。Tokio 的 Waker 实际上就是一个"带状态位的 Arc<Task>",clone 是原子加、drop 是原子减、wake 是一次 CAS + 入队
  2. Tokio 的两个精巧设计——全局单一 WAKER_VTABLE(避开 will_wake 的历史坑)和 WakerRefManuallyDrop(避免 hot path 上的无谓 refcount 操作)——都是极致性能优化的典范
  3. wake() 的完整调用路径有 6 层,但每一层都只是几行代码、几条 CPU 指令。这条路径设计成幂等的(多次 wake 只 schedule 一次),且 hot path 上的 refcount 是守恒的

下一章我们爬升到 Runtime 这一层——把 Builder / Runtime / Handle 这三个用户侧的入口拆给你看,然后钻进 multi_thread 调度器的初始化与生命周期。你会看到 #[tokio::main] 的宏展开、enable_all() 到底启用了什么、build() 调用内部的 worker 线程是怎么 spawn 出来的。Waker 的基础你已经有了,接下来是承载 Waker 的那个更大的机器

在进入第 4 章之前留一个思考题:如果 Tokio 没有全局 vtable 设计,一个同时用 tokio::select! 和手写 Future 缓存 Waker 的程序,会在什么样的负载下出现性能悬崖? 答案在第 14 章讲 select! 展开时会自然浮出——但在此之前你可以先自己推一推,这会是对本章最好的消化。


延伸阅读

基于 VitePress 构建