我第一次在 Rust 里写自定义 Future 时,脑子里最大的问号是:poll 返回 Pending 之后,到底是谁决定什么时候再 poll 它一次?如果是我自己写一个 while 循环轮询,那和同步代码有什么区别?后来把 Waker 翻了个底朝天,才意识到异步编程这套体系,本质上靠的是一个极简的“信号通知 + 重新调度”闭环。你可以把 Future 比作肌肉,把执行器比作中枢,把 Waker 比作神经纤维——没有 Waker 这条神经系统,Rust 异步模型就只是一具华丽的空壳。
这篇文章不打算只讲 API 怎么用。我会从“为什么需要唤醒机制”讲起,深入 RawWaker 和 vtable 的底层形态,再手写一个定时器 Future 和一个最小 block_on,最后聊真实运行时里 Waker 的调度路径和调试经验。无论你是在学 Rust 异步,还是正在给别人封装备忘录,这篇文章都能帮你把 Waker 当成自己手里的工具,而不是玄学。
1. 问题源头:Future 天生“被动”,是谁在背后叫醒它
1.1 一次 poll 决定一生的返回结果
先看标准库里的 Future trait:
rust复制pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
poll 的结果只有两种:Ready(v) 表示完成;Pending 表示还没完成。关键就在这里:Future 本身不是一个“主动执行”的实体,它是一台只能被询问的机器。你问它“好了吗”,它只能回答“好了”或者“没好”。如果它回答“没好”,那谁来问第二次?
同步世界里这个问题很简单——我循环来问就行了。但异步世界里,我们追求的恰恰就是“不要一直问”。一个 I/O Future 可能在等待一条 TCP 连接上迟迟不来的数据,可能等一个 30 秒的定时器,也可能等另一个任务往 channel 里丢数据。如果调用方每隔几毫秒就 poll 一次,CPU 就被白白烧掉了,这种模型不仅没有省资源,反而比阻塞线程更浪费。
所以 Rust 异步设计者做了一个关键决定:poll 返回 Pending 时,必须通过 Context 里的 Waker 告诉调用者“以后有进展了我会喊你”。你不需要循环问,你只需要给我一个“叫号器”。
1.2 轮询循环为什么不现实
想象你去一家生意火爆的餐厅等位。没有叫号系统的时候,你只能每隔几分钟跑到前台问:“轮到我了吗?”如果这家餐厅上菜慢,一个多小时你就要问几十次。如果同时有几百桌客人,大家都这么问,前台就什么也别干了。
有了叫号器之后,你坐在椅子上刷手机,震动一响再起身过去。这叫“事件驱动”,也叫 push 模型。Rust Future 的 poll 本质上还是拉模型,但 Waker 把“何时再拉”的决定权从调用方手里夺回来,交给了真正的等待源。内核说“这个 socket 可读了”,epoll 通知到你的异步运行时,运行时拿出对应的 Waker 喊一声,Future 才被重新 poll。
操作系统层面的事件通知一直是 push 模式,Rust 要把这套语义接入用户态,就需要在 Future 和事件源之间修一条“反向通道”。Waker 就是这条通道。
1.3 神经系统三要素:产生信号、传递信号、响应信号
从这张图看 Rust 异步程序的运转,非常像人体反射弧:
- 感受器 / 末梢:实际等待的事件源,比如 socket 数据到达、定时器到期、channel 有消息。它负责“感知外界变化”。
- 传入神经:Waker。事件源知道“有进展了”,但它不需要知道 Future 怎么执行,它只需要把唤醒信号发出去。
- 中枢:执行器(Executor)。收到 Waker 信号后,把它翻译成“某任务需要重新 poll”,并在合适的时机、合适的线程上驱动 future。
这套神经系统里,Waker 是那个最容易被忽略、却决定了性能 bottom line 的部分。它承担了“两个执行线程之间最小的握手协议”:一方说“你可以继续了”,另一方收到后恢复执行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把 Waker 拆开:数据指针加 vtable,本质是一个胖指针
2.1 RawWaker 和 Waker 的层级关系
在标准库里,Waker 并不是一个复杂对象。它内部持有的是一个 RawWaker:
rust复制pub struct RawWaker {
data: *const (), // 指向任意运行时自定义数据
vtable: &'static RawWakerVTable,
}
pub struct RawWakerVTable {
clone: unsafe fn(*const ()) -> RawWaker,
wake: unsafe fn(*const ()),
wake_by_ref: unsafe fn(*const ()),
drop: unsafe fn(*const ()),
}
我把 Waker 理解为“安全的皮,unsafe 的瓤”。Waker 对外提供安全的 wake()、wake_by_ref()、clone(),但内部实际操作的是来自 vtable 的函数指针。每个异步运行时都可以自由定义自己的 data 和 vtable,这样 Waker 不需要知道你的任务是 Tokio 的还是自定义执行器的,它只需要知道“如何唤醒”。
这也是为什么 Rust 可以做到“运行时无关”。Waker 是一个协议,不是一种实现。
2.2 vtable 里的四个函数到底在什么时机被调用
| 函数 | 调用时机 | 典型实现 |
|---|---|---|
clone |
调用 waker.clone(),或把 Waker 传入共享状态时 |
对底层数据增加引用计数,重新返回 RawWaker |
wake |
外部事件发生时,通知 future 可以重新 poll | 消费一份引用计数,触发任务入队 |
wake_by_ref |
借用方式触发唤醒,不转移所有权 | 只做触发,不动引用计数 |
drop |
Waker 被丢弃,或者 clone 出来的最后一个引用释放时 | 释放底层任务资源 |
最容易被忽略的是 clone 和 drop 的配对。Waker 不是 Copy,它被存进多个等待队列时,必然伴随多次 clone。如果你在自定义执行器里用裸指针管理任务,忘记在 drop 里释放引用计数,就会直接造成内存泄漏。这个坑我踩过不止一次。
2.3 从零构造一个能用的 RawWaker
假设我们要做一个基于线程 park/unpark 的 Waker,用 Arc<ThreadWakerInner> 作为底层数据。手动构造 RawWaker 的过程如下:
rust复制use std::sync::Arc;
use std::task::{RawWaker, RawWakerVTable, Waker};
use std::thread::Thread;
struct ThreadWakerInner {
thread: Thread,
}
unsafe fn clone_raw(data: *const ()) -> RawWaker {
let inner = Arc::from_raw(data as *const ThreadWakerInner);
let cloned = Arc::clone(&inner);
std::mem::forget(inner); // 避免 Arc::from_raw 把原来的引用消费掉
RawWaker::new(Arc::into_raw(cloned) as *const (), &VTABLE)
}
unsafe fn wake_raw(data: *const ()) {
let inner = Arc::from_raw(data as *const ThreadWakerInner);
inner.thread.unpark();
// 这里 Arc 会在函数结束时 drop,相当于消费掉一份引用
}
unsafe fn wake_by_ref_raw(data: *const ()) {
let inner = &*(data as *const ThreadWakerInner);
inner.thread.unpark();
}
unsafe fn drop_raw(data: *const ()) {
drop(Arc::from_raw(data as *const ThreadWakerInner));
}
static VTABLE: RawWakerVTable = RawWakerVTable::new(
clone_raw,
wake_raw,
wake_by_ref_raw,
drop_raw,
);
fn waker_from_thread(thread: Thread) -> Waker {
let inner = Arc::new(ThreadWakerInner { thread });
let raw = RawWaker::new(Arc::into_raw(inner) as *const (), &VTABLE);
unsafe { Waker::from_raw(raw) }
}
这里有一个非常重要的细节:Arc::from_raw 会获得一个所有权引用,如果不配合 std::mem::forget 或者不消费掉这个引用,原 Arc 的引用计数计算就会错乱。clone_raw 里我故意 forget 掉刚刚 from_raw 拿到的引用,是为了只增加计数而不减少计数。这是一个极其容易写崩的 unsafe 代码,建议直接利用标准库的 Wake trait 或者第三方工具,把这种手工 vtable 留到确实需要的时候。
2.4 为什么 Waker 不是闭包,而是胖指针
有 C++ 经验的读者可能会想,直接用 std::function<void()> 不就行了?为什么不把唤醒动作直接塞进一个闭包?
原因是跨运行时和跨线程的约束。一个 Waker 可能要在线程 A 创建,再被 clone 到线程 B,最后在线程 C 上触发唤醒。它必须满足 Send + Sync,而闭包捕获的变量未必能保证这一点。另外,一个 Waker 可能在 future 的整个生命周期里被 clone 很多次,如果用闭包,每次 clone 都要重新分配堆内存;用引用计数加 vtable,clone 只需要增加一个原子计数,开销极小。
胖指针的成本是最低的:两个指针宽的体积,一次 vtable 间接调用。所有异步运行时都偏爱这种设计。
3. 手工搭一套唤醒闭环:定时器 Future 与最小执行器
3.1 为什么选定时器做实验对象
我建议你第一次手写 Waker 交互时,都用定时器而不是真 I/O。原因是定时器不依赖操作系统异步 API,用 thread::sleep 就能产生一个“外部事件”,最关键的是,你能完整观察唤醒信号从子线程到主线程的传播路径。等这条路径打通了,再套到 socket 或者 channel 上,发现只有事件源不一样,唤醒逻辑完全复用。
我们要做的目标非常明确:一个 TimerFuture,它等指定时长后返回 Output = (),不依赖 Tokio,不依赖任何运行时。
3.2 共享状态与 Waker 槽位
定时器的核心是一个共享状态:一个 completed 布尔标志,加上一个 Option<Waker>。为什么需要一个共享状态?因为 poll 和定时器线程需要读写同一份数据,一个负责填入 Waker,一个负责标记完成并触发唤醒。
rust复制use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::thread;
use std::time::Duration;
struct TimerState {
completed: bool,
waker: Option<Waker>,
}
pub struct TimerFuture {
shared_state: Arc<Mutex<TimerState>>,
}
impl TimerFuture {
pub fn new(duration: Duration) -> Self {
let shared_state = Arc::new(Mutex::new(TimerState {
completed: false,
waker: None,
}));
let thread_shared_state = Arc::clone(&shared_state);
thread::spawn(move || {
thread::sleep(duration);
let mut state = thread_shared_state.lock().unwrap();
state.completed = true;
if let Some(waker) = state.waker.take() {
waker.wake();
}
});
TimerFuture { shared_state }
}
}
impl Future for TimerFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let mut state = self.shared_state.lock().unwrap();
if state.completed {
Poll::Ready(())
} else {
state.waker = Some(cx.waker().clone());
Poll::Pending
}
}
}
3.3 block_on 的最小实现:park/unpark 的配对
现在需要一个执行器来驱动 TimerFuture。最简单的执行器就是 block_on:它把当前线程变成调度线程,如果 poll 返回 Pending,就用 thread::park() 把自己挂起来;当 wake 被调用时,unpark() 唤醒线程,然后继续下一轮 poll。
这里我用标准库的 Wake trait 来生成 Waker,避免手工构造 vtable:
rust复制use std::sync::Arc;
use std::task::{Wake, Waker};
struct ThreadWaker(Thread);
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
fn block_on<F: Future>(fut: F) -> F::Output {
let mut fut = Box::pin(fut);
let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
let mut cx = Context::from_waker(&waker);
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(output) => return output,
Poll::Pending => std::thread::park(),
}
}
}
主函数跑一下:
rust复制fn main() {
block_on(TimerFuture::new(Duration::from_secs(2)));
println!("醒了");
}
运行这个程序,你会看到进程卡住约 2 秒,然后打印“醒了”。整个过程中的闭环是:
- 主线程
block_on第一次 poll TimerFuture。 - poll 发现
completed == false,把cx.waker().clone()存进共享状态,返回Pending。 - 主线程执行
thread::park(),进入睡眠。 - 2 秒后定时器线程醒来,把
completed置为 true,调用waker.wake()。 wake()内部调用ThreadWaker::wake(),也就是self.0.unpark()。- 主线程从
park()返回,继续循环,再次 poll。 - 这次
completed == true,返回Ready(())。
一个“神经系统反射”就这么完成了。
3.4 埋在这里的经典竞态:唤醒先于注册
上面这份代码有一个非常隐蔽的 bug。考虑这个时序:
- 定时器线程 sleep 结束,拿到锁,把
completed置为 true。 - 此时主线程还没有执行 poll,或者 poll 还没执行到
state.waker = Some(...)这一步。 - 定时器线程发现
state.waker还是 None,就什么也没做,直接释放锁。 - 主线程终于进入 poll,看到
completed为 true,直接返回Ready。这种情况是好的。
但另一种时序更危险:
- 主线程执行 poll,先检查
completed,发现是 false。 - 此时定时器线程已经拿锁把
completed置 true,然后调用 waker.wake(),但 waker 还没有被填进去,所以 wake 丢失。 - 主线程填完 waker,返回
Pending,然后 park。 - 没有人再来 unpark,主线程永远睡下去了。
修复方法是在存好 Waker 之后,再检查一次 completed:
rust复制fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let mut state = self.shared_state.lock().unwrap();
if state.completed {
Poll::Ready(())
} else {
state.waker = Some(cx.waker().clone());
if state.completed {
Poll::Ready(())
} else {
Poll::Pending
}
}
}
这种“先注册后复查”的模式,在任何事件驱动系统里都是保命手段。你去看 Tokio 的源码,里面大量存在类似的“注册唤醒源后再检查状态”的写法,目的就是压缩 wake 丢失的窗口。
4. 从 Waker 到重新 poll:执行器怎么消费“神经信号”
4.1 wake 不会直接调 poll
初学者最容易误解的地方:wake 被调用后,是不是就会立刻在当前线程调用 poll?答案是否定的。
在真实运行时里,wake 的典型实现是把自己的任务 ID 塞进一个运行队列,然后触发线程池的调度。也就是说,wake 只是“请求调度”,而不是“立即执行”。为什么不能直接在唤醒线程里调用 poll?
因为一个 future 可能正被另一个线程 poll。如果定时器线程在调用 wake 时又顺手 poll 一次,同一个 future 就被两处同时 poll,这直接违反 Future 协议,会导致数据竞争或逻辑错乱。Rust 的 Future 只有持有 Pin<&mut Self> 才能 poll,Pin 保证它不被移动,但不保证你不从两个线程同时 poll。
正确做法是:wake 只负责把任务标记为“可运行”,真正 poll 发生在执行器统一的调度循环里。这就像神经信号传到中枢后,是否让某个肢体动,取决于中枢怎么协调,而不是感受器直接拉一下肌肉。
4.2 唤醒信号合并:唤醒 100 次只跑一次 poll
既然 wake 只是入队,那如果一个任务在执行过程中被唤醒了多次,会发生什么?假设它被放入队列 100 次,执行器岂不是要 poll 它 100 次?
真实执行器不会这么傻。常见的做法是给任务维护一个“调度状态位”。比如 Tokio 的任务状态机里有一个 bit 表示“我已经被调度进运行队列了”。如果该 bit 已置位,后续的 wake 就只改状态,不再重复入队;等执行器取出任务开始 poll 时,再把 bit 清掉。这样无论一个任务在等待期间被唤醒多少次,它最多在队列里出现一次。
| 场景 | 队列行为 |
|---|---|
| 一个任务被唤醒 1 次 | 入队一次 |
| 一个任务被唤醒 100 次,还未被取出 | 只保留一次 |
| 任务正在 poll,又被唤醒 | wake 先置位,poll 结束后判断是否需要再入队一次 |
这种设计保证了唤醒信号的爆发不会压垮调度器,也是异步运行时高性能的关键之一。
4.3 为什么 Waker 必须 Send + Sync
Waker 跨线程唤醒来得太频繁了。一个 future 可能运行在线程 A,它注册的 Waker 被传递到线程 B,而触发 Waker 的可能是内核 I/O 线程、定时器线程、或者是任何一个网络连接线程。因此 Waker 必须满足 Send + Sync。
这也回到了 2.4 节那个问题:为什么不能用一个简单的栈上闭包当 Waker。闭包捕获的变量可能包含非 Send 类型,而 Waker 是可能被 clone 到另一个线程的。标准库选择用 data + vtable,并且明确规定 Waker 必须可以安全地跨线程调用,这一约束让异步运行时在任意线程模型下都能安全使用同一个唤醒协议。
4.4 用任务句柄作为 data:一个通用的 Waker 工厂
如果你要写一个小型执行器,可以把任务的堆内存地址(比如 Arc<Task>)当作 Waker 的 data。这样 wake 时可以直接根据 data 找到任务并重排队列。伪代码如下:
rust复制struct Task {
future: Mutex<Pin<Box<dyn Future<Output = ()> + Send>>>,
schedule: Arc<RunQueue>,
// 其他状态
}
impl Wake for Task {
fn wake(self: Arc<Self>) {
self.schedule.push(self.clone());
}
}
调用 Waker::from(task_arc.clone()),就能为每个任务生成一个唤醒器。这里 Task 本身就承担了 vtable 中 data 的角色,而 Wake trait 帮我们隐藏了所有 RawWaker 的 unsafe 细节。这是目前自定义执行器最推荐的写法。
5. 神经系统失灵:唤醒故障的三种症状与排查思路
5.1 典型故障一:future 永远停在 Pending,睡死过去
症状:程序启动后卡住,CPU 占用接近 0,任务没有任何进展。
排查步骤:
- 确认 poll 被调用过。在 poll 开头打印日志,如果压根没进 poll,说明 future 没被驱动。
- 确认返回
Pending时保存了 Waker。检查共享状态里有没有 waker 字段,以及是否复用了cx.waker().clone()。 - 确认外部事件发生后调用了
wake()。在定时器线程里、I/O 回调里,打印日志看看 wake 是否执行。 - 确认 waker 没有被提前 drop。Waker 的所有权被谁持有?如果被覆盖,旧 waker 就失效了。
最常见的睡死原因不是没有调用 wake,而是把 waker 存在了一个会被覆盖的变量里,或者压根没保存。一个调查技巧:给 Waker 打日志时记录它自己的内存地址,这样你能看到 poll 保存的 waker 和 wake 触发的 waker 是不是同一个对象。
5.2 典型故障二:CPU 飙高,任务空转
症状:程序没有卡死,但 CPU 占用接近 100%,大量时间花在无意义的 poll 上。
这种情况多半是 waker 被“无条件”触发。比如某个 future 在 poll 里不是根据实际事件决定是否唤醒,而是只要返回 Pending 就立刻调用 cx.waker().wake_by_ref()。这等于告诉执行器“我永远准备好了”,执行器就会把它塞进队列不断 poll,循环烧 CPU。
真正的唤醒应该只发生在事件发生时。如果你的代码里写了一个没有 if 条件的 wake(),需要停下来想一想,这个唤醒是否一定对应某种真实变化。如果没有,那 100% 是空转 bug。
5.3 典型故障三:偶发卡死,重启才能恢复的竞态
这类问题最坑,因为它在本地低负载时几乎不出现,高并发或慢环境下一跑就崩。本质就是 3.4 里说的“唤醒先于注册”。
排查要点:
- 检查事件源“置位完成状态”和“读取 waker”的顺序。
- 检查 poll 里“读取完成状态”和“注册 waker”的顺序。
- 如果状态和 waker 不是同一个锁保护的,你还需要确认二者的内存序是否可靠。
更稳妥的写法永远是:先注册 waker,再检查一次状态。把检查放在注册之后,就能避免“事件发生在检查之后、注册之前”的最危险窗口。
5.4 调试手段:给唤醒链路打标记
我调试唤醒问题时从不猜。习惯是先用 tracing 或者最朴素的 println! 把以下四个事件打出来:
- poll 开始
- poll 返回 Pending
- wake 被调用
- poll 再次开始
看到日志就能立刻判断链路是哪一环断了。如果 poll 返回 Pending 后没有对应 wake,问题在事件源;如果 wake 打了但 poll 没再进,问题在执行器队列;如果 poll 反复进但状态没变化,问题在 future 内部状态机。
也可以用 loom 对自定义执行器和 Waker 实现做并发模型验证。loom 会模拟各种线程交错,帮你找出“偶发”竞态。不过我一般只在写通用库时才跑 loom,普通业务代码用日志定位已经足够。
6. 生态里常见的 Waker 用法与进阶技巧
6.1 用 std::task::Wake 避开 unsafe
标准库提供了 Wake trait,只要你的任务类型实现了它,就能直接通过 Waker::from(Arc<Task>) 得到 Waker,完全不用手写 RawWaker 和 vtable。这也是我在新代码里最推荐的方式,unbounded 的 vtable 操作留给极少数的底层封装即可。
rust复制use std::sync::Arc;
use std::task::{Wake, Waker};
struct MyTask;
impl Wake for MyTask {
fn wake(self: Arc<Self>) {
// 入队
}
fn wake_by_ref(self: &Arc<Self>) {
// 借用方式唤醒,可以避免一次 Arc clone
}
}
let waker = Waker::from(Arc::new(MyTask));
注意 Wake trait 默认实现了 wake_by_ref,它会先 clone 再调 wake。如果你追求极致性能,可以自己覆写 wake_by_ref,避免一次原子加引用计数。
6.2 测试和临时驱动:noop_waker 与 waker-fn
调试阶段经常需要“一个什么都不干的 waker”。futures::task::noop_waker() 直接给你一个空转 Waker。我用它来测一个 future 手写 poll 状态机时,可以快速驱动而不引入执行器。
如果你想把 poll 当成同步函数测试,可以用 waker-fn crate,把闭包变成 Waker:
rust复制use waker_fn::waker_fn;
use std::pin::pin;
use std::task::Context;
let waker = waker_fn(|| {
println!("wake called");
});
let mut cx = Context::from_waker(&waker);
let fut = pin!(async { 1 + 1 });
let res = fut.poll(&mut cx);
在单元测试里,这种轻量工具比起一个完整执行器划算得多。它也能让你直观看到“一个 async 块第一次 poll 会发生什么”。
6.3 从 Waker 理解整个异步闭环
最后聊点实际经验。我见过很多刚学 Rust 异步的人,在上手 Tokio 时会觉得 Waker 跟自己关系不大,反正 tokio::spawn 一调,调度都交给运行时了。这个判断基本正确,但当你要自定义一个 future、写一个 channel、做 I/O 封装,甚至自己写一个小执行器的时候,Waker 就从背景板变成了核心协议。
理解 Waker 给我最大的收益是,以后看任何异步运行时源码都不再发怵。看到 wake() 调用,我会自动去想这背后是一个任务入队;看到 poll 返回 Pending,我会去想这个 future 到底把 waker 存到了哪个状态里;看到偶发的任务卡死,我会优先检查“事件完成”和“waker 注册”这两个操作的相对顺序。
Rust 异步的神经系统并不复杂,它只是把一个“别人通知我”的朴素需求,封装成了一个胖指针。你把这条链路的每个环节都亲手写过一遍,就会发现,所谓异步编程,其实就是 poll、Pending、Waker、wake、再 poll 这个循环的无限延展。掌握 Waker 之后,你再回头用 Tokio,整个心里就有底了。
