async and await with asyncio

Python · Advanced · 15 min

Network servers, clients and bots handle thousands of connections efficiently with asyncio.

async def defines a coroutine function; calling it returns a coroutine object without running anything. Inside, await suspends the coroutine until the awaited operation completes, handing control back to the event loop, which runs other coroutines in the meantime. All of this happens on a single thread: concurrency comes from cooperatively switching at await points, not from parallelism.

asyncio.run(main()) starts the loop and runs your top-level coroutine. To run things concurrently you must turn coroutines into tasks:

  • asyncio.TaskGroup (3.11) is the modern, safe way: tasks created with tg.create_task(...) run concurrently, the async with block waits for all of them, and if one fails the others are cancelled and errors are raised as an ExceptionGroup (handle with except*)
  • asyncio.gather(*coros) returns results in order and is still common
  • asyncio.timeout(seconds) (3.11) bounds how long a block may take

Awaiting coroutines one after another (await a(); await b()) runs them sequentially — the most common reason async code is mysteriously slow.

The cardinal rule: never block the event loop. A call like time.sleep(), requests.get() or a heavy computation freezes every other task. Use async libraries (asyncio.sleep, httpx, aiohttp, asyncpg), and push unavoidable blocking calls to a thread with await asyncio.to_thread(func, *args).

asyncio suits I/O-bound workloads with many waits. It does nothing for CPU-bound work, and mixing sync and async code adds complexity, so choose it deliberately rather than by default.

Example

import asyncio
import time

async def fetch(name: str, delay: float) -> str:
    await asyncio.sleep(delay)          # stands in for network I/O
    return f"{name} done after {delay:.1f}s"

async def main() -> None:
    start = time.perf_counter()
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch(f"job{i}", 0.2 * i)) for i in range(1, 4)]
    print([t.result() for t in tasks])
    print(f"elapsed {time.perf_counter() - start:.1f}s")   # ~0.6s, not 1.2s

    try:
        async with asyncio.timeout(0.1):
            await fetch("slow", 1)
    except TimeoutError:
        print("timed out")

    # run a blocking function without freezing the loop
    result = await asyncio.to_thread(sum, range(1_000_000))
    print(result)

asyncio.run(main())

Remember

  • await yields control to the event loop; everything runs on one thread
  • Use TaskGroup or gather to run coroutines concurrently
  • Never call blocking functions in a coroutine; use asyncio.to_thread

Watch out for

  • Calling time.sleep() or requests inside async code, stalling every task.
  • Awaiting coroutines one by one and expecting concurrency.

Check yourself

  1. `await a(); await b()` where each sleeps 1s with asyncio.sleep. Roughly how long does it take?
  2. What happens to other tasks if one coroutine calls `time.sleep(5)`?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/asyncio-task.html