← 文章 / 编程开发
Hacker News 6小时前 · 2026-09-12 08:14:46 · 0 阅读

异步/等待:设计空间探索与语言差异

如今,许多编程语言都提供了 async/await 关键字来表达并发。其设计思路相当一致:让并发代码看起来更像直线执行逻辑(参见:PythonRustSwift)。因此,我们将包含 async/await 的这种范式称为 直线异步性(straight-line asynchrony),以区别于使用事件循环或回调的模式。

关于直线异步性的语言设计已经酝酿了超过 15 年。在这个项目中,我们希望弄清楚一个问题:不同语言中的 async/await 究竟有多相似,又有多少差异?简短的答案是,差异远超出我们的预期。我们撰写了一篇论文 《Async/Await 设计空间探索》,详细解释了其中的缘由。

你以为你懂 async/await?

为了展示现代语言之间可能存在的巨大分歧,这里有一段用伪代码编写的简短异步程序。其中一个函数负责写入日志,另一个函数则以后台任务的形式触发日志写入并继续执行。

async fn write_to_log():
  print("A")
  // simulate a slow log write
  await sleep(2)
  print("B")

async fn fire_and_forget():
  task = spawn write_to_log()
  // return without awaiting the task

async fn main():
  await fire_and_forget()
  await sleep(1)
  print("C")

你预期这个程序会输出什么?

Check my answer

其实并没有标准答案,因为你的猜测可能在 某种语言 中是正确的。以下是七个现代异步运行时实际的行为表现:

Show me the outputs

仅仅一个用于后台写入日志行的小程序,竟有四种不同的输出结果。更糟糕的是,论文中 证明,在这七个运行时中,没有任何两个 在运行此简单程序的三个变体时能产生相同的输出!

你真的 确切 了解你所用语言的异步语义吗?

为何存在分歧?

在观看某位编程领域网红博主的视频(链接在此)时,你可能听说过“冷”启动或“热”启动的异步函数调用。其核心概念是:“热启动”会立即返回一个在运行时中正在执行的任务,而“冷启动”则返回一个处于惰性状态的对象,在被 await 之前不会执行任何操作。

热启动与冷启动之间的选择,是我们所说的异步设计维度:一种影响程序执行可观察语义的设计决策(而非纯粹的性能问题)。我们将这一特定维度称为“急迫性”(Eagerness)。在我们的论文中,我们从现代直线式异步实现中识别出了九个这样的维度。下文将这些维度归纳为三大类,大致对应任务的生命周期:生命周期起点(Start of Life)、生命周期终点(End of Life)以及取消(Cancellation)。

点击任意语言,即可追踪该语言在表格中的设计选择。

Start of Life
Eagerness

如何求值异步函数的调用。

Lazy

求值为协程,但不继续执行。

Python · Rust

Eager

在当前线程中求值,并在 await 时调度为任务。

C# · JavaScript

Suspension

关于 await 点是否挂起的保证。

Static

保证 await 点必然挂起。

JavaScript

Dynamic

对 await 任务不提供挂起保证。

C# · Swift · Tokio · Smol · Asyncio · Trio

End of Life
Extent

任务默认存在的最大时间区间。

Indefinite

默认情况下,任务可存在于整个运行时生命周期内。

JavaScript · C# · Tokio · Smol · Asyncio

Dynamic

默认情况下,任务可存在于其生成作用域内。

Swift · Trio

Reference Strength

[适用于 Indefinite Extent] 运行时持有任务引用的类型。

Strong

运行时持有强引用。

JavaScript · C# · Tokio

Weak

运行时持有弱引用。

Asyncio · Smol

Destruction

任务在其 Extent 结束时如何清理。

Awaited

任务被 await 至完成。

JavaScript · Trio

Cancelled(取消)

任务被取消,之后可能会被 await。

Swift · Tokio · Smol · Asyncio

Terminated(终止)

程序退出。

C#

Propagation(异常传播)

未被 await 的任务中的异常如何处理。

Destructive(破坏性)

异常由依赖它的任务重新抛出。

Trio

Never(不传播)

异常保留在任务内部。

JavaScript · C# · Tokio · Smol · Asyncio · Swift

Cancellation(取消)
Awareness(取消感知)

任务能否响应取消。

Unaware(无感知)

任务无法响应取消。

Rust

Aware(有感知)

任务可以响应取消。

Asyncio · Trio · Swift

Direction(取消方向)

取消如何沿任务图传递。

Top-Down(自上而下)

从根任务开始,由依赖者传递给被依赖者。

Rust

Bottom-Up(自下而上)

从根任务的被依赖者开始传递给依赖者。

Asyncio · Trio

Simultaneous(同时)

同时传递给所有间接被依赖者。

Swift

Persistence(取消持久性)

[针对有感知的取消] 任务的取消状态持续多久。

Transient(瞬态)

任务可以忽略取消并照常继续执行。

Asyncio

Persistent(持久)

任务可以忽略取消,但取消状态会保留。

Trio · Swift

其中两个维度与我们的示例程序关系最大。采用 Dynamic Extent(动态作用域) 的语言不允许任务比创建它的函数存活得更久。Swift 和 Python+Trio 选择了 Dynamic Extent,与其他语言不同。这意味着在 fire_and_forget 函数内,与 write_to_log 关联的任务不会比 fire_and_forget 函数存活得更久。

Swift 和 Trio 虽然都选择了 Dynamic Extent,但在 Destruction(销毁方式)上有所不同。fire_and_forget 函数作用域结束时,Swift 采用 Cancelled Destruction(取消销毁),取消 task;而 Trio 采用 Awaited Destruction(等待销毁),礼貌地等 write_to_log 执行完。正是 Extent 和 Destruction 的选择,解释了为什么 Swift 输出 "AC" 而 Trio 输出 "ABC"。

每个设计维度都涉及性能、内存占用、易用性、语义等方面的权衡取舍。没有绝对的对错,每种语言都有各自的设计理念。但当决策项如此之多时,即便解释一个小程序的输出也异常复杂!

为了使我们的设计空间更加精确,我们将其转化为一个异步程序核心演算系统的形式语义。该模型让我们能够通过追踪执行过程,确切解释示例程序为何会产生不同的结果。

下图展示了如何利用该形式模型解释不同的执行结果。图中展示了模型的执行轨迹,高亮标出了导致不同结果的语义决策。每个方框代表一个抽象机状态。每个箭头代表一步小步归约,箭头上标注了触发的规则。大多数规则在所有运行时中的行为一致;被高亮的部分是设计决策,每一个高亮处都代表着一条分岔路口。

async fn write_to_log(): print "A"; await sleep(2); print "B"async fn fire_and_forget(): task = spawn write_to_log()async fn main(): await fire_and_forget(); await sleep(1); print "C"block_on(main())out: εacjkmrsC[ spawn write_to_log() ]out: εacjkmrsC[ task ]T: (2, write_to_log, print "B")out: AcjC[ task; cancel task; try await task catch e -> ()]Q: print "A"; await sleep(2); print "B"out: εsC[ spawn coro ]out: εakmrC[ task ]Q: print "A"; await sleep(2); print "B"out: εakmC[ task; await task ]Q: print "A"; await sleep(2); print "B"out: εrblock_on(())T: (2, write_to_log, print "B")out: ACacjkC[ throw "cancelled" ]out: As(); await sleep(1); print "C"out: As(); await sleep(1); print "C"out: εm(); await sleep(1); print "C"out: ABrout: ACBjout: Cmout: ACacksout: ABCrBlock-WaitAsync-AppAwaitAsync-AppeagerOS-IOAsync-Appsemi-eagerAsync-ApplazySpawndynamicSpawnindefiniteOS-IOSignalScheduleScheduleOS-IOSignalCancel-UnstartedOS-IOSignalScheduleBlock-DoneScheduleOS-IOSignalAwait-TaskOS-IOSignalScheduleBlock-DoneScheduleOS-IOAwaitCatch-ExnOS-IOSignalScheduleBlock-DoneBlock-DoneterminatedSignalScheduleBlock-Doneawaitedlanguagesa = Asyncioc = C#j = JavaScriptk = Tokiom = Smolr = Trios = Swift

若要理解这张图以及你最喜欢的编程语言 async/await 系统设计背后的决策,请阅读我们新发布的论文《Async/Await 设计空间探索》

原始来源: Hacker News

评论 (0)