async/await and asyncio
Coroutines that do nothing until awaited, the single-threaded event loop, gather and TaskGroup, the blocking call that freezes everything, and timeouts that cancel.
After this lesson you can
- Explain what calling an async function returns and when its body runs
- Run coroutines concurrently with create_task, gather or TaskGroup, and say why sequential awaits are not concurrent
- Recognise a blocking call inside a coroutine and what it does to the event loop
- Apply a timeout and clean up a cancelled task correctly
async def defines a coroutine function. Calling it runs none of
its body; it returns a coroutine object, which runs only when it is
awaited or wrapped in a task. A coroutine that is never awaited never
runs, and when the forgotten object is thrown away Python only emits a
RuntimeWarning: "coroutine ... was never awaited".
asyncio.run(main()) starts an event loop on the current thread
and runs main to completion. The loop runs one task at a time. A task
keeps the thread until it reaches an await on something that is not
ready yet — a sleep, a network read — and then the loop switches to
another task that can make progress. No preemption, no parallelism:
concurrency comes entirely from tasks waiting at the same time.
Concurrent, or just one after another
await fetch_a() followed by await fetch_b() is sequential: b does
not start until a has finished. To overlap them, the second must be
running before you wait on the first:
asyncio.create_task(coro)schedules a coroutine to run as soon as the loop gets a chance, and returns aTaskyou can await later;await asyncio.gather(a(), b())runs them together and returns the results in argument order. By default the first exception is raised to whoever awaits thegather, and the other awaitables are not cancelled: they keep running. Withreturn_exceptions=True, exceptions come back as values in the result list instead;async with asyncio.TaskGroup() as tg:(Python 3.11+) waits for every task created in it. If one fails, the others are cancelled, and the failures are raised together as anExceptionGroup, whichexcept*can pick apart.
import asyncioimport warnings def run(): log = [] async def worker(name): for step in range(2): log.append(f"{name}{step}") await asyncio.sleep(0) # give the loop a turn return name.upper() async def main(): never_awaited = worker("x") kind = type(never_awaited).__name__ with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") del never_awaited # thrown away without being awaited warning = [f"{w.category.__name__}: {w.message}" for w in caught] await worker("a") await worker("b") log.append("|") results = await asyncio.gather(worker("c"), worker("d")) return kind, warning, results kind, warning, results = asyncio.run(main()) return {"unawaited_call_returns": kind, "warning": warning, "log": log, "gather_results": results}Never block the loop
Because everything shares one thread, a call that blocks — time.sleep,
a synchronous HTTP or database client, a long CPU loop — stops every
task until it returns. Use the async versions (await asyncio.sleep,
async drivers), or push blocking work to a thread with
await asyncio.to_thread(fn, ...).
import asyncioimport time def run(): async def ticker(log): await asyncio.sleep(0) log.append("tick") async def blocking(log): tick = asyncio.create_task(ticker(log)) log.append("start") time.sleep(0.05) # holds the thread log.append("end") await tick async def cooperative(log): tick = asyncio.create_task(ticker(log)) log.append("start") await asyncio.sleep(0.05) # lets other tasks run log.append("end") await tick with_blocking, with_await = [], [] asyncio.run(blocking(with_blocking)) asyncio.run(cooperative(with_await)) return {"time_sleep": with_blocking, "asyncio_sleep": with_await}Timeouts and cancellation
asyncio.wait_for(coro, seconds), or the async with asyncio.timeout(seconds): block, cancels the work when time runs out
and raises TimeoutError (since 3.11, asyncio.TimeoutError is the
built-in one). Cancelling a task raises CancelledError inside it,
at the await where it is paused, so its finally blocks run. Catch
CancelledError only to clean up, and re-raise it. task.cancel() only
requests cancellation; the cleanup happens once the task runs again, so
await the task (or gather it) when you need the cleanup finished.
The example below waits on events that are never set instead of long
sleeps, so what times out does not depend on the machine's speed. It
also puts TaskGroup and gather side by side when one task fails.
import asyncio def run(): log = [] async def main(): never = asyncio.Event() # nobody sets it: waiting on it never ends async def slow(): try: await never.wait() log.append("slow finished") finally: log.append("slow cleanup ran") async def sibling(): try: await never.wait() except asyncio.CancelledError: log.append("sibling cancelled") raise async def failing(): await asyncio.sleep(0) raise ValueError("bad input") try: await asyncio.wait_for(slow(), timeout=0.05) except TimeoutError: log.append("TimeoutError raised") try: async with asyncio.TaskGroup() as tg: tg.create_task(sibling()) tg.create_task(failing()) except* ValueError as group: messages = [str(e) for e in group.exceptions] log.append(f"{type(group).__name__}: {messages}") go = asyncio.Event() async def patient(): await go.wait() return "patient finished" waiting = asyncio.create_task(patient()) try: await asyncio.gather(waiting, failing()) except ValueError as e: log.append(f"gather raised {e!r}; patient cancelled: {waiting.cancelled()}") go.set() log.append(await waiting) values = await asyncio.gather(asyncio.sleep(0, "fine"), failing(), return_exceptions=True) log.append([repr(v) for v in values]) asyncio.run(main()) return {"log": log, "same_timeout_error": asyncio.TimeoutError is TimeoutError}Common mistakes
- Calling an async function without
await. You get a coroutine object, the work never happens, and only a warning says so. - Awaiting coroutines one by one in a loop and expecting them to run
concurrently. Create tasks first, or use
gatheror aTaskGroup. - Calling
time.sleepor a synchronous client inside a coroutine. The whole program stalls, not just that task. - Swallowing
CancelledError, so a task that was asked to stop keeps running and timeouts quietly stop working.
What interviewers ask
- How is asyncio different from threads? One thread and cooperative
switching at
awaitpoints, against preemptive switching between OS threads; asyncio scales to many waiting tasks cheaply, but any blocking call stalls everything. - What does
gatherreturn, and what happens when one coroutine fails? Results in argument order; by default the first exception is raised to the awaiting code (the others keep running), and withreturn_exceptions=Trueexceptions are returned as values instead. - How do you add a timeout to an async operation?
asyncio.wait_fororasyncio.timeout; the work is cancelled, itsfinallyblocks run, andTimeoutErroris raised.
Exercise · Pro
Everything that finishes before the deadline
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade