Executable Models of Async/  Await
1 The Tower
2 Module Reference
3 Core calculus
LC
-->lc
4 Exceptions
Exn
-->exn/  core
-->exn
5 Platform
define-extended-ev-system
5.1 Sketch:   an eager async language
6 Python asyncio
Async  IO
-->aio
-->>aio
7 Python Trio
Trio
-->trio
-->>trio
8 Java  Script
Js
-->js
-->>js
9 C#
C#
-->c#
-->>c#
10 Swift
Swift
-->swift
-->>swift
11 Rust tokio
Tokio
-->tokio
-->>tokio
12 Rust smol
Smol
-->smol
-->>smol
13 Python substrate
Py
-->py/  core
-->py
14 Rust substrate
Rust
-->rs/  core
-->rs
15 Type checker
type-check
16 Writing Your Own
17 Testing
9.2

Executable Models of Async/Await🔗ℹ

Gavin Gray

What does await mean? More things than you think, and the differences are observable. This package contains executable PLT Redex semantics for the async/await implementations of seven runtimes — Python’s asyncio and Trio, JavaScript, C#, Swift, and Rust’s tokio and smol. These are not sketches: each model has been validated against its real runtime (the paper’s artifact, distributed as a Docker image at ghcr.io/gavinleroy/async-await, reproduces that validation).

1 The Tower🔗ℹ

Each language model is the top floor of a small tower, and only the top floor differs between languages:

  • oopsla26-async-await/lc a call-by-value λ-calculus with state, lists, structs, and delimited control. The control operators (reset/shift) are how tasks suspend; everything else is furniture.

  • oopsla26-async-await/exn exceptions: throw, catch, and throw-in, which raises inside a suspended coroutine. Cancellation will want that.

  • "platform.rkt" the machine. A configuration is (t σ Q T P): a logical clock, a store, a ready queue, a timer table, and a pool of threads. The platform supplies blocking (os/block), timed I/O (os/io), the scheduler rules, and the define-extended-ev-system form that each language instantiates. Time is logical: os/io promises at least its delay, and the clock jumps to pending deadlines.

  • one module per language — the surface forms (async/lambda, await, and whichever of spawn, cancel, or timeout the language actually offers) and their reduction rules.

The payoff of the layering: when two models disagree about a program, the disagreement lives in one file, and it is a semantic decision, not plumbing.

2 Module Reference🔗ℹ

Every language module exports exactly three identifiers, consistently named. For a language L: the language itself; >L, the relation that runs programs (deterministic scheduler spines are collapsed into single steps, so test-->> converges quickly); and >>L, the non-collapsing variant that exposes every successor state — use it when you want the whole interleaving space, not just an execution.

3 Core calculus🔗ℹ

language

LC

value

-->lc : reduction-relation?

The sequential core: a call-by-value λ-calculus over states (σ e) a store paired with the running expression. -->lc is its standard reduction.

The grammar, abridged (arithmetic, comparison, and string forms elided):

  e = x
  | v
  | (e e ...)
  | (if e e e)
  | (let ([x e] ...) e)
  | (letrec ([x e] ...) e)
  | (begin e ...)
  | (set! x e)
  | (reset e)
  | (shift x e)
  | (box e)
  | (unbox e)
  | (set-box! e e)
  | (list e ...)
  | (cons e e)
  | (car e)
  | (cdr e)
  | (empty? e)
  | (struct [x e] ...)
  | (field x e)
     
  v = number
  | string
  | boolean
  | (void)
  | (ptr x)
  | (lambda (x ...) e)
  | (list v ...)
  | (struct [x v] ...)

Two features earn their keep. reset/shift are delimited control — every async language above builds task suspension out of them: a task body runs inside a reset, and awaiting captures the rest of the body with shift as a continuation to park. And the store σ maps names to values through (ptr x) references, so tasks, boxes, and structs are heap objects that survive across suspensions.

4 Exceptions🔗ℹ

oopsla26-async-await/lc plus exceptions. -->exn/core contains just the new rules; -->exn is the full language reduction.

The grammar extension:

  e = ....
  | (throw e)
  | (catch e_handler e_try)
  | (throw-in e_coro e_exn)

A throw unwinds — through a dedicated propagation context, G to the nearest enclosing catch, whose handler receives the payload. throw-in is the asynchronous variant: it arms a suspended coroutine so that the exception raises inside it when it next resumes. The async languages implement cancellation with it — a cancelled task is one that wakes up to an exception it never threw.

5 Platform🔗ℹ

 (require oopsla26-async-await/platform)
  package: oopsla26-async-await

The machine every async language runs on. A configuration is (t σ Q T P):

  t = natural
     
  label = x
  | root
     
  Q = ((label (lambda (x) e)) ...)
     
  T = ((t label (lambda (x) e)) ...)
     
  F = (label e)
     
  FS = (thread F ...)
     
  P = (FS ...)

a logical clock, the store, a ready queue of labeled thunks, a timer table of thunks due at a deadline, and a pool of threads, each a stack of labeled frames. The platform also extends the expression grammar with the os/* hooks a language’s rules schedule work through:

  e = ....
  | (os/block e)
  | (os/time)
  | (os/io e_delay e)
  | (os/start-soon e)
  | (os/start-later e_time label e)

os/block parks the root thread on an awaitable (this is how a program’s main runs); os/io performs I/O that takes at least e_delay logical steps — time is logical, and the clock jumps to pending deadlines rather than ticking.

syntax

(define-extended-ev-system Lang
  #:def-reduction red-id
  maybe-exn-reduction
  #:with-base-lang base-lang-id
  #:with-base-reduction base-red
  maybe-single-threaded
  maybe-serial-dispatch
  grammar-clause ...
  maybe-binding-forms)
 
maybe-exn-reduction = 
  | #:def-exn-reduction red/exn-id
     
maybe-single-threaded = 
  | #:single-threaded
     
maybe-serial-dispatch = 
  | #:serial-dispatch
     
maybe-binding-forms = 
  | #:binding-forms spec ...
Defines Lang as base-lang-id (usually Exn) extended first with the machine above, then with your grammar-clauses — new expression forms and their evaluation-context holes (E, M, G). It binds red-id to the generated scheduler relation over (t σ Q T P): dispatch, timer delivery, os/io, os/block, and garbage collection; red/exn-id, when requested, layers exception propagation over it. Your language’s own rules go in a separate reduction-relation over the same domain, unioned with the generated one.

#:single-threaded makes synchronous code run unbounded on one thread (an infinite loop blocks the runtime, as in a real event loop); #:serial-dispatch selects run-to-completion event-loop dispatch, microtasks before timers. They are independent — Trio uses the first without the second.

The form also injects (deliberately unhygienically) the vocabulary your rules will use: async/main (wraps a surface program into an initial machine state, (async/main #:threads n e)); make-big-step (collapses deterministic scheduler spines); program-output and prog/equiv (observation and equivalence for tests); queue operations Q:push/Q:pop and T:push/T:pop; and the task:* family — task:allocate, task:set-done!, task:set-failed!, task:set-cancelled!, task:is-completed?, task:continue-with, task:add-self-as-dependent!, task:get-dependents, and friends.

5.1 Sketch: an eager async language🔗ℹ

Condensed from oopsla26-async-await/javascript, the smallest complete instance. First the language:

(define-extended-ev-system Toy
  #:def-reduction -->sys
  #:def-exn-reduction -->sys/exn
  #:with-base-lang Exn
  #:with-base-reduction -->exn
  #:single-threaded
  #:serial-dispatch
 
  (e ::= .... (async/lambda (x ...) e) (await e))
  (v ::= .... (async/lambda (x ...) e))
  (E ::= .... (await E))
  (M ::= .... (await M))
  (G ::= .... (await G)))

Then the semantic decision — here, eager calls: applying an async/lambda allocates a task and runs the body immediately on the calling thread, inside a reset so an await in the body can suspend it; settling the task wakes its dependents:

(define -->toy/core
  (reduction-relation
   Toy
   #:domain (t σ Q T P)
   [--> (t σ_0 Q T (FS_0 (... ...)
                    (thread (label (in-hole E ((async/lambda (x (... ...)) e_body)
                                               v (... ...))))
                            F (... ...))
                    FS_1 (... ...)))
        (t σ_2 Q T (FS_0 (... ...)
                    (thread (x_task (reset
                                     (begin
                                       (catch (lambda (v_err) (task:set-failed! x_task v_err))
                                              (task:set-done! x_task e_subst))
                                       (os/start-soon (task:get-dependents x_task)))))
                            (label (in-hole E x_task))
                            F (... ...))
                    FS_1 (... ...)))
        (where/error (σ_1 x_task v_task) (task:allocate σ_0))
        (where/error (x_fresh (... ...)) (gensyms (σ_1 e_body) (x (... ...))))
        (where/error σ_2 (ext σ_1 (x_task v_task) (x_fresh v) (... ...)))
        (where/error e_subst (substitute* e_body (x x_fresh) (... ...)))
        "async-app"]))

An await rule follows the same shape (capture the continuation with shift; park it with task:add-self-as-dependent! or reschedule it with os/start-soon if the task already settled), and the whole language is the union:

(define -->toy
  (union-reduction-relations (make-big-step -->sys/exn) -->toy/core))

Run a program by wrapping it into an initial state: (test-->> -->toy (async/main #:threads 1 e) v). For the remaining decisions a real language forces — dispatch order, destruction at scope exit, cancellation delivery — read the seven instances; each rule is named after the runtime behavior it mimics.

6 Python asyncio🔗ℹ

Lazy coroutines; spawn (create_task) gives indefinite extent; per-task cancel delivered at suspension points.

7 Python Trio🔗ℹ

Structured: tasks are nursery-scoped, a scope’s end awaits its children, and timeout (a cancel scope) is the only cancellation.

8 JavaScript🔗ℹ

Eager promises with static suspension (an await always yields). No spawn, no cancel, no timeout — there is nothing to spell.

9 C#🔗ℹ

Eager hot tasks with dynamic suspension (awaiting a completed task does not yield). No cancellation.

10 Swift🔗ℹ

Structured, semi-eager: async calls are async let children, cancelled and implicitly awaited at scope exit; timeout is the only cancellation source and flags a whole subtree.

11 Rust tokio🔗ℹ

Lazy futures; a spawned task detaches when its handle drops; cancel is JoinHandle::abort.

12 Rust smol🔗ℹ

Lazy futures with weak handles: dropping a task’s handle cancels it.

13 Python substrate🔗ℹ

The bare Python coroutine substrate (no scheduler), shared by the asyncio and Trio towers.

14 Rust substrate🔗ℹ

The bare Rust future substrate (poll-driven, no executor), shared by the tokio and smol towers.

15 Type checker🔗ℹ

procedure

(type-check e [#:rust? rust?])  
any/c any/c
  e : any/c
  rust? : any/c = #f
Bidirectional type checker for surface programs; returns the fully annotated term and its type, or #f on failure.

16 Writing Your Own🔗ℹ

Suppose your language isn’t here. Resist the urge to start from nothing.

  1. Pick the nearest neighbor and copy it. Eager task start? Read oopsla26-async-await/csharp or oopsla26-async-await/swift. Lazy? Read oopsla26-async-await/aio or oopsla26-async-await/tokio.

  2. Decide the semantics before writing rules. Four questions do most of the work: Does calling an async function run it (eager) or build a value (lazy)? Does awaiting a completed task suspend anyway? What keeps an unawaited task alive — a handle, the runtime, a scope? And who dies at cancellation — a task, or a tree of them? Every pair of answers you can observe with a three-line program; the models exist because runtimes answer differently.

  3. Extend the machine. define-extended-ev-system takes your new expression forms and their evaluation-context holes (E, M, G). Forgetting the holes is the classic mistake: your form will parse and then silently never reduce.

  4. Write the scheduler interaction as rules. Dispatch (what runs next) and delivery (what a due or cancelled timer does) are where languages hide their personality. Keep each rule small; name it after the runtime behavior it mimics.

  5. Test with test-->>. Start with the three-line observation programs from step 2, and run them against the real language too — by hand is fine. That comparison is where our models’ bugs were found, and yours will be too.

17 Testing🔗ℹ

Each model’s test submodule is pure Redex — no external toolchains required. The full validation against the real runtimes is part of the paper’s artifact, not this package.