Tokio 源码深度解析
第3章 Waker 机制:唤醒的本质
本章要点
RawWaker+RawWakerVTable是 Rust 异步生态的唯一跨运行时接口。任何运行时(Tokio、smol、embassy、monoio)都必须通过这对类型来提供 Waker,Future 实现者永远只看到&Waker- Tokio 1.40 在
tokio/src/runtime/task/waker.rs里用102 行 Rust 代码实现了全部 Waker 语义——本章会把这 102 行逐块拆掉 - Tokio 采用 "全局单一 VTABLE + Task Header 指针作 data" 的设计,背后是
rust-lang/rust#66281这个 will_wake 的历史坑 WakerRef的ManuallyDrop<Waker>技巧让 runtime 内部可以"临时伪装成有一个 Waker"而不真的触发 clone / drop 的 refcount 操作——这是 Tokio hot path 性能的关键细节之一- 一次
waker.wake()走过的完整路径有 6 层:Waker::wake(经WAKER_VTABLE.wake转发)→waker.rs::wake_by_val(ptr)→RawTask::wake_by_val(tokio/src/runtime/task/harness.rs:67)→state::transition_to_notified_by_val的 CAS →RawTask::schedule经内层 vtable 到单态化的raw.rs::schedule::<S>→S::schedule(Notified(Task))入队 wake_by_val与wake_by_ref的区别不是"取不取走所有权"那么简单,而是两套不同的 refcount 账本:_by_val在不需要入队的分支里把"还掉调用方那份引用"并进状态机的同一次 CAS;需要入队时,Tokio 1.40 反而刻意把那份引用留到schedule返回后再还
3.1 回到原点:RawWaker + vtable 是跨运行时的唯一接口
第 2 章已经把 RawWaker 和 RawWakerVTable 的 struct 摆了出来。这里我们把它们放在Rust 异步生态的接口层级上再看一次——只要一个 Future 能拿到 &mut Context<'_>,它就能用其中的 Waker,不需要知道这个 Waker 是哪个运行时创建的:
// 来源:rust-lang/rust · library/core/src/task/wake.rs:22 与 :109(删去 doc 注释)
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 异步生态有三类角色:
- 语言:提供
Futuretrait 和async / await语法 - 标准库:提供
Waker/Context/Poll这些只定义接口形状、不含实现的类型 - 运行时:提供
Waker的实际行为(wake 意味着什么、clone 要不要分配、drop 要不要释放)
标准库里的 Waker 把上述 RawWaker 和 vtable 包了一下:
#[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) }
}
// ...
}
两个关键细节:
Waker是#[repr(transparent)]的单字段 struct,内存布局等同于RawWaker——两个指针,16 字节(64 位平台)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 异步接口 的分水岭:
┌───────────────────────────────┐
│ Future 实现者(你、库作者) │
│ 只看到: &Waker │ ← 稳定 API,用 wake() / clone() / will_wake()
├───────────────────────────────┤
│ 标准库 (core::task) │
│ 定义: RawWaker + VTable │ ← 稳定 API(按 Rust 稳定性承诺不做破坏性修改;不承诺二进制布局)
├───────────────────────────────┤
│ 运行时实现者(Tokio 等) │
│ 提供: VTABLE 实例 + data │ ← 各家自由发挥,只要遵守 VTable 的契约
└───────────────────────────────┘
Tokio 的全部 Waker 逻辑就是实现"运行时实现者"这一层。下面我们进去看它怎么做。
3.2 Tokio 的 Waker:一个指针指向一个 Task Header
Tokio 选择了把 Waker 的 data 指针直接指向 Task Header——这是一个极简但极快的设计。Task Header 里有引用计数、状态位、调度入口——Waker 拿到这个指针就拿到了唤醒 Task 所需的全部上下文。这种"指针 = 身份 + 能力"的设计让 Waker 本身不需要存储任何额外信息——16 字节的 Waker 背后是整块 Task 数据。这是系统编程里"用小代价承载大能力"的经典范例。
打开 tokio/src/runtime/task/waker.rs——整个文件 102 行,这是 Tokio 1.40.0 的全部 Waker 实现。我们把关键部分贴出来(省略了 doc 注释、cfg_trace! / cfg_not_trace! 两段宏定义和每个 vtable 函数里的 trace! 调用),然后一块一块拆。
// 来源:tokio-rs/tokio · tokio/src/runtime/task/waker.rs (tokio-1.40.0 标签,节选:省略 trace! 相关代码与注释)
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 行左右。它在做四件事:
raw_waker(header)—— 构造函数。把一个NonNull<Header>打包成RawWaker,填进全局的WAKER_VTABLE- 四个 vtable 函数 ——
clone_waker/drop_waker/wake_by_val/wake_by_ref,每个都只有 3-5 行 WAKER_VTABLE—— 全局唯一的 vtable 实例,用static储存WakerRef—— 一个特殊的"引用式 Waker",下一节专门讲
注意 clone_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)
}
clone 一个 Waker = 对 Task Header 的引用计数加 1,然后返回一个新的 RawWaker 实例(data 指针和 vtable 都不变)。
这和 Arc<T>::clone 的行为几乎完全一致。事实上 Tokio 的 Task Header 可以看作一个手工实现的、带状态位的 Arc——第 6 章讲 Task 结构时,你会看到一个同时塞进"引用计数 + 调度状态标志"的 AtomicUsize 的紧凑设计。
而 drop_waker 是对应的减 1:
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>"(只是类比:Tokio 并没有用Arc,引用计数打包在 Header 的state里)。一个 Waker 的内存代价:16 字节(两个指针),clone 代价:一次原子加,drop 代价:一次原子减(可能触发释放) - data 指针永远指向 Task 的 Header——Header 在第 6 章会细讲,它是
#[repr(C)]的Cell<T, S>的第一个字段(tokio/src/runtime/task/core.rs:115),1.40.0 里只有四个指针宽的成员(tokio/src/runtime/task/core.rs:150;不计tokio_unstable+tracing下才有的tracing_id):state(把状态位和引用计数打包进一个AtomicUsize)、queue_next、vtable、owner_id。调度器本身存在Core里(tokio/src/runtime/task/core.rs:139),Header 靠vtable指向的那张表里记录的scheduler_offset等偏移量找到它
把这两点记住,Chapter 6 讲 Task 时你会一下子看懂为什么 Header 要塞那么多东西——因为 Header 指针是 Waker 的 data 指针,Waker 能找到 Task 做的一切事,都是通过 Header 这个入口。
3.3 WakerRef 的 PhantomData:编译期的"借用有效性"证明
回到 WakerRef 的定义:
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 的生命周期绑定到传入的&NonNull<Header>这次借用——编译器会阻止你让 WakerRef 活得比这次借用长;Header 本身在这段时间里不被释放,靠的是 poll 方手里那份引用计数S:记录调度器类型,让 WakerRef 在类型系统里和具体的 scheduler 绑定- 型变(variance):
&'a Header对'a是 covariant(换成&'a mut Header对'a也同样是 covariant)——允许 WakerRef 在'a上缩短(safe),不允许延长(unsafe)
这是 Rust 类型系统做"零成本 lifetime 证明"的经典形态:运行时不多花一个字节、不多一次检查,但编译器已经证明了你不会让 WakerRef 活过它依托的那次借用。
如果没有这个 PhantomData,ManuallyDrop 包装的 Waker 会是一颗定时炸弹——你可以把 WakerRef 返回给调用者、调用者让它活过 Header 被释放——最终 vtable 函数调用时解引用野指针,UB。
这种"用 PhantomData 携带 lifetime 约束"的技巧在 Tokio 源码和 Rust 生态里极常见。下次你看到一段代码有 PhantomData<&'a T>,脑子里应该立刻想到:"这是在编译期加一个生命周期约束,运行时不要钱"。
3.4 为什么 Tokio 用全局单一 VTABLE
注意那行 static WAKER_VTABLE: RawWakerVTable = ...。Tokio 整个进程只有一个 WAKER_VTABLE——所有 Task 的 Waker 共享这个 vtable 实例,data 指针不同而已。
这是一个有意识的、反直觉的设计决策。要理解它,得看 Tokio 源码里这段被我省略的注释(现在原样贴出来):
// 来源: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.
这段注释解释了一个陷阱。第 2 章只列了 Waker::will_wake 的签名,这里贴出它的实现:
// 来源:rust-lang/rust · library/core/src/task/wake.rs:478(删去注释)
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 给借出去的那个 Waker 单独配一张 vtable(2020 年 #2045 之前正是如此:waker_ref 用的是一张 drop 为空操作的专用 vtable),那即使两个 Waker 实际上指向同一个 Task,但它们是通过两个不同的代码路径构造的——vtable 指针比对就会 false,will_wake 返回 false。
这会导致什么?第 2 章讲过:Future 实现者应该在每次 poll 时检查 cx.waker().will_wake(&stored_waker),如果不匹配就重新 clone。will_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 多次用 static 和 Arc 把某些东西做成"全局唯一实例"的内在动机。
这不是 Tokio 一家的巧思
rust-lang/rust#66281(标题 Waker::will_wake() gets mostly defeated by executor optimizations)讨论的是一个不止一个执行器撞上过的问题:为了省掉引用计数,执行器往 Context 里放的是一个"借来的" Waker,它和被 clone 存下来的 Waker 用的不是同一张 vtable,而 will_wake 拿 vtable 地址做比较,于是频繁给出假阴性。Tokio 的解法是让借用版和拥有版共用同一张 vtable,借用语义改由 ManuallyDrop 保证(tokio 仓库提交 5930acef,#2045,2020-01-06)。
Tokio 的特别之处只在于它把这条推理写进了源码注释,直接指向那个 issue——这是读源码时最值钱的一类线索:注释里写着"为什么不能换一种写法"。
一个具体的"如果没有这个设计会怎样"
想象你写了一段代码,spawn 了 1000 个 Task,每个 Task 里有一个自定义 Future,Future 的 poll 里有标准的 Waker 缓存逻辑:
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.5 WakerRef:借用语义的 Waker
看这段:
// tokio/src/runtime/task/waker.rs:9
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>被丢弃时不会运行内部T的 drop - 通过
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 绑定到那次 &NonNull<Header> 借用,编译期保证你不会让 WakerRef 活得比这次借用久。
这个设计和 Rust 标准库里 Cow<'_, T>、 ManuallyDrop<T> 的哲学一脉相承:给高性能路径一个"借用语义"的版本,避开所有权规则里的额外成本。
Future 实现者看不到 WakerRef
注意 WakerRef 是 pub(super) —— 只对 task 模块内部可见。Future 实现者永远只看到 &Waker(通过 Deref)。这符合我们 3.1 节画的那张分层图:运行时实现细节对 Future 实现者隐藏。
但如果你自己写一个自定义运行时,你就得考虑这类优化——这是为什么读 Tokio 源码对"想写运行时的人"价值特别高:你不仅能学到 Waker 的接口契约,还能学到如何在这个契约内做极致优化。
3.6 一次 waker.wake() 的完整调用路径
现在到了本章最关键的一节:当你在某段代码里调用 waker.wake() 的那一瞬间,Tokio 内部发生了什么?
我们追一次完整调用。出发点:假设在 I/O Driver 里,某个 socket 变可读,Driver 从内部表里拿到这个 socket 关联的 Waker,然后:
// 在 I/O Driver 内部某处
waker.wake();
第 1 层:Waker::wake(标准库)
// library/core/src/task/wake.rs:438
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)
// tokio/src/runtime/task/waker.rs:81
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, tokio/src/runtime/task/harness.rs:67)
注意这一层不在 raw.rs:RawTask 有两个 impl 块,raw.rs 那个放的是 poll /
schedule / dealloc 这类直接查 vtable 的薄封装,而 wake_by_val 在 harness.rs
里——因为它要先做状态机跃迁,再决定是提交还是回收。
// 简化自 tokio/src/runtime/task/harness.rs
impl RawTask {
pub(super) fn wake_by_val(&self) {
use super::state::TransitionToNotifiedByVal;
match self.state().transition_to_notified_by_val() {
// 调用方给了我们一个 ref-count,跃迁又新建了一个,此刻手里有两个:
// 新的那个变成 Notified 交给 schedule,旧的那个留到 schedule 返回后再放,
// 免得 schedule 内部把任务丢掉时对象提前析构。
TransitionToNotifiedByVal::Submit => {
self.schedule(); // ← 这一步才走 vtable
self.drop_reference();
}
TransitionToNotifiedByVal::Dealloc => self.dealloc(),
TransitionToNotifiedByVal::DoNothing => {}
}
}
}
唤醒不是一次函数指针跳转,而是一次状态机决策——同一个 wake() 可能变成「排队」、
「直接回收」或者「什么都不做」,取决于 Task 当前的状态位。真正查 vtable 的是里面那句
self.schedule()。
说到 vtable——Tokio 自己内部又有一层?是的。这不是重复,这是第 6 章会细讲的 "Task Vtable":
// 来源: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 契约规定的,用于跨运行时接口 - 内层(
Vtable,在raw.rs):Tokio 自己的"Task 行为分发表"。因为 Task 里装着泛型T: Future和泛型S: Schedule——不同T/S组合需要不同的poll/schedule实现,但 Task 指针本身需要是单态的(否则 Header 这个前缀就没办法通用了)
内层 vtable 是 Tokio 用的"伪 Rust trait object"—— 把泛型信息擦成固定形状的函数指针表,让 Task 指针可以统一处理。
对着上面这张表核对一下会发现:Vtable 里没有 wake_by_val 这一项。所以 RawTask::wake_by_val 是一个非泛型、全程序只有一份的函数(tokio/src/runtime/task/harness.rs:52 挂在 impl RawTask 上的那句 doc comment 说得很清楚:Only one version of these methods should exist in the final binary);真正的分叉发生在下一层的状态机里,真正走 vtable 的是再下一层的 self.schedule()。
第 4 层:状态转移—— state::transition_to_notified_by_val(tokio/src/runtime/task/state.rs)
这是 Tokio 最精巧的一段 state machine。源码里就是三个分支(fetch_update_action 的闭包里,一次 CAS 全部搞定):
is_running():置 NOTIFIED、ref_dec()(把调用方交给我们的那个 ref-count 还掉),返回 DoNothing——不 schedule,因为正在跑这个 Future 的那个线程负责在 poll 结束后看到 NOTIFIED 位并重新排队is_complete() || is_notified():只做ref_dec();减完 refcount 归零就返回 Dealloc(释放整块 Task 内存),否则返回 DoNothing- 其余(即 idle):置 NOTIFIED、
ref_inc()造出一个新的引用给即将入队的Notified,返回 Submit
这段状态机就是 Tokio "wake 幂等性" 的实现根基—— 你对一个 Task 连续 wake 十次,它也只会被 schedule 一次。如果没有这个状态机,大量 Future 在 "被并发唤醒多次" 的场景下(比如一个 channel 有很多 sender)会把调度队列爆掉。
第 6 章会把这段状态机完整拆出来——这里你只需要记住 wake 是幂等的,它的实现不是"先检查再做"而是原子 CAS 一次做完。
第 5 层:RawTask::schedule → vtable → 单态化的 schedule::<S>(Tokio, raw.rs)
当 transition_to_notified_by_val 返回 Submit,RawTask 调自己的 schedule——这才是走内层 vtable 的地方:
// 来源:tokio-rs/tokio · tokio/src/runtime/task/raw.rs
impl RawTask {
pub(super) fn schedule(self) {
let vtable = self.header().vtable;
unsafe { (vtable.schedule)(self.ptr) }
}
}
// vtable.schedule 指向的、按 S 单态化出来的函数:
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,原先占着 slot 的任务被挤进 worker 本地队列,本地队列满了 overflow 到全局注入队列;不在 worker 线程上(比如从外部线程 wake),直接进全局注入队列并唤醒一个空闲 worker(
tokio/src/runtime/scheduler/multi_thread/worker.rs:1056-1113) - current_thread 调度器:在运行时线程上直接入单线程的本地任务队列;从外部线程则进注入队列并 unpark driver(
tokio/src/runtime/scheduler/current_thread/mod.rs:596-618)
这一步的细节属于第 5 章和第 7 章的内容。这里你需要看到的是整条路径的总长度:
用户代码: waker.wake()
│
├─ Waker::wake (std) 1 次 vtable 间接调用
│
├─ wake_by_val (Tokio) 1 次指针转换
│
├─ RawTask::wake_by_val (harness.rs:67) 非泛型,全程序只有一份
│ │
│ ├─ state.transition_to_notified_by_val 1 次 CAS 循环,决定分支
│ │
│ └─ match 分支:
│ ├─ Submit → schedule() + drop_reference()
│ ├─ Dealloc → dealloc()
│ └─ DoNothing → (return)
│
├─ RawTask::schedule (raw.rs:204) 1 次 Tokio 内部 vtable 间接调用
│
├─ raw.rs::schedule::<S> 按 S 单态化出的真实函数
│
└─ S::schedule(Notified(Task)) 入队(本地队列 / LIFO slot / 全局队列)
一次 wake() 的用户态成本,结构上就是"两次间接调用 + 一次 CAS 循环 + 一次入队"——在 worker 线程上入本地队列、又不需要叫醒别的 worker 时,没有系统调用、没有堆分配、没有锁;从外部线程唤醒则要拿注入队列的锁,还可能 unpark 一个 worker。这就是为什么在整条 I/O 链路里 Waker 这一段几乎不出现在 flamegraph 上;真要知道它在你的机器上是多少纳秒,用 perf 自己量,别抄任何文章里的数字。
这条 6 层路径的每一层解决一个不同的问题
看似层数多,但每一层承担的责任是必要的、不可合并的:
- 第 1 层(标准库 Waker::wake):提供跨运行时的抽象。不做具体工作,只是通过 vtable 转发
- 第 2 层(Tokio wake_by_val 函数):桥接标准库接口 和 Tokio 内部类型。把
*const ()还原成NonNull<Header> - 第 3 层(RawTask::wake_by_val):Tokio 的 public-internal 边界。外界(scheduler / Driver / JoinHandle)都通过 RawTask 操作 Task
- 第 4 层(state::transition_to_notified_by_val):纯 atomic 操作,决定这次 wake 应该做什么
- 第 5 层(RawTask::schedule → 内层 vtable):泛型单态化点。这里 Tokio 才需要知道具体的
S: Schedule是谁 - 第 6 层(Schedule::schedule):调度策略。Tokio 在这里做 LIFO slot + 本地队列 + 全局队列的分级入队
如果你试图合并其中任意两层,设计都会崩:
- 合并 1 和 2:破坏跨运行时接口(Future 代码无法迁移)
- 合并 3 和 4:状态转移和唤醒入口耦合,
wake_by_ref/remote_abort/shutdown就没法复用同一套状态机 - 合并 5 和 6:Task 指针无法做类型擦除(Header 这个前缀就没法对不同
T/S通用了)
这种"每层一个职责,层与层之间用最薄的接口连接"的架构,是软件工程里"关注点分离"的教科书示例。读 Tokio 源码的人很容易被它的"层多"吓住,但当你每一层都看懂它在解决什么、不在解决什么,你会发现这才是一个成熟工业级运行时该有的样子。
3.7 wake_by_val 与 wake_by_ref:一次 refcount 优化的案例学习
重新看 vtable 的 4 个函数:
static WAKER_VTABLE: RawWakerVTable =
RawWakerVTable::new(clone_waker, wake_by_val, wake_by_ref, drop_waker);
注意 wake_by_val 和 wake_by_ref 是两个独立的函数。它们的语义差别在标准库 Waker 上是:
Waker::wake(self)—— 消耗 Waker(所有权转移),之后不能再用Waker::wake_by_ref(&self)—— 借用 Waker,之后可以再用
对应到 Tokio 的实现:
// 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 算法。看 RawTask::wake_by_val 和 RawTask::wake_by_ref 的对比(两者都在 harness.rs 的 impl RawTask 块里):
// 来源:tokio/src/runtime/task/harness.rs:67(impl RawTask;节选,省略 use 语句和部分注释)
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:调用者交来一个 refcount。Submit 分支里,transition_to_notified_by_val在同一次 CAS 里再加 1 给即将入队的Notified;调用者那份没有直接过户,而是留到schedule()返回后由drop_reference()还掉(防止schedule内部把任务丢掉时对象提前析构)——最终调用者那份被消费、Notified持有一份,Task refcount 守恒。其余分支里,调用者那份的ref_dec直接并进那次 CASwake_by_ref:调用者的 Waker 没被消费——Task 的 refcount 不变;但transition_to_notified_by_ref又给"被 schedule 的任务"加 1;净效应是 +1。schedule 消耗这个 +1 的 refcount 后不再需要额外 drop
这段算法有什么意义? 把它和"wake_by_ref 之后再 drop 这个 Waker"逐分支比一比,数的是原子读改写:
- Task 空闲(Submit 分支):两种写法都是"一次 CAS(顺带 +1)+
schedule+ 一次fetch_sub"——Tokio 1.40 在这里没有省掉原子操作,代价就是上面那次推迟的drop_reference() - Task 正在运行:
wake_by_val一次 CAS 同时置 NOTIFIED 和减计数;wake_by_ref+ drop 是一次 CAS 再加一次fetch_sub——省一次原子操作 - Task 已完成或已被通知:
wake_by_ref这一支不写 state,只读一次,之后 drop 是一次fetch_sub;wake_by_val是一次 CAS——都是一次原子读改写
所以 _by_val 这一项的收益集中在"任务正在运行时被唤醒"这类分支上,而不是凭空省掉一对 clone/drop。Tokio 里 mpsc(经 AtomicWaker)、Notify 等同步原语唤醒时会走 Waker::wake,也就是 wake_by_val;oneshot 发送时用的则是 Waker::wake_by_ref(tokio/src/sync/oneshot.rs:1135)。
3.8 为什么这一对 clone/drop 值得单开一条 vtable 项
标准库本可以只给 wake_by_ref 一个函数指针——wake(self) 完全可以实现成"调 wake_by_ref 再 drop 自己"。RawWakerVTable 却坚持把 wake 和 wake_by_ref 分成两项,代价是每个运行时都要多写一个函数。为什么?
因为对 refcount 型的运行时(Tokio、async-task、几乎所有基于 Arc 的实现)来说,这两条路径的引用计数账本不一样:
- 走
wake_by_ref:调用方那份引用还在,schedule 需要的那份必须新造——净+1 - 走
wake_by_val:调用方那份引用正要作废,运行时有机会直接过户给 schedule,或把还掉它的那一步并进别的原子操作
如果 vtable 只有一项,Waker::wake(self) 就只能退化成"先按 _by_ref 造一份新的、再把自己那份还掉",运行时连选择的余地都没有。分成两项之后,要不要过户、在哪些分支里把减计数并进状态 CAS,由运行时自己决定——Tokio 1.40 的选择上一节已经逐分支数过:Submit 分支为了安全仍然"先造新的、后还旧的",省下的那次原子操作在"任务正在运行"的分支里。这不是"大概能省点"的猜测,而是能从代码里数出来的。
这类"数原子操作"的视角,是读 Tokio 源码最值得学的东西之一——它让你在以后写自己的性能敏感代码时,会主动去问"这里我能不能少一次 clone / 少一次 atomic",而不是等到 profile 上看见了才补救。
至于"这对原子操作在你的负载上值多少"——那是 cargo bench / perf 的问题,不是本专栏能替你回答的。
3.9 ref_inc / ref_dec:把状态位和引用计数塞进一个 usize
clone_waker 里调的是 header.as_ref().state.ref_inc()。State 是什么?
简化的结构(完整代码在 tokio/src/runtime/task/state.rs):
pub(super) struct State {
val: AtomicUsize,
}
一个 Task 的 State 就是一个 AtomicUsize。这个 usize 的位被 Tokio 切成两段——低 6 位是状态标志,其余高位全部给引用计数(注意方向:很多人下意识以为是反过来的):
// 来源:tokio-rs/tokio · tokio/src/runtime/task/state.rs:17-49(示意,非逐字:注释换成中文)
const RUNNING: usize = 0b0001; // 正在被 poll
const COMPLETE: usize = 0b0010; // 已完成,置位后永不清除
const LIFECYCLE_MASK: usize = 0b11;
const NOTIFIED: usize = 0b100; // 已被推进某个 run queue
const JOIN_INTEREST: usize = 0b1_000; // JoinHandle 还在
const JOIN_WAKER: usize = 0b10_000; // JoinHandle 的 waker 已设置
const CANCELLED: usize = 0b100_000; // 被强制取消
const STATE_MASK: usize = LIFECYCLE_MASK | NOTIFIED | JOIN_INTEREST | JOIN_WAKER | CANCELLED;
/// 引用计数占掉剩下的全部高位
const REF_COUNT_MASK: usize = !STATE_MASK;
const REF_COUNT_SHIFT: usize = REF_COUNT_MASK.count_zeros() as usize; // = 6
const REF_ONE: usize = 1 << REF_COUNT_SHIFT;
于是 ref_inc() 就是给整个 usize 加一个 REF_ONE(即 1 << 6),ref_dec() 就是减一个——状态位一个都不会被碰到。落到源码上,ref_inc 是 fetch_add(REF_ONE, Relaxed)(tokio/src/runtime/task/state.rs:448),ref_dec 是 fetch_sub(REF_ONE, AcqRel)(tokio/src/runtime/task/state.rs:458)——加计数用 Relaxed 的理由源码注释里直接引了 Boost 的那段论证(新引用只能从已有引用派生),减计数要在归零那一刻把别人的写都 acquire 过来、把自己的写 release 出去,所以必须 AcqRel。这正是"一次 CAS 同时改状态和 refcount"能成立的原因。
这种 "把多个逻辑字段塞进一个 atomic usize" 的技巧是性能敏感系统的经典做法。它的好处:
- 所有状态转移可以用一次 CAS 原子完成——比如"从 IDLE 转到 NOTIFIED 并同时 refcount++"是一次 CAS,不会有中间态
- state + refcount 不会分裂——不存在"state 改了但 refcount 没跟上"的时间窗
代价:位操作复杂、掩码计算容易写错。Tokio 用一组 const 掩码和 Snapshot 上的小方法(ref_inc、set_notified 等)把这些位操作封装起来。
第 6 章讲 Task 结构时会把完整的 State bit layout 画出来。这里你只需要知道:ref_inc / ref_dec 是原子的位操作,只动引用计数那几位;需要同时改状态位和引用计数的地方走一次 CAS,不会出现"其中一个更新了、另一个漏了"的 race condition。
为什么这个设计对 Tokio 的正确性是必要的
想象另一种设计:状态位和 refcount 分别存两个 AtomicUsize。这看起来简单,但会在 wake / poll 并发时引入一类微妙的 race:
- 线程 A:读 state 看到 RUNNING
- 线程 B:Future 完成,state 写成 COMPLETE
- 线程 B:refcount 读到 2,减 1 变 1
- 线程 A:refcount 读到 1,减 1 变 0,释放内存——但此时线程 B 可能还拿着 Task 指针
如果 state 和 refcount 分开读写,上面 4 步的任意交错都可能导致"刚 COMPLETE 就 free,但还有在途操作"。
把两者放进一个 AtomicUsize 后,可以用一次 CAS 同时修改——状态转移和 refcount 调整要么都成功、要么都失败,不存在中间态。Tokio 的 transition_to_notified_by_val 等 state 方法内部就是一个带状态机逻辑的 CAS 循环:
// 伪代码示意——真实代码把这个循环封装成了 tokio/src/runtime/task/state.rs:470 的 fetch_update_action,
// 调用方只需要写"给我一个 snapshot,我返回 (要采取的动作, 新 snapshot)"的闭包
loop {
let current = Snapshot(self.val.load(Acquire));
let (action, next) = f(current); // 见 transition_to_notified_by_val 的闭包
match self.val.compare_exchange(current.0, next.0, AcqRel, Acquire) {
Ok(_) => return action,
Err(_) => continue, // 有人抢先改了,重来
}
}
一次 CAS 完成"读状态 + 决定新状态 + 同步更新 refcount"三件事——这是单 AtomicUsize 设计的根本收益。
这种技巧在工业界有多普遍?
这不是 Tokio 独创——它是高性能并发数据结构的通用技巧:
- Linux kernel 的
refcount_t相关接口就是把 refcount 和状态位混用的类似设计 - Folly(Meta 的 C++ 并发库)的 future / promise 共享核心同样用"单个 atomic word 承载状态机 + 回调槽"的形态
- Go runtime 的锁与等待队列同样大量使用"一个机器字里塞标志位 + 计数"的布局
- JVM 的 CMS / G1 GC 里的对象头(object header)也是类似的多字段打包
读过 Tokio 的这段 state 代码后你会发现:现代性能敏感系统的并发数据结构,底层几乎都是这种"bit layout + CAS loop"的形态。学会了一套,看别家的代码会快很多。
3.10 回看第 2 章的两个疑问
现在我们有了 Waker 的全部机械细节,可以回到第 2 章结尾的两个承诺,给出严格的机械解释:
疑问 1:为什么 BrokenCounter 会永久卡死?
回忆第 2 章的代码:
impl Future for BrokenCounter {
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<()> {
// ... remaining -= 1
Poll::Pending // ← 没有做任何 Waker 相关的事
}
}
机械解释:
- Tokio 的 worker 线程 poll 这个 Task,进入 Harness 的 poll 函数
- 传进去的 Context 里含
WakerRef<'_, S>(指向当前 Task 的 Header,refcount 不变) - Future 的
poll返回Poll::Pending,但既没 clone Waker,也没 wake_by_ref - Worker 的 poll 处理完 Pending 后,Task 的状态位会从 RUNNING 转回 IDLE(因为没人 NOTIFIED 它)
- Task 从 worker 的本地队列里移除,沉睡在 Tokio 的 Task 池里
- 没有任何代码会调
waker.wake()——因为 Future 的 Waker 没被任何地方保存 - Task 在内存里,refcount = 2 —— 初始状态带三个引用(
OwnedTasks名册、送进队列的那个Notified、JoinHandle;见tokio/src/runtime/task/state.rs:61的INITIAL_STATE),这次 poll 结束时transition_to_idle把Notified那一个ref_dec()掉了(tokio/src/runtime/task/state.rs:159),剩下名册和JoinHandle各持一个,永不释放、永不被 poll
症状:程序正常运行,CPU 占用 0%,某个 async fn 卡在 .await 上永远不返回。在 tokio-console 里,这个 Task 一直停在 idle、外面也没有任何它的 Waker(console 靠 tokio/src/runtime/task/waker.rs 里 trace! 上报的 waker.clone / waker.drop 等事件计数)——第 17 章讲可观测性时会回到这个诊断场景。
疑问 2:TickingCounter 的 Waker 是怎么"传到" Time Driver 的?
看 TickingCounter 的核心:
match this.sleep.as_mut().poll(cx) { // cx 就是当前 Task 的 Context
Poll::Pending => return Poll::Pending,
// ...
}
Sleep::poll 内部(第 11 章讲定时器的注册与触发)做的事:
- 从
cx.waker()拿到当前 Task 的&Waker cx.waker().clone()—— 触发clone_waker—— Task refcount + 1- 把这个 Waker 存进定时器条目自带的
AtomicWaker(tokio/src/runtime/time/entry.rs:102、:147),条目挂在 Time Driver 分层时间轮的某个 slot 上 - 返回
Poll::Pending
Time Driver 在驱动它的那个线程上(park 时拿到 driver 的 worker,或 block_on 所在的线程)处理到期的定时器:
- 时间到了,从 bucket 里拿出那个 Waker
- 调
waker.wake()—— 触发wake_by_val的完整路径——最终 Task 被 schedule 到 worker
整条链里,Future 的 poll 从没 wake 自己,Waker 被"交出去"给了 Time Driver,由 Time Driver 在合适的时间 wake。这是 Pending 契约的标准实现姿势。
3.11 如果你要写自己的 Waker:实战最小示例
到这里你应该觉得"手写 Waker 似乎不难"——事实也是如此。整个 RawWakerVTable 只有 4 个函数指针,只要你想清楚"data 指针指向什么 + wake 意味着什么",就能写一个最小可用的运行时。
下面是一个真正能跑的极简 executor。它虽然简陋,但跟 Tokio 在结构上完全同源——看懂它,你就能看懂任何运行时的 Waker 代码:
// 一个最小 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) }
}
// 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);
loop {
// 注意:必须先把 queue 的锁放掉再 poll。如果写成
// `while let Some(task) = queue.lock().unwrap().pop_front() { .. }`,
// MutexGuard 会活到整轮循环体结束——一旦 future 在 poll 里同步调用
// wake(),wake 里的 `arc.queue.lock()` 就会在同一把非重入锁上自锁。
let task = match queue.lock().unwrap().pop_front() {
Some(t) => t,
None => break,
};
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 任务没问题。
两点编译/运行上的提醒:四个 vtable 函数都是 unsafe fn,里面直接调 Arc::from_raw——在 2015/2018/2021 edition 下能干净地编过,切到 2024 edition 后 unsafe_op_in_unsafe_fn 默认报警告(每个 Arc::from_raw 一条 E0133 警告),想消掉就在函数体内再包一层 unsafe { .. };另外 Poll::Pending 分支只是把 future 塞回去,并不重新入队,任务要靠 wake() 才会被捡回来;而且队列一空 run 就 break 返回——如果任务此时还在等别的线程来 wake,就直接被丢下了,所以它只适合跑那些不依赖外部线程唤醒的任务。
看它怎么对应 Tokio:
| 本示例 | Tokio 对应 |
|---|---|
TaskInner |
Task<S>(tokio-1.40.0/tokio/src/runtime/task/mod.rs:220,只有一个类型参数 S)/ Cell<T, S>(tokio-1.40.0/tokio/src/runtime/task/core.rs:115) |
Arc<TaskInner> 的引用计数 |
Tokio 的 State::val 高位(tokio-1.40.0/tokio/src/runtime/task/state.rs:17-49:低 6 位是 LIFECYCLE_MASK/NOTIFIED/JOIN_INTEREST/JOIN_WAKER/CANCELLED,REF_COUNT_MASK = !STATE_MASK 占剩下的高位,REF_ONE = 1 << REF_COUNT_SHIFT) |
queue: VecDeque<Arc<TaskInner>> |
Scheduler 的本地队列 + 全局队列 |
VTABLE(全局 static) |
WAKER_VTABLE(同样全局 static,tokio-1.40.0/tokio/src/runtime/task/waker.rs:96-97) |
make_waker(task) |
waker_ref(header)(tokio-1.40.0/tokio/src/runtime/task/waker.rs:16)+ clone_waker(tokio-1.40.0/tokio/src/runtime/task/waker.rs:67) |
wake 中 push_back |
RawTask::schedule(tokio-1.40.0/tokio/src/runtime/task/raw.rs:204)→ S::schedule(Notified(Task))(tokio-1.40.0/tokio/src/runtime/task/raw.rs:274-281) |
区别:
- Tokio 用
AtomicUsize做状态机,本示例用Mutex<Option<...>>——锁比较重但代码清晰 - Tokio 用手工偏移量把 header + future 塞进一块连续内存,本示例用
Box::pin——多一次堆分配但不用 unsafe - Tokio 的 work-stealing 多队列调度在本示例里退化成一个全局 VecDeque
但 Waker 的结构完全同构。理解这一层后,你再看 Tokio 的 102 行 tokio-1.40.0/tokio/src/runtime/task/waker.rs,会发现里面没有任何"魔法"——全是对这个基本骨架的极致优化版本。
这个示例可以作为你理解任何 Rust 运行时的锚点。smol / embassy / monoio 的 Waker 实现都可以对比回这个骨架来理解。
疑问 3(新的):stale Waker 会造成什么 bug?
生产环境里还有一类极微妙的 Waker bug——stale Waker(过期 Waker)。场景:
// 手写 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
- 由于某种原因(比如 Task A 把这个还没完成的 Future 通过 channel 交给了 Task B),这个 Future 现在由 Task B 持有
- Task B poll 这个 Future,Future 仍然用缓存的 Task A 的 Waker
- 未来某个事件触发
waker.wake()——唤醒的是 Task A(可能早已结束,这个 Waker 持有的引用只是让它的内存还留着),不是 Task B - Task B 永远卡死
正确的做法是每次 poll 比对 will_wake,只在不同时更新:
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.4 节算过的那笔"每次 poll 都白 clone 一次"的账。而有了全局 vtable,只有真正跨 Task 时才 clone,hot path 零 overhead。
Tokio 的很多内置 Future 都遵循这个 will_wake + clone 模式:TcpStream 背后的 ScheduledIo(tokio/src/runtime/io/scheduled_io.rs:348)、Mutex 背后的信号量(tokio/src/sync/batch_semaphore.rs:503)都是;也有例外,比如 mpsc::Receiver 经 AtomicWaker::register_by_ref 每次都 clone 一份新的(tokio/src/sync/task/atomic_waker.rs:379-381)。第 10 章讲 TcpStream::poll_read 时你会看到它的标准写法。
3.12 Waker 的生命周期契约
把所有坑合起来,Waker 的生命周期契约可以总结成三条:
- poll 返回 Pending 之前,必须已经把 Waker 交到某个外部的手里——否则无人唤醒(BrokenCounter)
- 不要持有过期 Waker——跨 poll 的 Waker 必须
will_wake校验(LeakyFuture) - 不要泄漏 Waker——一个永不被 wake 也永不被 drop 的 Waker 会让对应 Task 的 refcount 永远不归零,Task 内存永远不释放
违反第 1 条 → Task 永远不 poll(卡死) 违反第 2 条 → 唤醒错 Task(该醒的那个卡死) 违反第 3 条 → 内存泄漏
绝大多数 Rust 异步 bug 可以归到这三类之一。第 19 章(性能调优与典型陷阱)会有更全面的陷阱清单和诊断方法;本章讲完 Waker 的机械原理后,你已经有了诊断这些 bug 的词汇。
3.13 和其他运行时的 Waker 实现:对比验证
本节我们看三个真实运行时的 Waker 实现,跟 Tokio 对比——不仅巩固你对 Waker 接口的理解,也让你看清同一个接口下、不同运行时做了不同权衡。
smol(async-task crate)
smol 和它的底层依赖 async-task 用的是和 Tokio 非常相似的结构:
// async-task 中的 Waker 构造(极简版伪代码;真实代码里 vtable 是泛型 impl RawTask<F, T, S, M> 上的关联常量 RAW_WAKER_VTABLE)
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 是状态位 + refcount 打包的state、等待Task句柄完成的awaiterWaker、指向TaskVTable的指针(std下再加一个 panic 传播标志);schedule闭包不在 Header 里,而是和 future 一起存在同一块任务内存中。Tokio 的 Header 则挂着queue_next(注入队列链表)、owner_id(OwnedTasks名册)和可选的tracing_id - 同样有两层 vtable,但不管调度:async-task 也有一张
TaskVTable(schedule、drop_future、destroy、run等,外加指向RawWakerVTable的指针),作用和 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 都是静态分配的:
// 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:置位 RUN_QUEUED,若之前未置位就挂进 executor 的侵入式 run queue
unsafe fn wake(p: *const ()) {
wake_task(TaskRef::from_ptr(p as *const TaskHeader));
// wake_task 内部:header.state.run_enqueue(..) 成功才 executor.enqueue(task)
// executor 每轮只 poll 队列里的 Task,不扫描全部 Task
}
// 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 = 一次状态位的原子更新 + 一次侵入式链表入队,不分配、不计数。
monoio(thread-per-core + io_uring)
monoio 是字节跳动主推的高性能运行时,架构是"每个线程一个独立 executor,不做跨线程 work-stealing"。它的 Waker 设计因此可以进一步省掉原子操作:
- 所有 Waker 和 Task 都是线程局部的
- clone / wake / drop 都是普通内存读写(不需要 atomic),因为不跨线程
- wake 直接把 Task 放进当前线程的 ready 队列
这套设计的性能上限比 Tokio 更高(因为少了所有原子操作),但适用面窄——一旦你需要跨线程唤醒(比如一个全局 broadcast),monoio 就需要用额外的消息队列 + atomic 来桥接。
结论:同一个接口,三套完全不同的 Waker
对比三家:
| 维度 | Tokio | embassy | monoio |
|---|---|---|---|
| 引用计数 | 原子 refcount | 无(全 'static) | 无(线程局部) |
| wake 的代价 | 1 次 CAS + 入队 | 置位 + 侵入式队列入队 | 几次普通读写 |
| Task 内存 | 堆分配 | 静态分配 | 堆分配 |
| 适用场景 | 通用后端 | 嵌入式 no_std | 高性能同核服务 |
三家的 Waker 对 Future 实现者完全等价(都是一个 &Waker)。但它们内部实现反映了三种截然不同的工程哲学:通用稳健、资源受限、极致性能。
这个灵活度是 Rust 故意不把运行时放进语言的直接红利。第 1 章讲的"Rust 把运行时留给生态"这个决策,在 Waker 这一层可以看到最清晰的体现。
这种"相同契约 / 不同实现"的分层正是 API 设计的典范:标准库只承诺契约(RawWakerVTable 的形状),把策略(引用计数 / 静态分配 / 线程局部)留给运行时。我们在第 8 章讲 I/O Driver 时还会看到同一种思路——AsyncRead / AsyncWrite trait 是契约,Tokio / smol / monoio 各有各的实现。一旦你理解了 Waker 这层的接口游戏,整个 Rust 异步生态的"为什么有这么多运行时但能共存"的问题就解开了。
3.14 和这个系列的其他专栏的关联
本章讲的 "单一全局 vtable 避免 will_wake 误判" 这个设计模式,和 《Vue 3 设计与实现》专栏第 6 章(Alien Signals) 里 link() 用引用相等(sub.depsTail.dep === dep)给依赖去重的技巧是同一类思想——都是"用地址 identity 做逻辑 identity 的代理"。两个专栏互相参照,能看清这种"用低级原语承载高级语义"的工程风格是如何跨语言存在的。
ref_inc / ref_dec 背后"把状态位和 refcount 手工塞进一个 AtomicUsize"的做法,可以对照 《Rust 编译器与运行时揭秘》专栏第 5 章(内存布局与 Niche 优化) 来读——那一章讲的是编译器自动做的布局优化(字段重排、padding、niche),两相对照能分清哪些紧凑是编译器替你做的、哪些得像 Tokio 这样手工做。
3.15 本章小结
带走三件事:
- Waker 的本质是一个 16 字节的 vtable 指针对。Tokio 的 Waker 相当于一个"带状态位的
Arc<Task>"(并没有真的用Arc,引用计数打包在state里),clone 是原子加、drop 是原子减、wake 是一次 CAS + 入队 - Tokio 的两个精巧设计——全局单一
WAKER_VTABLE(避开will_wake的历史坑)和WakerRef的ManuallyDrop(避免 hot path 上的无谓 refcount 操作)——都是极致性能优化的典范 wake()的完整调用路径有 6 层,但每一层都只是几行代码。这条路径设计成幂等的(多次 wake 只 schedule 一次),且 hot path 上的 refcount 是守恒的
延伸阅读
- Tokio 源码:
tokio/src/runtime/task/waker.rs—— 本章拆解的那个 102 行文件 - Tokio 源码:
tokio/src/runtime/task/harness.rs——wake_by_val/wake_by_ref的 state 分支 - Tokio 源码:
tokio/src/runtime/task/state.rs—— State bit layout 和所有状态转移(第 6 章会详拆) - Rust issue
rust-lang/rust#66281——will_wake的 vtable 比对历史讨论 - 《Vue 3 设计与实现》专栏第 6 章:Alien Signals 的依赖链表与引用相等去重