Phase 5 · Advanced PythonModule 31~38 min read

Asynchronous Python

Handle thousands of I/O operations efficiently with asyncio and async/await.

What you'll learn

Async lets a single thread juggle thousands of I/O operations by switching between them whenever one is waiting. No threads, no locks, no race conditions — just cooperative pauses at await.

By the end of this lesson you'll be able to:

  • Explain what problem async solves and how the event loop works
  • Write coroutines with async def and await
  • Run many coroutines concurrently with asyncio.gather
  • Group related tasks safely with asyncio.TaskGroup
  • Know when async is the right tool — and when it isn't

What async solves

Threads work for concurrency, but each thread carries memory and switching overhead — a few thousand is a lot. Async runs on one thread and an event loop: whenever a task hits an await on something slow, the loop parks it and runs another ready task. This scales to tens of thousands of simultaneous connections, which is why web servers and network tools love it.

Coroutines: async & await

A function defined with async def is a coroutine. Calling it doesn't run it — it returns a coroutine object you must await (or hand to the event loop with asyncio.run). await means "pause here until this finishes, and let other work run meanwhile."

first.py
import asyncio

async def greet(name):
    print(f"hello {name}")
    await asyncio.sleep(1)        # give control back while "waiting"
    print(f"bye {name}")

asyncio.run(greet("Ada"))         # run the coroutine to completion

Running many at once

The payoff appears when you await many things together. asyncio.gather starts all the coroutines and waits for them concurrently — three one-second calls finish in about one second, not three:

gather.py
import asyncio, time

async def fetch(n):
    await asyncio.sleep(1)        # simulate a 1s network call
    return n * 10

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(fetch(1), fetch(2), fetch(3))
    print(results, f"in {time.perf_counter() - start:.0f}s")

asyncio.run(main())

Key idea

gather returns results in the order you passed them, regardless of which finished first. This is the async equivalent of the thread pool from the last lesson — but on a single thread, with no locks.

Structured concurrency

Modern asyncio (Python 3.11+) prefers asyncio.TaskGroup: it starts tasks and guarantees they all complete before the async with block exits. If any task raises, the group cancels the rest and propagates the error — no silently lost tasks.

taskgroup.py
import asyncio

async def work(n):
    await asyncio.sleep(0.1)
    return n * n

async def main():
    async with asyncio.TaskGroup() as tg:   # Python 3.11+
        t1 = tg.create_task(work(2))
        t2 = tg.create_task(work(3))
    # the block exits only when every task is done
    print(t1.result(), t2.result())

asyncio.run(main())

When to use async

Async shines for high-concurrency I/O: many network calls, web servers, chat, scraping. The catch is that everything must cooperate — one blocking call freezes the entire loop.

blocking.py
import asyncio, time

async def bad():
    time.sleep(1)          # WRONG: blocks the whole event loop!

async def good():
    await asyncio.sleep(1) # RIGHT: yields control while waiting

Watch out

Never call blocking code (time.sleep, requests.get, heavy CPU loops) directly in a coroutine — it blocks every other task. Use async libraries (asyncio.sleep, httpx, aiohttp) or offload blocking work with asyncio.to_thread(...). For CPU-bound work, use processes instead.

Recap & quick check

Key takeaways

  • Async runs on one thread with an event loop, switching tasks whenever one awaits something slow.
  • async def defines a coroutine; calling it returns a coroutine object you must await or asyncio.run.
  • await pauses the current coroutine and lets other ready tasks run meanwhile.
  • asyncio.gather runs many coroutines concurrently and returns results in input order.
  • asyncio.TaskGroup (3.11+) gives structured concurrency: all tasks finish, errors cancel the rest.
  • Async is for high-concurrency I/O; a single blocking call freezes the whole loop — use async libraries or offload work.

Quick check

1. What does calling an async def function do?

2. What does await do?

3. Why do three 1-second fetches in asyncio.gather finish in ~1 second?

4. What happens if you call time.sleep(1) inside a coroutine?

5. Which workload is async best suited to?

You can now write fast, concurrent code. Next we make sure it's correct code — and stays that way. Next up: Module 32 — Testing.