JavaScript、Rust 与 Python 中的 async/await 对比
比较 JavaScript、Rust 和 Python 的 async/await:了解函数何时启动、遗漏 await 会发生什么,以及如何并发运行任务。
在 JavaScript 中,调用异步函数的那一刻它就开始执行了。而 Rust 的 async fn 或 Python 的 async def,在被 await 或被调度之前,什么都不会做。
异步任务何时开始执行,这一差异会引发一类常见 bug:把一段可正常运行的 TypeScript 代码移植到 Rust 或 Python 后,要么函数根本没有执行,要么本应并发的调用变成了逐个顺序执行。本文分别在 Node.js、Rust(Tokio)和 Python 3.11+ 中运行同一段小代码,并展示各自的输出顺序。本文假设你已熟悉 JavaScript 的异步编程。如需先补充相关背景,可参阅 OpenReplay 的指南:JavaScript 异步编程最佳实践以及 JavaScript 异步编程技巧与陷阱。
核心要点
- 在 JavaScript 中调用异步函数时,函数体会同步执行到第一个
await为止,因此该await之前的代码会立即运行。 - 调用 Rust 的
async fn会返回一个Future,在该 future 被 await 或交给 Tokio 等运行时之前,函数体中的任何代码都不会执行。 - 调用 Python 的
async def函数会返回一个协程对象,在它被 await 或被包装成 task 之前,不会执行任何代码。 - 如果先创建两个异步调用,再依次 await,那么在 JavaScript 中这些任务会并发执行,而在 Rust 和 Python 中则会顺序执行。
- JavaScript 运行时内置了事件循环。Rust 需要
#[tokio::main]这样的执行器(executor),Python 则通过asyncio.run()启动事件循环。
同一段代码,三种语言,三种输出
下面每个版本都定义了相同的函数:打印一行,await 一次 10 毫秒的休眠,再打印一行。随后都在不 await 的情况下调用该函数。只有 JavaScript 会打印 a: start。
示例基于 Node.js LTS 版本、Tokio 1.x 以及 Python 3.11 或更高版本。
JavaScript(Node.js)
async function work(label) {
console.log(`${label}: start`);
await new Promise((r) => setTimeout(r, 10));
console.log(`${label}: end`);
}
work("a"); // no await
console.log("main: after call");
a: start
main: after call
a: end
Rust(Tokio),依赖配置为 tokio = { version = "1", features = ["macros", "rt", "time"] }
use std::time::Duration;
async fn work(label: &'static str) {
println!("{}: start", label);
tokio::time::sleep(Duration::from_millis(10)).await;
println!("{}: end", label);
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
work("a"); // future created, never awaited
println!("main: after call");
}
main: after call
Python 3.11+
import asyncio
async def work(label):
print(f"{label}: start")
await asyncio.sleep(0.01)
print(f"{label}: end")
async def main():
work("a") # coroutine created, never awaited
print("main: after call")
asyncio.run(main())
main: after call
为什么 Rust 的 async/await 在被 await 之前不会执行?
JavaScript、Rust 和 Python 的 async/await 之间的区别,在于调用异步函数时返回的是什么。JavaScript 返回的是一个代表已开始执行的任务的 Promise;Rust 和 Python 返回的则是一个代表尚未开始的任务的对象。
- JavaScript:
async function会同步执行到第一个await为止。你拿到的 Promise 所对应的函数体已经在运行中了。 - Rust:
async fn返回impl Future。Futuretrait 描述的是一个只有在被轮询(poll)时才会推进的值。在你写下.await或将 future 传给tokio::spawn之类的函数之前,函数体中的任何代码都不会执行。 - Python: 调用
async def函数会得到一个协程对象。asyncio 文档明确指出,调用协程并不会调度它执行。
Gray、Krishnamurthi 和 Crichton 的研究论文将这一设计选择称为异步设计中的”急切性”(Eagerness)维度,包括三种选项:惰性(Lazy)、急切(Eager)和半急切(Semi-Eager)。实践中,JavaScript 在调用处就开始执行任务,而另外两种语言则会等待。
忘记 await 会发生什么?
三种语言都允许你忘写 await,但出错的方式各不相同。JavaScript 仍会执行任务,因此风险在于错误可能无人捕获;Rust 和 Python 则会完全跳过该任务,并给出提示。
JavaScript。 函数体会执行,但如果它 reject 且没有任何代码处理该 rejection,Node 会将其视为错误。--unhandled-rejections 的默认模式是 throw,它会将该 rejection 作为未捕获异常抛出,若未注册处理程序则会终止进程:
async function save() { throw new Error("disk full"); }
save(); // no await, no .catch()
Rust。 编译器会在构建时通过 unused_must_use lint 给出警告:
warning: unused implementer of `Future` that must be used
= note: futures do nothing unless you `.await` or poll them
Python。 当未被 await 的协程被垃圾回收时,CPython 会向 stderr 打印一条警告。asyncio 指南在检测从未被 await 的协程一节中介绍了这一点:
RuntimeWarning: coroutine 'work' was never awaited
三种语言的修复方式相同:在 JavaScript 和 Python 中写 await work("a"),在 Rust 中写 work("a").await。修改之后,三者都会依次打印 a: start、a: end、main: after call。
如何并发执行异步任务?
在 JavaScript 中,只需调用函数即可获得并发;而在 Rust 和 Python 中,你必须通过组合器(combinator)或 task 显式请求并发。
“先创建、后 await”的模式可以清楚地展示这一差异:
const pa = work("a"), pb = work("b");
await pa; await pb; // JS: a: start, b: start, a: end, b: end
let fa = work("a"); let fb = work("b");
fa.await; fb.await; // Rust: a: start, a: end, b: start, b: end
ca = work("a"); cb = work("b")
await ca; await cb # Python: a: start, a: end, b: start, b: end
无论是 Rust 的 future 还是 Python 的协程,在被 await 之前都不会启动,因此每个调用都要等前一个完成后才会执行。修复方式如下:
| 语言 | 等待全部完成 | 立即启动、稍后 await |
|---|---|---|
| JavaScript | Promise.all | 直接调用函数即可 |
| Rust | tokio::join! | tokio::spawn |
| Python | asyncio.gather、asyncio.TaskGroup | asyncio.create_task |
tokio::join! 在当前 task 内并发运行多个 future。而 tokio::spawn 则将 future 作为一个独立的 task 交给运行时,并返回一个可供稍后 await 的 JoinHandle。被 spawn 的 future 必须满足 Send + 'static,这也是 work 的参数类型为 &'static str 的原因。
asyncio.TaskGroup 于 Python 3.11 引入。它会在 async with 代码块退出时等待所有 task 完成;如果某个 task 抛出异常,它会取消其余 task:
async with asyncio.TaskGroup() as tg:
tg.create_task(work("a"))
tg.create_task(work("b"))
即使是 spawn 一个 task,也不像 JavaScript 的函数调用那样”急切”。被 spawn 的函数体要等到调用方让出执行权(yield)后才会运行:
let h = tokio::spawn(work("a"));
println!("main: after spawn");
h.await.unwrap();
t = asyncio.create_task(work("a"))
print("main: after create_task")
await t
两者都会先打印 main: after ...,然后才是 a: start 和 a: end。而 JavaScript 会在执行调用方的下一行代码之前就打印 a: start。需要注意的是:上述 Rust 的输出顺序仅在 current_thread 下成立。在多线程运行时中,其他工作线程可能会立即接手被 spawn 的 task,因此输出行的交错顺序可能有所不同。
事件循环从何而来?
JavaScript 运行时内置了事件循环,而 Rust 和 Python 需要你自行启动:Rust 通过 #[tokio::main] 这样的执行器,Python 则通过 asyncio.run()。
- JavaScript: Node.js 和浏览器在你的代码开始执行之前就已经在运行事件循环了。OpenReplay 关于 JavaScript 事件循环与调用栈的文章解释了其工作原理。在 Node 中,顶层
await可在 ES 模块中使用(.mjs文件,或在package.json中设置"type": "module")。 - Rust: 标准库中没有执行器。
#[tokio::main]会将async fn main包装在 Tokio 运行时中。它默认使用多线程运行时(需要启用rt-multi-threadfeature),而flavor = "current_thread"可切换为单线程运行时。 - Python:
async def是语言本身的一部分,无需任何 import。但要运行协程,你需要一个事件循环,而asyncio.run()会创建事件循环、运行你的顶层协程,然后关闭该循环。
对比总结
| JavaScript | Rust(Tokio) | Python(asyncio) | |
|---|---|---|---|
| 调用异步函数 | 执行到第一个 await | 返回一个惰性的 Future | 返回一个惰性的协程 |
忘记 await | 任务会执行;rejection 可能导致 Node 崩溃 | 编译器警告,任务不执行 | RuntimeWarning,任务不执行 |
| 并发 | Promise.all | join! / spawn | gather / TaskGroup / create_task |
| 运行时 | 内置 | 需选择执行器 | asyncio.run() |
结语
async/await 语法在 JavaScript、Rust 和 Python 中看起来一样,但三种语言对任务何时开始执行有着不同的约定。JavaScript 在调用时就开始执行;Rust 和 Python 则只有在你 await 结果或显式调度时才开始执行。移植异步代码时,请检查每一个没有立即 await 结果的调用处。在 Rust 中,将这些调用改为 tokio::join! 或 tokio::spawn;在 Python 中,则使用 asyncio.gather、TaskGroup 或 create_task。
常见问题
Python asyncio 的 task 能像 JavaScript 异步函数那样急切启动吗?
可以,但需要手动启用。Python 3.12 新增了 asyncio.eager_task_factory,可通过 loop.set_task_factory(asyncio.eager_task_factory) 进行设置。设置之后,新建的 task 会在 create_task 调用内部立即开始运行其协程,直到第一次发生阻塞为止。如果协程在未阻塞的情况下就已完成,它根本不会进入事件循环。默认行为仍是惰性调度,而切换为急切 task 可能会改变执行顺序。
如果丢弃 tokio::spawn task 的 JoinHandle,会发生什么?
task 会继续运行。丢弃 JoinHandle 会使 task 处于分离(detach)状态,它会在后台继续执行,但其返回值会丢失,也无法再对其进行 join。这与 async fn 调用返回的未被 await 的 future 不同,后者根本不会执行。要停止一个被 spawn 的 task,可以在 JoinHandle 上调用 abort()。await 一个已被中止的 task 会返回一个表示已取消的 JoinError。
当某个 task 失败时,asyncio.gather 和 asyncio.TaskGroup 有什么区别?
当某个 task 抛出异常时,asyncio.gather 不会取消其他 task。在默认的 return_exceptions=False 下,第一个异常会立即传递给正在 await 的代码,而其余 task 会继续运行。若设置 return_exceptions=True,异常则会作为结果列表中的元素返回。Python 3.11 引入的 asyncio.TaskGroup 会在某个 task 失败时取消其余 task,从而避免失败的任务在无人察觉的情况下继续运行。
为什么"即发即弃"的 asyncio task 可能在完成之前就消失?
asyncio 事件循环只持有 task 的弱引用,因此如果你的代码从未保存 asyncio.create_task 返回的 task,它可能在执行过程中被垃圾回收。Python 文档建议保留对 task 的引用,例如将每个 task 添加到一个 set 中,并调用 task.add_done_callback(background_tasks.discard)。asyncio.TaskGroup 可以避免这一问题,因为它持有每个 task 的强引用并会 await 它们。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k