Seven async/await runtimes disagree on three short programs; Brown maps nine semantic axes

A Design Space Exploration of Async/Await

Gavin Gray, Shriram Krishnamurthi, Will Crichton

cs.PL

2026-08-21

Brown maps JS, C#, Swift, Python and Rust async/await onto nine axes; on three short programs, no two of seven runtimes print the same traces.

What problem this solves

JavaScript, C#, Python, Rust, and Swift all ship async/await, and their docs all promise code that "reads like a sequence of statements." That slogan does not survive a language switch. The keywords match. The answers to "what happens on a call," "when does a task die," and "how does cancellation travel" do not.

This OOPSLA 2026 paper from Brown University names the paradigm straight-line asynchrony: the compiler hides callbacks and event loops so concurrent code can look sequential. The core comparison is seven settled, widely used designs: JavaScript in ECMAScript 2025, C14 on .NET 10, Swift 6.2 structured concurrency, Python 3.14 with Asyncio and Trio 0.33, and Rust 1.92 with Tokio 1.50 and Smol 2.0. Kotlin, C++20, F#, Haskell, OCaml, Hack, Nim, Dart, and Zig were surveyed; they stay out of the main table.

The opening experiment is blunt. One "write a log in the background" snippet, three calling contexts, seven runtimes, seven different print traces. No pair matches on all three.

Method

The authors keep only decisions that change whether work runs and in what order. Prefix versus postfix await is out. How a runtime talks to the OS is out, unless it changes observable behavior. They read RFCs, specs, GitHub threads, and surprise-behavior posts, then cut nine axes into start of life, end of life, and cancellation.

Start of life has two axes. Eagerness: what a call does immediately. Python and Rust are lazy: the call returns a coroutine and runs nothing; concurrency needs an explicit spawn into a task. Cand JavaScript are eager: the current thread enters the body and only yields a task at the first await. Swift's async let is semi-eager: the work is queued at the call, the caller keeps going, and output can be nondeterministic. Suspension: whether await is guaranteed to yield. Only JavaScript statically yields, per the spec. Everyone else is dynamic: awaiting an already-finished task can fall through. Static yield prevents starvation; the cost is a runtime round-trip even for a completed await.

End of life has four. Extent: how long a task may live by default. JS, C#, Tokio, Smol, and Asyncio are indefinite (until the runtime ends). Swift and Trio are dynamic (tied to the parent scope). Reference strength, for indefinite designs: JS, C#, and Tokio hold a strong runtime handle, so a dropped handle still runs to completion; Asyncio and Smol hold a weak one. Smol cancels via Drop. Asyncio's weak refs are filed as a CPython bug, and Guido van Rossum does not recall why they were designed that way. Destruction: JS and Trio await outstanding work; Swift, Tokio, Smol, and Asyncio cancel, then maybe await; Cterminates, the process exits, in-flight tasks die. Propagation of unawaited exceptions: only Trio reraises at nursery exit. The rest mostly log or swallow.

Cancellation has three. Awareness: Rust (Tokio and Smol) is unaware, the continuation is never polled again; Python and Swift inject a cancellation exception or flag, so finally can restore invariants. Direction: Rust walks top-down via Drop; Python throws from the leaves upward; Swift sets a flag on the whole subtree at once. Persistence: Asyncio is transient, catch once and later awaits proceed; Trio and Swift are persistent, every later await can raise again. Async cleanup on the persistent side uses a shield that temporarily ignores cancellation.

They encode the choices as operational semantics on λv, using delimited continuations (reset/shift, a bounded continuation that captures exactly the rest of the await) plus exceptions. Each axis colors a different fragment of [Async-App], [Spawn], [Await], and the cancellation rules. The model lives in PLT Redex. The artifact ships runnable translations and a differential fuzzer against the real runtimes.

Results

Figure 1 is the main result. writetolog prints A, sleeps 2, prints B. ex1 calls it without awaiting, then prints C and sleeps 3. ex2 detaches, sleeps 1, prints C. ex3 wraps the awaiting version in timeout(1). The traces:

runtimeex1ex2ex3
C#ACBACACB
JavaScriptACBACBACB
SwiftCABACAC
Python+AsyncioCACAC
Python+TrioCABCAC
Rust+TokioCACACB
Rust+SmolCCAC

Seven unique rows.

Local examples are equally sharp. Under eager evaluation, a repeated call prints ABCAB in JavaScript (static yield) and AABBC in C(dynamic yield). At scope exit, Trio waits and can print ABC; Swift cancels first and may print nothing; Cterminates and may print only A. A child exception in a Trio nursery reraises at the nursery boundary, so A prints and B does not; the same program prints both letters elsewhere.

The formal trace of ex2 shows that matching output can hide different rules. Cand Swift both print AC there: Cbecause the runtime loop terminates leftover tasks, Swift because the parent scope cancels unawaited children. A different context splits them.

There are no throughput, latency, or user-study numbers. Validation is the runnable ports, the Redex model, and the differential fuzzer. The paper does not report how many mismatches the fuzzer found.

Why it matters

Anyone moving async knowledge from JS to Rust, or Cto Swift, should stop treating the keywords as one concept. Lazy "I called it, so it started" is false, and that falsehood is exactly the intuition imported from sync calls. Tokio and Smol are both Rust: one runs dropped tasks to completion, the other cancels them on Drop. Pick the wrong runtime in a library and the program changes.

For language designers the map is the product. Miss any of the nine axes and "code that looks synchronous" grows another print trace. C++20 coroutines are configurable on every axis, so they are left out of Table 1: each library becomes its own async DSL, and knowledge transfer fails even inside one language.

This is a design-space paper, not a ranking that says "use Trio." The authors leave the next questions empirical: how much extra concurrency semi-eager buys versus eager, and at what cost; whether simultaneous cancellation prevents bugs that bottom-up would miss.

Limitations

The paper has no empirical answer for which design matches programmer expectations or produces fewer bugs. Kotlin's implicit suspend, Zig's colorblind I/O, and C++20's fully programmable coroutines appear only in the discussion, unclassified on the nine axes. The main table is seven "settled and widely used" systems; F#, the first major async/await design, is not in it.

Immediately awaited calls (await f()) are excluded on purpose, because they behave like sync calls under every design studied. That pattern is common in real code, so the disagreements here are disagreements about calls that introduce concurrency, not about all async code.

The fuzzer exists; the body never states how many discrepancies it found or what coverage it has. Cand JavaScript have no built-in way to cancel a running task, so the three cancellation axes barely apply. The paper fills the gap with cancellation tokens, which sit at a different layer than language-level cancellation.

Terms

Source

What people are saying

Related papers

All paper explainers