Python asyncio Explained: async, await, Tasks and gather
asyncio runs many waits on one thread: async def makes a coroutine, await yields to the event loop, and gather runs them concurrently with ordered results.
- Course: Python study plan
- Module: Concurrency — threads, processes and asyncio
- Kind: Lesson
- Reading time: 15 min
- Runtime: CPython 3.11
How do async and await work in Python?
In Python, async def defines a coroutine function: calling it returns a coroutine object that does nothing until it is awaited or scheduled. await runs an awaitable and, while it waits, hands control back to the asyncio event loop, which runs other coroutines. asyncio.run(main()) starts the loop, and asyncio.gather runs several coroutines concurrently, returning their results in argument order.
Lesson
asyncio runs many waiting operations on one thread. An async def function is a coroutine: calling it builds an object that does nothing until the event loop drives it, and inside it await hands control back to the loop until the awaited thing is ready. The loop runs whichever coroutine can make progress, so ten downloads overlap on one thread — with no locks needed, because a switch happens only at await. This lesson covers the vocabulary (coroutine, task, event loop, awaitable), asyncio.run, await versus creating tasks, gather for ordered fan-out, sleep as the model of a wait, and the mistakes — calling without awaiting, blocking the loop — that every beginner makes once.
Coroutines and the loop
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay) # yield to the loop for `delay` seconds
return f"{name} ready"
async def main():
result = await fetch("a", 0.1) # runs fetch to completion, then continues
print(result)
asyncio.run(main()) # creates the loop, runs main, closes the loop
fetch("a", 0.1) does not run anything — it returns a coroutine object. await runs it and produces its return value; while it is suspended in asyncio.sleep, the loop may run other coroutines. asyncio.run(coro) is the entry point: exactly one per program, at the top level, never inside a running loop. Everything that is awaited must be an awaitable: a coroutine, a Task, a Future, or an object with __await__.
Sequential versus concurrent
async def main():
a = await fetch("a", 0.2) # 0.2 s
b = await fetch("b", 0.2) # then another 0.2 s — sequential, 0.4 s total
async def main():
a, b = await asyncio.gather(fetch("a", 0.2), fetch("b", 0.2)) # both at once, 0.2 s
await one thing at a time is sequential — the loop has nothing else to run. Concurrency comes from starting several before waiting: gather(*awaitables) schedules them all and returns their results as a list in argument order, whatever order they finished in. That ordering is what makes gather the deterministic tool, like executor.map.
Tasks
async def main():
task = asyncio.create_task(fetch("a", 0.2)) # scheduled now; runs at the next await
other = await do_something_else()
result = await task # collect it later
create_task(coro) wraps a coroutine in a Task and schedules it; it starts running the first time the current coroutine awaits anything. A task is a Future: done(), result(), cancel(), add_done_callback. Keep a reference to every task you create (in a list or a set) — the loop holds tasks weakly, and a task with no reference may be garbage-collected mid-flight. gather creates tasks for the coroutines it is given, so the common pattern needs no explicit create_task at all.
sleep(0) and the order of interleaving
async def worker(name, log):
log.append(f"{name} start")
await asyncio.sleep(0) # yield once: let the others reach this point
log.append(f"{name} end")
async def main():
log = []
await asyncio.gather(worker("a", log), worker("b", log))
print(log) # ['a start', 'b start', 'a end', 'b end']
asyncio.sleep(0) yields exactly once. The loop's scheduling is FIFO and deterministic: tasks run in the order they were created, each until its next await. That makes small asyncio traces reproducible — the sequence above is the same every run — unlike thread interleavings, which depend on the operating system. For judged exercises this is the model: control the order with await points, collect with gather, print after.
The loop in one paragraph
The event loop is a queue of callbacks and a selector. call_soon appends a callback to the ready queue; each iteration runs every callback that was ready at the start, in order, then asks the operating system which sockets and timers are ready and queues their callbacks. A task is one callback that runs its coroutine until the next await; the awaited future, when it completes, calls call_soon for the task again. Nothing pre-empts anything: a coroutine keeps the loop until it awaits, which is why shared state is safe between awaits — and why a long computation starves every other task.
Timeouts and cancellation
try:
result = await asyncio.wait_for(fetch("a", 5), timeout=1.0)
except asyncio.TimeoutError:
result = "timed out"
task.cancel() # raises CancelledError inside the task at its next await
wait_for cancels the awaitable when the timeout expires. Cancellation is cooperative: CancelledError is thrown into the coroutine at its current await, and the coroutine may clean up in finally — but should not swallow the exception. 3.11 also has asyncio.timeout() as a context manager (async with asyncio.timeout(1.0):).
Blocking the loop
time.sleep(1), requests.get(...), a CPU loop of a second, input() — anything that does not await — freezes every coroutine for its duration, because the loop is one thread. The replacements: await asyncio.sleep, an async HTTP client (aiohttp, httpx), and for something with no async version, await asyncio.to_thread(blocking_fn, *args), which runs the call on a thread pool and awaits its result. A CPU-bound stage belongs in a ProcessPoolExecutor via loop.run_in_executor.
Pitfalls
- Calling a coroutine without
await— nothing runs, and Python warns "coroutine … was never awaited". awaitin a plaindef— a syntax error; the caller must beasync deftoo, all the way up toasyncio.run.- A blocking call in a coroutine.
- Awaiting sequentially when the work is independent.
- Not keeping references to created tasks.
asyncio.runinside a running loop (notebooks) — useawaitdirectly there.
Key takeaways
async defbuilds a coroutine;awaitruns it and yields to the loop while it waits;asyncio.rundrives the top-level one.- Sequential
awaits do not overlap;gather(...)runs awaitables concurrently and returns results in argument order. create_taskschedules a coroutine to run at the nextawait; keep a reference; a task is a future.- Scheduling is FIFO and reproducible:
sleep(0)yields once, so small traces are deterministic. - Never block the loop;
to_threadandrun_in_executorbridge to blocking and CPU-bound code;wait_for/timeoutbound a wait, cancellation is cooperative.
Common questions
What happens if you call a coroutine without await?
Nothing runs. Calling an async def function only creates a coroutine object; if it is never awaited or scheduled as a task, the body never executes and Python emits a RuntimeWarning that the coroutine was never awaited.
What is the difference between await and asyncio.create_task?
await coro runs the coroutine to completion before the next line, so two awaits in a row are sequential. create_task(coro) schedules it to start at the next await and returns a Task to collect later, so the work overlaps. Keep a reference to every task you create.
Why does time.sleep freeze an asyncio program?
The event loop is a single thread, and a coroutine keeps it until it awaits. A blocking call such as time.sleep, requests.get or a long CPU loop stops every other coroutine; use await asyncio.sleep, an async client, or await asyncio.to_thread(fn) for blocking code.
Does asyncio.gather return results in order?
Yes. gather(*awaitables) runs them concurrently and returns a list of results in argument order, whatever order they finished in. That makes it the deterministic tool, like executor.map for threads.
How do you add a timeout in asyncio?
Wrap the awaitable in asyncio.wait_for(aw, timeout=1.0), which cancels it and raises asyncio.TimeoutError when time runs out, or use async with asyncio.timeout(1.0): from Python 3.11. Cancellation is cooperative: CancelledError arrives at the coroutine's current await.
Exercises
gather with a finish log
Read lines <name> <steps> until EOF. Write async def job(name, steps, log) that awaits asyncio.sleep(0) steps times, appends name to log, and returns f"{name}={steps * 10}". In main, run every job with asyncio.gather, print the results joined by ", " (they come back in argument order), then finished <log joined by spaces> — the completion order, which the FIFO loop makes reproducible.
Input: one job per line. Output: two lines.
a 3
b 1
c 2
prints
a=30, b=10, c=20
finished b c aSequential versus concurrent
Read lines <name> <delay_ms> until EOF. Write async def fetch(name, ms) that awaits asyncio.sleep(ms / 1000) and returns f"{name} ready". Run all fetches concurrently with gather and print each result on its own line in input order; then print sequential <sum of delays> ms and concurrent <largest delay> ms — the two wall-clock totals the two strategies would take.
Input: one fetch per line. Output: the results, then two summary lines.
db 30
api 10
cache 5
prints
db ready
api ready
cache ready
sequential 45 ms
concurrent 30 msIn this module: Concurrency — threads, processes and asyncio
- The GIL and the three models — threads, processes, asyncio
- Threads — Thread, Lock, Event, Queue and the race you must see once
- concurrent.futures — executors, futures, map and as_completed
- multiprocessing — Process, Pool, pickling, queues and shared state
- asyncio basics — coroutines, await, tasks and gather (this lesson)
- asyncio patterns — TaskGroup, queues, semaphores, async iteration and bridging
- Checkpoint — Concurrency
← multiprocessing — Process, Pool, pickling, queues and shared state · asyncio patterns — TaskGroup, queues, semaphores, async iteration and bridging →