异步/等待:设计空间探索与语言差异
如今,许多编程语言都提供了 async/await 关键字来表达并发。其设计思路相当一致:让并发代码看起来更像直线执行逻辑(参见:Python、Rust 或 Swift)。因此,我们将包含 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 设计空间探索》!