12k
All articles

JavaScript、Rust 与 Python 中的 async/await 对比

比较 JavaScript、Rust 和 Python 的 async/await:了解函数何时启动、遗漏 await 会发生什么,以及如何并发运行任务。

OpenReplay Team
OpenReplay Team
JavaScript、Rust 与 Python 中的 async/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。Future trait 描述的是一个只有在被轮询(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
JavaScriptPromise.all直接调用函数即可
Rusttokio::join!tokio::spawn
Pythonasyncio.gather、asyncio.TaskGroupasyncio.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-thread feature),而 flavor = "current_thread" 可切换为单线程运行时。
  • Python: async def 是语言本身的一部分,无需任何 import。但要运行协程,你需要一个事件循环,而 asyncio.run() 会创建事件循环、运行你的顶层协程,然后关闭该循环。

对比总结

JavaScriptRust(Tokio)Python(asyncio)
调用异步函数执行到第一个 await返回一个惰性的 Future返回一个惰性的协程
忘记 await任务会执行;rejection 可能导致 Node 崩溃编译器警告,任务不执行RuntimeWarning,任务不执行
并发Promise.alljoin! / spawngather / 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 它们。

Open-source session replay

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

We use cookies to improve your experience. By using our site, you accept cookies.