Language
Async
Concurrency is cooperative. async def creates coroutines, and the scheduler interleaves them on a single thread. A coroutine runs until it yields, sleeps, awaits or returns.
There is no asyncio module. The primitives are the builtins run, sleep, gather, with_timeout, cancel and receive.
ok
Two kinds of callables
A def body executes when called. An async def body returns a coroutine value that does nothing until run or gather drives it. Only a coroutine can be cancelled.
1 <coroutine object coro> 1
A plain def called from a coroutine can still call yielding builtins such as sleep and receive. The scheduler keeps the frame of the helper and suspends the whole call chain. On resume it re-enters the helper, and its return value lands at the original call site.
The module body runs as an implicit coroutine. Top-level statements suspend the same way.
from helper
run
run(coro) executes a single coroutine to completion and returns its value.
25
run(c1, c2, ...) accepts several coroutines. They run concurrently, and the call returns the result of the first argument.
first
await
Inside an async def, await coro runs the coroutine to completion. It 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.
30
sleep
sleep(seconds) suspends the coroutine for seconds. sleep(0) yields to the scheduler without waiting.
a step 1 b step 1 a step 2 b step 2
The clock that sleep waits on depends on the grants of the run.
- Without any
timescope the run uses a virtual clock.sleepandwith_timeoutdeadlines pass at once and in order, and a host call takes no time. - Any
timescope turns the wall clock on for the whole run.
System describes the scopes.
gather
gather(*coros) runs each coroutine concurrently. It returns a list of results in argument order.
['a!', 'b!', 'c!']
The sleeps overlap. On the wall clock the batch takes the longest delay, not the sum.
If any coroutine raises, gather re-raises after all peers have ended. The survivors are not cancelled.
caught
Concurrent host calls
A system call that waits, such as net.response, runs concurrently with others under gather.
- Each call parks its own coroutine while the host works on all of them.
- Every result goes back to the coroutine that made the call.
- A failed call raises only in its own coroutine, at the line that made the call. A
tryaround it lets the rest of the batch finish. - An uncaught failure has a traceback that points at that line.
import net
async def status(url):
try:
net.response(net.request("GET", url))
return "ok"
except OSError:
return "failed"
# The bad host raises inside its own coroutine, the other still resolves, printing ['ok', 'failed'].
print(gather(status("https://api.github.com/zen"), status("https://nope.invalid/x")))The example needs edge.json to grant net:api.github.com and net:nope.invalid, see System. In a browser each request is a fetch from the Web Worker. The CLI fetches each request on a thread of its own, and on both hosts gather overlaps the calls.
with_timeout
with_timeout(seconds, coro) runs coro and raises TimeoutError if the deadline passes first. The coroutine is cancelled on timeout.
timed out
cancel
cancel(coro) flags a registered coroutine for cancellation. On its next scheduler tick it raises CancelledError at the suspension point. It runs every enclosing finally and stops.
The cancelled coroutine cannot catch or suppress CancelledError.
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. Limits and errors places TimeoutError and CancelledError in the exception hierarchy.
receive
receive() pops the oldest message from the event queue of the host. When the queue is empty it parks the coroutine until the host pushes one. Messages are strings.
- JavaScript shows how a page or a worker pushes them.
- A parked
receive()is a natural pause point for snapshots. - Inside an actor pool its counterpart
send(group, body)hands a string to another group.
async def main():
while True:
msg = receive() # parks until the host pushes an event
print(f"got {msg}")
run(main())When every coroutine waits, the run suspends to the host. The host resumes it once a timer, a host call or a message is ready. Rust shows that loop for a host of your own.
async for and async with
async for works on any for iterable, on coroutines and on async generators. An async generator is an async def with yield.
- Each iteration resumes the source to its next yield.
- Over lists, tuples and dicts it behaves like a regular
for. - There is no
__aiter__or__anext__dispatch on user classes. Write anasync defgenerator instead.
0 1 2 10 20
async with reuses the sync dispatch through __enter__ and __exit__. __aenter__ and __aexit__ are not consulted. For async setup and teardown, use try and finally with an explicit await.
Limitations
- No preemption between coroutines.
while True: passinside a coroutine blocks the scheduler. The host can still force a pause through the preempt interval, see Snapshots. - No suspending in a cancelled
finally. Afinallyrunning fromcancel()cannotawaitorsleep. Doing so raisesRuntimeError. - No async comprehensions.
[x async for x in it]is a parse error. - No
gen.send,throworclose. Generators and coroutines are one-way producers. For flow in both directions, userunorgatherand pass messages through arguments. receive()can park indefinitely. An empty queue with no message from the host leaves the coroutine waiting. Pair it withwith_timeoutfor a deadline.
Why is there no create_task?
All concurrency is structural. A coroutine only progresses while awaited inside run(...) or gather(...).