A Design Space Exploration of Async/Await

A Design Space Exploration of Async/Await

Async/Await 设计空间探索

Many programming languages now provide the async/await keywords for expressing concurrency. The design rationale is pretty consistent: to make concurrent programs look more like straight-line code (see: Python, Rust, or Swift). We therefore describe the paradigm which encompasses async/await as straight-line asynchrony, as opposed to using event loops or callbacks. 许多编程语言现在都提供了用于表达并发的 async/await 关键字。其设计理念相当一致:使并发程序看起来更像线性代码(参见 Python、Rust 或 Swift)。因此,我们将这种包含 async/await 的范式称为“线性异步”(straight-line asynchrony),以区别于使用事件循环或回调的方式。

Language designs for straight-line asynchrony have been brewing for over 15 years. In this project, we wanted to understand: how similar or different is async/await between languages? The short answer is a lot more different than we expected. We wrote a paper, “A Design Space Exploration of Async/Await”, to explain how. 线性异步的语言设计已经酝酿了超过 15 年。在这个项目中,我们想要了解:不同语言之间的 async/await 有多相似或多不同?简短的回答是:它们之间的差异远超我们的预期。我们撰写了一篇名为《Async/Await 设计空间探索》的论文,来解释其中的缘由。

So you think you know async/await? To demonstrate how much modern languages can diverge, here’s a small async program written in pseudocode. One function writes to a log, and another fires off the log write as a background task and moves on. 你以为你了解 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")

What would you expect this program to print? There isn’t really a right answer, because you were probably right for some language. Below is how seven modern async runtimes actually behave: 你认为这个程序会输出什么?其实并没有标准答案,因为你的猜测对于某些语言来说可能是正确的。以下是七种现代异步运行时实际的表现:

Four different answers, for a program whose entire job is to write a log line in the background. And it gets worse. In the paper, we show that across the seven runtimes, no two produce the same output for three variations of this simple program! Do you actually know your language’s async semantics? 对于一个仅仅是在后台写入一行日志的程序,竟然有四种不同的输出结果。更糟糕的是,在论文中我们展示了,在这七种运行时中,对于这个简单程序的三个变体,没有哪两个运行时能产生相同的输出!你真的了解你所用语言的异步语义吗?

Why the disagreement? While watching videos from a programming influencer you may have have heard the terms “cold” or “hot” async function calls. The idea is that “hot starts” return a task that is immediately running in the runtime, while “cold starts” return an inert object that does nothing until awaited. 为什么会有分歧?在观看编程博主的视频时,你可能听说过“冷”(cold)或“热”(hot)异步函数调用的术语。其核心思想是:“热启动”返回一个在运行时中立即执行的任务,而“冷启动”返回一个惰性对象,在被 await 之前什么都不会做。

Hot vs. cold functions is what we call an async design dimension: a design decision that affects the observable semantics of program execution (as opposed to matters of pure performance). We call this particular dimension “Eagerness”, and in the paper we identify nine such dimensions from modern implementations of straight-line asynchrony. Below we’ve grouped these nine dimension into three categories that roughly correspond to the lifetime of a task: Start of Life, End of Life, and Cancellation. “热”与“冷”函数是我们所称的“异步设计维度”之一:这是一种影响程序执行可观测语义(而非纯性能问题)的设计决策。我们将这一特定维度称为“急切性”(Eagerness)。在论文中,我们从现代线性异步实现中识别出了九个这样的维度。我们将这九个维度归纳为三个大致对应任务生命周期的类别:生命周期开始(Start of Life)、生命周期结束(End of Life)和取消(Cancellation)。

(Table omitted for brevity, but the categorization highlights the complexity of language-specific choices like Eagerness, Extent, and Cancellation propagation.) (为简洁起见省略表格,但该分类突显了诸如急切性、范围和取消传播等语言特定选择的复杂性。)

Two of these axes are particularly relevant for our example program. Languages with Dynamic Extent do not allow tasks to outlive the functions in which they were spawned. Unlike the other languages, Swift and Python+Trio chose Dynamic Extent. This means that within the function fire_and_forget, the task associated with write_to_log cannot outlive the function fire_and_forget. 其中两个维度与我们的示例程序特别相关。具有“动态范围”(Dynamic Extent)的语言不允许任务的生命周期超过创建它们的函数。与其他语言不同,Swift 和 Python+Trio 选择了动态范围。这意味着在 fire_and_forget 函数内部,与 write_to_log 关联的任务不能超过 fire_and_forget 函数的生命周期。

Although Swift and Trio both chose Dynamic Extent, they differ in choice of Destruction. At the end of the fire_and_forget function scope, Swift uses Cancelled Destruction, and cancels the task, while Trio uses Awaited Destruction and politely waits for write_to_log to finish. The choices of Extent and Destruction explain why Swift prints “AC” and Trio prints “ABC”. 尽管 Swift 和 Trio 都选择了动态范围,但它们在“销毁”(Destruction)的选择上有所不同。在 fire_and_forget 函数作用域结束时,Swift 使用“取消销毁”,直接取消任务;而 Trio 使用“等待销毁”,礼貌地等待 write_to_log 完成。范围和销毁方式的选择解释了为什么 Swift 输出“AC”而 Trio 输出“ABC”。

Each design dimension has trade-offs of performance, memory usage, ergonomics, semantics, etc. There’s no right or wrong answers, and each language has its own design rationale. But with so many decisions, explaining the output for even small programs becomes quite involved! 每个设计维度都在性能、内存使用、人体工程学、语义等方面存在权衡。没有绝对的对错,每种语言都有其独特的设计理由。但由于涉及的决策如此之多,即使是解释小型程序的输出也变得相当复杂!

To make our design space more precise, we translated it into a formal semantics on a core calculus of asynchronous programs. This model lets us explain exactly why the sample program diverges by tracing its execution. 为了使我们的设计空间更加精确,我们将其转化为异步程序核心演算的正式语义。该模型使我们能够通过追踪执行过程,准确解释示例程序为何产生分歧。