AnyLearn
All lessons
Programmingadvanced

Async Python and asyncio

Async Python from the bytecode up. Coroutines vs return values, how the event loop schedules tasks, when async beats threading or multiprocessing, structured concurrency with TaskGroup, and the cancellation rules that production backends live or die by.

Not signed in โ€” your progress and quiz score won't be saved.
Lesson progress1 / 8

Where a sync handler bleeds time

A synchronous web handler that opens a database query and waits 50 ms for a result spends those 50 ms doing nothing. The CPU is idle. Other connections sit in the accept queue. Multiply by every concurrent request and the worker process has effectively serialized them.

Python's answer is cooperative concurrency: one thread, one event loop, and a way for code to say I'm waiting on I/O, give the loop back to someone else. That word is await. When you write await query() you are not blocking โ€” you are yielding control until the I/O finishes.

This is not parallelism. It is overlap. One thread, one core, but the loop multiplexes thousands of connections because none of them ever waits on the CPU. Web servers, brokers, and proxies are almost pure I/O โ€” which is why modern backend Python defaults to this model.

Full lesson text

All 8 steps on one page โ€” for reading, reference, and search.

Show

1. Where a sync handler bleeds time

A synchronous web handler that opens a database query and waits 50 ms for a result spends those 50 ms doing nothing. The CPU is idle. Other connections sit in the accept queue. Multiply by every concurrent request and the worker process has effectively serialized them.

Python's answer is cooperative concurrency: one thread, one event loop, and a way for code to say I'm waiting on I/O, give the loop back to someone else. That word is await. When you write await query() you are not blocking โ€” you are yielding control until the I/O finishes.

This is not parallelism. It is overlap. One thread, one core, but the loop multiplexes thousands of connections because none of them ever waits on the CPU. Web servers, brokers, and proxies are almost pure I/O โ€” which is why modern backend Python defaults to this model.

2. Coroutines are not function calls

async def fetch(url: str) -> str:
    ...

x = fetch("https://example.com")   # x is a coroutine object, NOT the result
result = await fetch("https://example.com")  # await drives it; this is the result

The first line allocates a coroutine object โ€” the function body has not yet executed. The only way to actually run that body is to await it (or schedule it with asyncio.create_task / asyncio.run). Forget the await and the body never runs; CPython emits a RuntimeWarning: coroutine was never awaited at GC time but does not raise.

This is why time.sleep(1) is dangerous inside an async handler. time.sleep is a synchronous, blocking syscall โ€” it freezes the entire thread for one second. The event loop cannot schedule any other task during that second. One slow handler stalls every other request on the worker. The async-safe equivalent is await asyncio.sleep(1), which yields control back to the loop.

3. The event loop, in one picture

The loop is a scheduler over a ready queue. Tasks run until they hit an await; awaiting on I/O parks them with the OS selector until the kernel signals the file descriptor is ready.

flowchart TD
  A["Loop tick"] --> B["Pick task from ready queue"]
  B --> C["Run task until next await"]
  C --> D["Inspect awaited object"]
  D --> E["Coroutine: drive inline"]
  D --> F["I/O: register with selector"]
  E --> A
  F --> G["Loop sleeps on selector"]
  G --> H["FD ready: mark task ready"]
  H --> A

4. Structured concurrency with TaskGroup

asyncio.TaskGroup (3.11+) supersedes asyncio.gather for new code. The difference is cancellation safety: if any child raises, the group cancels its siblings and re-raises an ExceptionGroup. gather leaves siblings running in the background after the first failure unless you pass return_exceptions=True and audit every result yourself.

import asyncio
import httpx

async def fetch(client: httpx.AsyncClient, url: str, sem: asyncio.Semaphore) -> int:
    async with sem:
        r = await client.get(url, timeout=5.0)
        return r.status_code

async def main(urls: list[str]) -> list[int]:
    sem = asyncio.Semaphore(20)
    async with httpx.AsyncClient() as client:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(fetch(client, u, sem)) for u in urls]
    return [t.result() for t in tasks]

The Semaphore bounds in-flight concurrency to 20 โ€” without it you would open all N sockets at once and the kernel would shed them. The httpx.AsyncClient is held open across the whole group so the connection pool is reused.

5. Async โ‰  parallel โ€” pick the right primitive

Async multiplexes I/O on one thread. It does not run CPU work in parallel โ€” the GIL still serializes Python bytecode across threads on a standard CPython build. (PEP 703 ships an experimental free-threaded build from 3.13, but production deployments are still GIL'd.)

The decision:

  • I/O-bound (DB queries, HTTP, file I/O) โ†’ asyncio. One worker handles thousands of sockets.
  • CPU-bound (image processing, parsing, ML inference in Python) โ†’ multiprocessing.Pool or concurrent.futures.ProcessPoolExecutor. Each worker has its own interpreter and its own GIL.
  • Short blocking calls inside async code (a sync driver you can't replace, Pillow.resize) โ†’ await asyncio.to_thread(blocking_fn, args). The call runs on a thread-pool worker; the loop stays free.

threading rarely wins in pure Python because of the GIL. It still helps when the C extension releases the GIL (most stdlib I/O does), but asyncio.to_thread is the modern wrapper for that case.

6. Crossing the async-sync boundary

Sync and async functions have different calling conventions โ€” the coloured functions problem. A sync function returns a value; an async function returns a coroutine that must be awaited. You cannot transparently call one from the other.

import asyncio
from asgiref.sync import sync_to_async

# Sync entrypoint launching async code โ€” only at the top level.
asyncio.run(main())  # nested asyncio.run() raises RuntimeError

# Inside async code: offload a slow sync call to a thread.
result = await asyncio.to_thread(slow_blocking_call, arg)

# Django ORM calls are sync โ€” bridge them explicitly.
user = await sync_to_async(User.objects.get)(id=42)

The bridges: asyncio.run() starts a loop for a top-level entry point. Inside a running loop, asyncio.to_thread() (or anyio.to_thread.run_sync()) offloads sync work to a thread pool so the call site can await it. FastAPI auto-detects sync vs async handlers and applies the wrapper for you โ€” but you still need to know it is happening, or one slow sync handler will saturate the thread pool.

7. Cancellation is part of the contract

Every await is a potential cancellation point. The loop can kill a task at any await by raising asyncio.CancelledError from within the awaited expression. Your code must let that exception propagate โ€” or do cleanup in finally and then re-raise.

async def handler() -> dict | None:
    conn = await pool.acquire()
    try:
        return await conn.fetch("SELECT 1")
    except asyncio.CancelledError:
        # WRONG: swallowing cancellation breaks structured concurrency.
        # The parent TaskGroup now thinks this task finished successfully.
        return None
    finally:
        await pool.release(conn)  # cleanup always runs

If a critical section truly must not be cancelled mid-way โ€” finishing a payment write, say โ€” wrap it with asyncio.shield:

await asyncio.shield(finalize_payment(tx_id))

shield allows the outer task to be cancelled while the inner task keeps running. This is the only idiomatic way to delay cancellation; catching CancelledError and continuing is a bug, not a pattern.

8. Production gotchas โ€” the checklist

The list of things that bite real backends, in rough order of frequency:

  • Never call sync-blocking APIs in an async handler. time.sleep, requests.get, the standard psycopg2 cursors, Pillow.open โ€” all stall the loop for every concurrent request. Use asyncio.sleep, httpx.AsyncClient, asyncpg / psycopg[async], or wrap in asyncio.to_thread.
  • Always timeout external I/O. A hung TCP connection without a timeout pins a Task forever. httpx.AsyncClient(timeout=5.0) or async with asyncio.timeout(10): ....
  • Watch the unawaited-coroutine warning. loop.run_until_complete(some_async_fn) โ€” note: passing the function, not a call โ€” does nothing useful and warns at GC. The bug already shipped.
  • Set PYTHONASYNCIODEBUG=1 in staging. It logs any task that holds the thread longer than 100 ms โ€” fastest way to find the one sync call you forgot to wrap.
  • Don't share httpx.AsyncClient / aiohttp.ClientSession across event loops. They bind to the loop that created them; reusing across asyncio.run calls leaks connections and produces cryptic RuntimeError: Event loop is closed.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Given `async def f(): return 1`, what does the expression `f()` evaluate to?
    • The integer 1
    • A coroutine object that has not yet executed
    • A Task already scheduled on the running event loop
    • A Future resolved with the value 1
  2. A handler inside an asyncio event loop calls `time.sleep(2)`. What happens to other tasks scheduled on the same loop during those 2 seconds?
    • They run in parallel on the loop's worker threads
    • They queue and resume after the sleep, with no other side effects
    • They are stalled โ€” the entire single-threaded loop is blocked
    • asyncio spawns a new worker process to keep them running
  3. One child task in an `asyncio.TaskGroup` raises an exception. What does TaskGroup do that `asyncio.gather` with default arguments does not?
    • It re-raises the exception while letting siblings finish in the background
    • It cancels the sibling tasks and re-raises an ExceptionGroup containing the failure
    • It silently swallows the exception and returns partial results
    • It restarts the failed task with exponential backoff
  4. You have `def parse_pdf(path: str) -> dict`, a pure-Python parser that runs 30 seconds of CPU work per file. You need to process 100 PDFs as fast as possible. Which primitive fits?
    • `asyncio.gather` over coroutines that call `parse_pdf`
    • `await asyncio.to_thread(parse_pdf, path)` for each PDF
    • `concurrent.futures.ProcessPoolExecutor`
    • A `threading.Thread` per PDF
  5. Inside an async handler you catch `asyncio.CancelledError`, log it, and return `None` instead of re-raising. Which invariant of structured concurrency does this violate?
    • The parent (e.g. a TaskGroup) sees the task as completed successfully, defeating cooperative cancellation of its siblings
    • It causes a memory leak by detaching the task from its event loop
    • It runs the `finally` block twice, double-releasing held resources
    • Nothing โ€” swallowing CancelledError is the recommended pattern in production code

Related lessons