为什么会写这篇
我写 JS 写了好几年,异步那一套是跟着事件循环学的。promise、微任务、宏任务,这些概念每天都在用,我一度觉得已经把异步想明白了。后来开始写 Rust,看到 async/await,第一反应是拿 JS 的模型去套。套了才发现处处对不上。
写 JS 有个方便的地方,运行时是现成的,我几乎不用想"谁在驱动这些队列"。Rust 把这一层露出来了,executor 要自己选,行为要自己理解,想绕都绕不开。这篇把我原来错在哪、又是被什么纠正的,复盘写下来。
我原来那套 JS 模型
我脑子里的异步模型长这样。await 把后面的代码包成续延,promise 完成以后,续延进微任务队列,当前这段同步代码跑完,续延才执行。整个过程跑在一个线程上,主线程永远不阻塞。
这套模型在 JS 里够用,它把调度藏得很干净。我从来没想过"队列是谁在驱动""任务挂起以后睡在哪里"这类问题,语言不逼我想。
偏差一 · 调用一个 async fn,什么都没发生
第一次写 Rust 的异步代码,我在 main 里调用了一个 async fn,然后什么都没发生。没有输出,没有请求。当时代码大概长这样。
rust
use tokio_postgres::{Client, Row};
async fn fetch_user(client: &Client, id: i64) -> Result<Vec<Row>, Box<dyn std::error::Error>> {
let sql = "SELECT * FROM users WHERE id = $1";
let rows = client.query(sql, &[&id]).await?;
Ok(rows)
}
fn main() {
let client = setup_client(); // 连接建立的代码略
fetch_user(&client, 42); // 我以为这一行执行完,SQL 就发出去了
}
我当时以为,调用就会执行。JS 里 async function 一调用,就同步跑到第一个 await,请求当场就出去了。我把这个直觉原样搬进 Rust,fetch_user(&client, 42) 一写,SQL 就应该出进程。
结果什么都没发生。编译时 Rust 还给了一个 unused_must_use 警告,我起初没当回事。查 executor 资料的时候,看到一句 "futures are lazy",才反应过来。Rust 的 future 是惰性的,fetch_user(...) 只是构造一个状态机,client.query(sql) 也只是把查询包装成状态机里的一步,SQL 还没出进程。要等这个 future 被 poll,请求才真的发出去。poll 它的人是 executor,任务得先 spawn 或者 block_on,才有人碰它。
现在我看到 Rust 异步代码,先问一句,这个 future 是谁在 poll 它。没人 poll,它就是一具不会动的状态机,被 drop 的时候连请求的影子都没有。
对照 JS 看更清楚。promise 是急切的,调用即发起,创建和执行之间没有缝隙。future 是惰性的,创建和执行是两件事,中间的差别全在 executor 这一步。
纠正后的完整流程。

偏差二 · await 后面那句,并不总在队列里
我原来以为 await 永远走队列。JS 的 await 永远走微任务队列,就算 promise 已经 settle,续延也要等当前同步代码跑完。我一度以为这是所有 await 的通用语义,Rust 里也应该一样。
有一阵调试一段时序不对的代码,日志顺序怎么都对不上。用 JS 的顺序直觉去推,"当前这段同步代码一定先跑完",实际输出正好相反,await 后面那句日志总是先打出来。直到把"就绪了就同步继续"这条规则立起来,才对上。还是拿 query 说。
rust
async fn run(client: &Client) -> Result<(), Box<dyn std::error::Error>> {
let res = client.query("SELECT 1", &[]).await?;
println!("{:?}", res); // 我以为它要等当前同步代码跑完,其实当场就执行了
Ok(())
}
Rust 的 poll 是普通函数调用,控制权在调用者手里。内层 future 返回 Ready,同一个 poll 里直接继续,不经过任何队列。query.await? 一旦返回 Ready,下一行代码当场就跑,零延迟。
JS 那边,await 永远异步,是语言为了顺序确定性做的硬保证。Rust 这边,poll 已就绪就直接返回,同步直达是 poll 模型自带的性质。想在 Rust 里依赖"当前代码先执行完",先得想清楚 future 到底就绪了没有。
纠正后的执行路径,数据就绪走 Ready 分支当场继续,没就绪才注册 waker 挂起。

偏差三 · 挂起以后,叫醒我的不是 promise
我原来以为唤醒是 promise 的事。JS 里续延和 promise 绑死,promise settle,续延进队列。这套机制是语言内置的,我从来没想过还有别的做法。
实际写起来才发现,Rust 里 poll 返回 Pending 之后,任务躺在哪、谁来叫醒它,答案不在语言里,而在 executor 和 reactor 的约定里。我第一次看到 poll 的签名,完全不知道那个 cx 是干嘛的。
rust
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<T>
查下去才弄明白,executor 通过 Context 把 waker 交给你,内层 future 把它注册进 reactor 的 epoll 注册表。事件就绪,wake() 把任务标记成已通知,放进就绪队列。waker 可以被任何线程持有,谁持有它,谁就能叫醒这个任务。
JS 的唤醒路径是语言写死的,续延和 promise 绑死,settle 之后自动进队列。Rust 的唤醒路径是开放的,waker 是显式对象,注册、唤醒、入队,每一环都露在外面。
纠正后的任务生命周期,挂起后由 Reactor 唤醒、重新入队。

从单线程模型过来,Rust 的 async 还默认跑在多线程 worker 上。任务会被哪个 worker 跑都不确定,共享状态得靠 Arc 和 Send 保证,两个任务可能真的同时在两个核上跑。这种事在我以前的 JS 模型里不存在。JS 里开 worker 是例外,Rust 里多线程是默认。
修正好以后,机制是这样的
把这几处想通以后,我再看 Rust 的 async,图景完整了。
编译器把 async fn 整个函数体编译成一个状态机结构体。每个 await 生成一个状态变体,变体字段里装着它正在等待的内层 future。
rust
enum QueryState {
Start { inner: QueryFuture },
AfterAwait { res: Result<Vec<Row>, Error> },
}
poll 进来,从当前变体开始。变体是 Start,就 poll 内层 future。内层返回 Ready,把结果写进 AfterAwait 变体,继续跑 await 后面的代码。内层返回 Pending,整个函数返回 Pending,下次 poll 再从 Start 变体进来,接着 poll 那个内层 future,不重跑已经走过的代码。
状态机要存内层 future,就牵出 Rust 的一个关键设计。await 之后的代码可能借用 await 之前的值,状态机里会出现指向自己字段的引用,这个结构体不能随便移动。于是 poll 的签名里带着 Pin。
rust
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<T>
Pin 保证状态机不被移动,自引用才安全。JS 那边不需要操心这个问题,续延和它的捕获都活在堆上,GC 统一管。这是 Rust 为了零分配状态机付出的代价,存储布局在编译期就要定死。
就绪队列里只放准备好被 poll 的任务。wake 有合并机制,任务身上有一个已通知位,连续 wake 两次,只入队一次。worker 线程没事做的时候 park 在 epoll 上,事件来了才醒,没有轮询。一百个并发请求,就绪队列里同时只有几个任务,其余九十九个挂在等待状态,躺在 reactor 的 epoll 里,内核替它们等。并发数量不会反映在队列长度上,这条我想了很久才顺过来。
宏任务和微任务的两档分层,JS 里是语言语义,规范写死的。Rust 里没有这个分层,调度策略由你选的 executor 决定,本地队列、全局队列、任务偷取,都是运行时策略。JS 的这套永远不用换,Rust 的这套可以换,换之前得先懂它。
总结
三个偏差有一个共同根源。我用 JS 的模型去猜 Rust 的机制,而 JS 把调度藏进了语言,Rust 把调度露给了运行时。JS 的模型里"谁驱动队列"这个问题不存在,Rust 里它是核心。把这个缺口补上,两套模型就都对上了。
理解 waker 以后,Rust 里那些"任务不跑了"的问题,基本都能自己定位。挂起点、唤醒、队列这三条线理清楚,剩下的就都是细节。
你从 JS 或者别的语言转 Rust 的时候,哪个认知最难纠正?惰性 future、Pin,还是就绪队列的合并机制?评论区聊聊。