Rust 的 async/await 本质上不会帮你开线程,也不会自动并发。写出 async fn 并不会立刻跑起来,得到的只是一个“未来的任务”,需要外部的运行时去驱动它继续执行。
接下来别往脑袋里塞成“写了 async 就有并行”的误会。你写了 async fn foo(),编译器并不是把它变成一条能跑的线程。它实则在背后把这个函数改造成一个实现了 Future trait 的类型,换句话说,编译器给你做了个状态机,把局部变量和执行位置都装进了一个结构体里。你去调用 foo(),得到的只是这个未来的结构体实例,根本不会马上进到函数体里干活。想让它动起来,得把这玩意儿交给 executor 去 poll。
再说 .await 的事儿。许多人以为 await 会把当前线程卡住,等到结果回来。错了。.await 更像是跟执行器说“我先放下这把活,你记得什么时候好再叫我”。当一个任务在被 poll 的时候,遇到还没准备好的子 future,就会返回 Poll::Pending,并把一个 waker 注册好。这个 waker 就像留个电话,等子 future 准备好了,底层会打这个电话来唤醒任务。IO 完成、定时器到点、或者别的事件触发时,驱动会调用这个 waker,executor 再次取出任务来 poll,从停下的位置接着走。
把 executor 想象成车站调度员,但别把它神话。像 tokio、async-std 这样的 runtime,主要工作是把那些需要等待的任务收集起来,给每个 task 记好 waker,等到底层 IO 驱动或者定时器发信号时,再把对应的任务放回可运行队列。这个过程是协作式的:future 自己不会无缘无故变成可运行,必须被外面的人(executor)去 poll,底层驱动收到完成事件后叫醒(调用 waker),executor 收到 wake 信号才去 poll,这样任务才继续往下走。没有内核上下文切换那种“重”开销,也没有默认的新线程。
举个常见的流程来把事儿说清楚。你写了一个 async fn main,里面 await 一个 slow()。实际发生的顺序大致是:先调用 main(),得到 main 的 Future;executor 把它 poll,一开始碰到 slow().await,slow 的 Future 还没准备好,于是 main 的 Future 返回 Poll::Pending,executor 把 main 的 waker 存起来;某个时刻底层 IO 完成,驱动把 waker 调用一次,executor 接到 wake 信号,把 main 再次加入运行队列,executor 再次 poll,这回可能得到 Poll::Ready,main 接着往下走。整个过程里线程没被堵死,只有任务在不同时间点被轮询。
有人说 async/await 是“零成本”的,这话有门道。关键在编译期的改写:编译器把 async 函数展开成一个带状态和跳转逻辑的结构体,状态切换靠分支跳转实现,局部变量搬到结构体字段里,执行位置用枚举或状态标记表明。除非你把 Future 放到 trait 对象里或者显式地 box 掉,否则不会额外堆分配。也不会无端牵扯锁或内核级上下文切换,运行时在必要时 poll 并在事件驱动下唤醒,所以开销能压得比较低。
但别把 async 当成线程来用,会闹笑话。它更像是把异步控制流写成同步风格的一种语法糖和编译器功夫。实际的性能和资源占用,还是要看你怎么用 executor,是不是频繁分配 boxed futures,IO 驱动的实现怎么样这些细节。常见的坑有:在 async 代码里偷偷跑同步阻塞操作、频繁 box 或弄大量 trait object、把大量 CPU 密集型工作放到 async 任务里不交给 spawn_blocking,这些都会把理论上的“低开销”打回原形。
说点实操性的,方便你排查问题。遇到性能怪异时,先查三样东西:executor 的配置、代码里有没有阻塞调用、是不是在频繁做堆分配或大量生成 boxed futures。waker 有时候会从别的线程被调用,注意线程安全的问题;pin 和 borrow 的细节也可能导致你不得不用 box 或者 arc,带来额外成本。还有,许多人把 spawn 当万能钥匙,结果把太多任务扔给运行时反而引起调度抖动,这事儿要看场景。
最后再讲个小比方帮记:把 async fn 想成你手里的一张“任务票”。拿着票并不等于你上车了,只有站务员(executor)在合适的时间核票并让你上车,车才动。await 只是你向站务员说明“等下,我还有事,等那件事做好再叫我”,waker 就是站务员手里的那份联络单。别把票当成车本身,也别指望票自己会跑。



收藏了,感谢分享