Concurrency is cooperative. async def creates coroutines and the scheduler interleaves them on a single thread. There is no preemption between coroutines. A coroutine runs until it yields, sleeps, awaits, or returns. The host can still force a pause, see Snapshots. Concurrency comes from interleaving, not parallelism.
There is no asyncio module. The primitives are top-level builtins: run, sleep, frame, gather, with_timeout, cancel, receive.
okTwo kinds of callables
A def body executes when called. An async def body returns a coroutine value that does nothing until driven with run or gather. Only coroutines are cancellable (cancel) and can suspend on real time (sleep).
1
<coroutine>
1A plain def called from a coroutine can still call yielding builtins like sleep and receive. The scheduler snapshots the helper’s frame, suspends the call chain, and re-enters the helper on resume, so its return value lands at the original call site. The module body runs as an implicit coroutine, so top-level statements suspend the same way.
from helperrun
run(coro) executes a single coroutine to completion and returns its value.
25run(c1, c2, ...) accepts multiple coroutines. They run concurrently and the call returns the first argument’s result.
firstawait
Inside an async def, await coro runs the coroutine to completion and resolves to its value, or re-raises its error. The awaiting coroutine parks while the awaited one sleeps or makes a host call, then resumes with the result.
30sleep
sleep(seconds) suspends until seconds of wall time pass. Without a host time hook, a virtual clock advances logically and coroutines interleave deterministically with no real wait. That is useful for tests.
sleep(0) yields to the scheduler without waiting.
a step 1
b step 1
a step 2
b step 2gather
gather(*coros) runs each coroutine concurrently and returns a list of results in argument order. If any coroutine raises, gather re-raises after all peers have terminated. Survivors are not auto-cancelled.
['a!', 'b!', 'c!']The total wall time is max(delays), not the sum. b and c overlap with a’s sleep.
caughtConcurrent host calls
Deferred host calls (for example fetch_text from the network package) run concurrently under gather. Each parks its coroutine, the host resolves them in parallel, and every result is routed back to the exact coroutine that issued it. A failed call raises only in its own coroutine, so a try / except lets the rest of the batch finish.
['ok', 'failed']In a browser host, fetch_text runs the browser’s fetch() inside a Web Worker and is subject to CORS. The native engine multiplexes the same calls on its own reactor, so gather overlaps them there too, with no CORS. See network.
with_timeout
with_timeout(seconds, coro) runs coro and raises TimeoutError if the deadline passes first. The coroutine is cancelled on timeout.
timed outcancel
cancel(coro) flags a registered coroutine for cancellation. On its next scheduler tick it raises CancelledError at the suspension point, runs every enclosing finally, and stops. It cannot be caught or suppressed.
A coroutine in a tight synchronous loop without await or sleep cannot be cancelled until it yields:
async def loop_forever():
for i in range(1_000_000):
pass # no yield, not cancellable here
sleep(0) # cancellable from this point onFor deadline-driven cancellation use with_timeout.
frame
frame() parks the coroutine until the host’s next render frame. Browser embedders hook requestAnimationFrame. Use it for animation loops at display refresh rate. It needs a web host. The native CLI has no render frame to wait for.
from dom import set_attribute
async def animate(node):
for i in range(60):
set_attribute(node, "style", f"transform: translateX({i}px)")
frame() # resumes on the next rendered framereceive
receive() pops the oldest message from the host event queue. When the queue is empty it parks the coroutine until the host pushes one (pushEvent from JS, run_push_event in the ABI). Messages are arbitrary strings, DOM event names from bind_event or anything the embedder sends. A parked receive() is also a natural pause point for snapshots.
async def main():
while True:
msg = receive() # parks until the host pushes an event
print(f"got {msg}")
run(main())async for and async with
async for works against any for-iterable plus coroutines and async generators (an async def with yield). Each iteration resumes the source to its next yield. Behaviour over lists, tuples, and dicts is identical to regular for. There is no __aiter__ / __anext__ dispatch on user classes. Write an async def generator instead.
0
1
2
10
20async with reuses the sync dispatch (__enter__ / __exit__). __aenter__ / __aexit__ are not consulted. For async setup and teardown, use try / finally with explicit await.
Exception types
| Exception | When |
|---|---|
TimeoutError | with_timeout deadline expired |
CancelledError | raised by cancel(), runs finally but cannot be caught |
TimeoutError matches except normally. CancelledError subclasses BaseException, so except Exception does not catch it.
Limitations
- No background tasks. There is no
create_task. All concurrency is structural: a coroutine only progresses while awaited insiderun(...)orgather(...). - No preemption between coroutines.
while True: passinside a coroutine blocks the scheduler. The host can still force a pause via the preempt interval, see Snapshots. - No suspending in a cancelled
finally. Afinallyrunning fromcancel()cannotawaitorsleep, doing so raisesRuntimeError. - Cooperative host loop. The scheduler suspends to the host when it cannot progress synchronously (pending timer, frame, or event). The embedder resumes via
run_start/run_resume/run_push_event, and can serialize a parked run withsave_state/restore_state. See Snapshots and the ABI. - No async comprehensions.
[x async for x in it]is a parse error. - No
gen.send/throw/close. Generators and coroutines are one-way producers. For bidirectional flow, userun/gatherand pass messages via arguments. receive()can park indefinitely. An empty queue with norun_push_eventleaves the coroutine waiting. Pair withwith_timeoutfor a deadline.
Time
The scheduler reads wall time from a host hook. WASM hosts wire it to Date.now() via the host_now_ns import. Native hosts use std::time::Instant. Without a hook, sleep advances a virtual clock so deterministic tests interleave correctly.
To run many of these programs side by side as message-passing tasks, see Workers.