七种 async/await 语义无一相同,布朗画出九维设计空间

A Design Space Exploration of Async/Await

Gavin Gray, Shriram Krishnamurthi, Will Crichton

cs.PL

2026-08-21

布朗大学把 JavaScript、C#、Swift、Python(Asyncio/Trio)和 Rust(Tokio/Smol)的 async/await 拆成九个语义轴。同一段伪代码三种调用,七种运行时打印结果无一重合,PLT Redex 操作语义解释每处分叉。

这篇在解决什么

JavaScript、C#、Python、Rust、Swift 都有 async/await,文档还都说「写起来像同步代码」。跨语言搬知识时,这套话会害人:表面同名,底层对「调用立刻干什么、任务何时死、取消怎么走」的回答完全拧着。

布朗大学这篇 OOPSLA 2026 论文把这种写法叫做 straight-line asynchrony(直线式异步),靠编译器把回调和事件循环藏起来,让异步代码读起来像顺序语句。对照对象是七套已定型、且在广泛使用的设计:ECMAScript 2025 的 JavaScript、.NET 10 上的 C14、Swift 6.2 的 structured concurrency、Python 3.14 配 Asyncio 与 Trio 0.33、Rust 1.92 配 Tokio 1.50 与 Smol 2.0。另外扫过 Kotlin、C++20、F#、Haskell、OCaml、Hack、Nim、Dart、Zig,主表只收这七套。

开篇一张表就把问题钉死。同一段「后台写日志」伪代码,三种调用方式,七套运行时打出七种不同的字母串,没有任何两套全对上。

方法

他们只收会改「操作有没有发生、发生顺序」的决策,不收 await 写在前还是后这种纯语法,也不收 runtime 怎么对接操作系统这种纯性能问题。材料是 RFC、语言规范、GitHub 讨论和社区踩坑帖,抽出九个轴,按任务出生、死亡、取消分成三组。

出生两轴。Eagerness 问的是:调用一个 async 函数时立刻干什么。Python 和 Rust 是 lazy,返回协程,什么都不跑,要并发必须显式 spawn 成 task;C和 JavaScript 是 eager,当前线程直接钻进函数体,撞上 await 才变成 task 交还调用方;Swift 的 async let 是 semi-eager,立刻挂到队列,调用方继续往下走,输出因此可不确定。Suspension 问的是:await 会不会保证让出。只有 JavaScript 按规范静态让出;其余都是动态的,已经完成的 task 被 await 时可以不挂起。静态让出能防饥饿,代价是已完成的 await 也要绕一圈 runtime。

死亡四轴。Extent:task 默认可活多久。JS、C#、Tokio、Smol、Asyncio 是 indefinite,可以活到 runtime 结束;Swift 和 Trio 是 dynamic,绑在父作用域。Reference strength:indefinite 下 runtime 握的是强引用还是弱引用。JS、C#、Tokio 强,丢了句柄也会跑完;Asyncio 和 Smol 弱。Smol 靠 Drop 取消,Asyncio 的弱引用在 CPython 仓库里被标成 bug,Guido van Rossum 也说不清当初为什么这么设计。Destruction:到期怎么收。JS 和 Trio 会等到跑完;Swift、Tokio、Smol、Asyncio 先 cancel,再可能等待;C直接 terminated,runtime 退出,跑着的 task 被掐掉。Propagation:没人 await 的异常怎么办。只有 Trio 会在 nursery 结束时重新抛出;其余大多只打日志或吞掉。

取消三轴。Awareness:被取消的 task 能不能反应。Rust 的 Tokio 和 Smol 是 unaware,yield 之后不再 poll,等价于扔掉 continuation;Python 和 Swift 会把取消异常或标志塞回去,finally 还能修不变量。Direction:取消在依赖图上怎么走。Rust 自上而下靠 Drop;Python 自下而上从叶子抛异常;Swift 同时打到整棵子树的取消标志。Persistence:取消是一次性还是粘住。Asyncio 是 transient,catch 掉一次之后后续 await 当没事;Trio 和 Swift 是 persistent,后面每个 await 都可能再抛取消。要做异步清理,后两者靠 shield 之类的临时豁免。

这套选择被写成 λv 上的操作语义,用 delimited continuation(reset/shift,有界的 continuation,用来精确切出 await 点之后那截)加异常处理。每个轴对应规则里不同的着色片段。[Async-App]、[Spawn]、[Await] 和取消规则按语言分叉。模型在 PLT Redex 里实现,附件里还有可运行翻译,以及对真实 runtime 的 differential fuzzer。

结果

主证据是 Figure 1。writetolog 打印 A、睡 2、打印 B。ex1 直接调用不 await,再打 C、睡 3;ex2 在 detach 后睡 1 再打 C;ex3 用 timeout(1) 包一层会 await 的版本。打印串如下:

运行时ex1ex2ex3
C#ACBACACB
JavaScriptACBACBACB
SwiftCABACAC
Python+AsyncioCACAC
Python+TrioCABCAC
Rust+TokioCACACB
Rust+SmolCCAC

七行互不相同。

局部例子同样硬。一个 eager 下的重复调用,JavaScript 因静态让出打出 ABCAB,C因动态让出打出 AABBC。作用域结束时,Trio 会等到子任务打完 ABC,Swift 先 cancel 所以可能一个字母都不打,Cterminated,可能只打出 A。Trio 的 nursery 里子任务抛异常,父作用域会在出口重抛,A 能印出来、B 印不出来;同样程序在其他系统里 A、B 都会印。

形式模型还说明:相同输出可以来自不同规则。ex2 上 C和 Swift 都打出 AC,C是 runtime 循环 terminated 掉还在跑的 task,Swift 是作用域结束 cancel 未 await 的子任务。换一个上下文,这两套就会散开。

没有吞吐量、延迟或用户实验数字。验证靠可运行翻译、Redex 模型和对真实 runtime 的 differential fuzzer,论文没报 fuzz 发现了多少不一致。

为什么重要

写异步代码、或从 JS 迁到 Rust、从 C迁到 Swift 的人,不能把关键字当同一概念。lazy 下「调用了等于没跑」会直接违反从同步函数带过来的直觉;Tokio 和 Smol 同属 Rust,一个强引用跑完、一个 Drop 就取消,库绑错 runtime,行为会变。

给语言设计者的用处更实在。新功能漏掉这九轴里任何一轴,都会在「看起来像同步」的代码上长出另一种打印串。C++20 协程每个轴都可配,论文干脆不把它放进 Table 1,理由是每个库都在自造一门异步 DSL,连同一门语言内部都难做知识迁移。

这是设计空间论文,不是「该用 Trio」的排名。作者把下一步写成经验问题:semi-eager 比 eager 多多少并发、贵多少;simultaneous 取消能不能少真实 bug。

局限与存疑

作者承认缺经验数据,没法回答哪套更符合程序员预期、哪套更少出错。Kotlin 的隐式 suspend、Zig 的 colorblind IO、C++20 的全可配协程只在讨论里点到,没有按九轴完整归类。主比较只覆盖「已定型且广泛使用」的七套,F作为最早的主流 async/await 反而没进主表。

他们刻意不管立即 await f() 的调用,因为在所有设计里它都等价于同步调用。真实代码里这种写法极多,论文展示的分歧是「引入并发的那一类调用」的分歧,不能直接外推到全部 async 代码。

fuzzer 存在,但正文没给发现数量或覆盖率。C和 JavaScript 没有内建取消 running task 的机制,取消三轴对这两门语言基本不适用,表格里用 cancellation token 补,和语言级取消不是同一层东西。

术语

原文与代码

社区讨论

相关论文

全部论文解读