Executable Models of Async/Await🔗ℹ
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).
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🔗ℹ
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.
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.
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.
| (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:
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.
Structured: tasks are nursery-scoped, a scope’s end awaits its
children, and timeout (a cancel scope) is the only cancellation.
Eager promises with static suspension (an await always
yields). No spawn, no cancel, no timeout — there is nothing to
spell.
Eager hot tasks with dynamic suspension (awaiting a completed task
does not yield). No cancellation.
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.
Lazy futures; a spawned task detaches when its handle drops;
cancel is JoinHandle::abort.
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🔗ℹ
|
| 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.
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.
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.
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.
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.
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.
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.