Rust语言圣经中关于 Waker 的描述是很不完整的,导致新手不可能从中看懂 Waker 在实际应用中真正的工作方式。本文旨在对这部分内容做一个补充,帮助新手了解 "Rust 异步生态是如何基于 Rust 编译器提供的能力建立的" 。
如果你读了圣经,还不清楚 Rust 中 async/.await 的工作原理,不知道编译器在其中做了什么,不知道执行器是用来 poll Future 的,可以去读读Rust 异步编程基础这篇。但这篇中没有提到 Waker 的作用。
还有我特别推荐一个开源仓库hexagonal-sun/trale: Tiny Rust Async Linux Executor,是一个难得的兼具简单易懂和符合实际情况两种特性的 Rust 异步执行器的实现。你对 Rust 异步底层有什么问题,都可以去看看,不费太大功夫,很可能能解答你的问题!
Waker 基础概念
因为 Rust 异步中和 Waker 相关相似的概念比较多,容易混淆,所以先罗列梳理一下
有一个 Trait 叫 Wake,它有一个方法叫 wake
rust
impl Wake for TaskId {
fn wake(self: Arc<TaskId>) {
EXEC.with(|exec| {
let mut exec = exec.borrow_mut();
if let Some(task) = exec.waiting.try_remove(self.0.load(Ordering::Relaxed)) {
exec.run_q.push(task);
}
});
}
}
有一个结构体叫 Waker,Waker 有一个 from 方法,可以利用 Impl Wake 的对象生成 Waker。Waker 还有一个 wake 方法,wake 方法内部其实在调用用于生成 Waker 的 Impl Wake 对象的 wake 方法。
rust
let waker = Waker::from(task.id.clone());
waker.wake();
有一个结构体叫 Context,Context 有一个 from_waker 方法,从 Waker 的引用中生成 Context。Context 有一个 waker() 方法,用于获取用于生成 Context 的 Waker 的引用。
rust
let waker = Waker::from(task.id.clone());
let mut cx = Context::from_waker(&waker);
cx.waker();
是不是真挺绕的。
异步库、执行器和 Waker
回顾一下关于 async/.await、Future 和 执行器的知识。Future 是标准库定义的一个 trait,有一个 poll 方法。async 指定的代码块会被编译器编译成一个 Future,代码块执行的逻辑被编译器处理后(形成一个状态机)放到 poll 方法里。.await 只能在 async 中使用,通过 .await 调用其他 async 块或 Future,编译器把 .await 转化为对 Future 的 poll,所以编译器生成的 Future poll 中,外层 Future 的 poll 内部会 poll 内层的 Future,直至最内一层的 Future。最外层的 async 或者 Future 会被放入执行器,执行器负责不断 poll Future,直至其返回完成的状态 。
重点: poll 方法有一个参数 Context,执行器在 poll 最外层 Future 时会传入一个 Context。这个 Context 是编写执行器代码的人通过上面的 from_waker 之类的方法构造的 。最外层的 Future 的 poll 方法内部有编译器生成的 poll 内层 Future 的代码,poll 内层 Future 时,会把这个 Context 传下去,传到最底层的 Future。最底层的 Future 是编写执行器的人手工实现的(不是编译器通过 async/.await 生成的),其 poll 方法内部会利用 Context 找到 Waker。这个最底层 Future 一般用于调用阻塞或者异步的系统 IO,比如 send/recv,如果 IO 不能立即响应,此 Future 会让执行器等待 IO 响应(通过 OS 提供的功能比如 epoll,io_uring),并将 Waker 注册给执行器,再向外层返回 Pending,通过返回值,执行器得知 Future 没有完成 。执行器在没有 Future 可 poll 的时候就等待 OS(epoll, io_uring)的反馈,得到阻塞或异步的 IO 可以继续推进的响应后,就找到此 IO 对应的 Waker(由刚刚的底层 Future 注册),调用 Waker.wake() 将外层 Future 重新放回执行器的待 poll 队列中。**这里的 Waker.wake 也是编写执行器的人负责实现的。**到此,执行器才能异步地调度上层用户随意实现的 Future。
引用自 hexagonal-sun/trale: Tiny Rust Async Linux Executor
圣经只告诉我们 Waker.wake() 告知 执行器 Future 可以继续执行,没有告诉我们这是如何做到的。实际上,只有底层 Future,Waker,Executor 三者利用 OS 协同工作,才能做到上文中的:1. 底层 Future 告知 Executor 等待特定事件,并将 Waker 与事件绑定注册给 Executor;2. Executor 知道从哪里等待 IO 事件的更新,从哪里获取 Future 注册的 Waker;3. Waker.wake() 能将 Future 重新交给 Executor。之所以他们能完成这么复杂的协同,是因为底层 Future,Waker,Executor都是同一伙人编写的,(目前为止)并非协商好特定接口就能做到的。
这就引出一个额外的问题,我们使用的上层异步库,比如LinkBond in rtnetlink - Rust,这种顶层 async 或 Future 也是编 Executor 那帮人实现的吗?并非如此,第三方的人也可以自行实现上层异步库,不过你必须要选择一个 Executor 作为你的执行器,并用该 Executor 提供的底层异步 IO Future。反过来,如果你用了一个 Executor 的底层异步 IO Future,你就不能用其他 Executor 作为你的执行器了!
有一篇博客 Portable and interoperable async Rust证实了上述理论:

讨论
在最初使用 Rust 异步库时,我以为随意挑选一个执行器即可,但想不明白在这种情况下 Waker 是怎么工作的,异步库实现的 Future 怎么知道如何将 Waker 注册给执行器,在它甚至不知道用哪个执行器的情况下?在我了解到实际情况后,感叹道,原来 Rust 中一个执行器就代表一个异步生态,而执行器就是异步生态的核心。编写执行器需要负责的内容要比想象的多。 当时觉得是不是这种上层库和执行器的绑定太不灵活了,怪不得Portable and interoperable async Rust这篇博客呼吁搞一个统一的执行器让上下层分离。但转念一想,比如 C++ 的 Boost.asio 也是一个异步库,干脆同时实现了上层接口和底层执行器,根本没想把执行器这个概念单拎出来,所以 Rust 虽然可能做的还不够,但做的也不少了。