Python

async/await and asyncio basics

The GIL lesson already established that threading helps I/O-bound work by overlapping waits. asyncio gets that same overlap a completely different way — one thread, explicitly handing off control at every await, instead of the OS switching between threads.

Intermediate

4 min read

The problem, restated from the GIL lesson

import time
 
def fetch_slow(name, seconds):
    print(f"{name} starting")
    time.sleep(seconds)     # blocks — nothing else can happen on this thread meanwhile
    print(f"{name} done")
 
fetch_slow("A", 2)
fetch_slow("B", 2)
# total: about 4 seconds — B doesn't even start until A finishes completely

Calling these two functions one after another takes roughly the sum of their wait times, because each call fully blocks until it finishes — nothing else runs on this thread while time.sleep() is waiting. The GIL lesson's fix for this was threading, letting the GIL move to another thread during a wait. asyncio solves the identical problem — overlapping I/O waits — with a different mechanism: one thread, and code that explicitly says where it's safe to pause and let something else run.

async def and await: functions that can pause

import asyncio
 
async def fetch_slow(name, seconds):
    print(f"{name} starting")
    await asyncio.sleep(seconds)     # pauses HERE, hands control back, resumes later
    print(f"{name} done")
 
async def main():
    await asyncio.gather(fetch_slow("A", 2), fetch_slow("B", 2))
    # total: about 2 seconds — A and B's waits overlap
 
asyncio.run(main())

async def marks a function as a coroutine — calling it doesn't run the body immediately, it returns a coroutine object, similar to how calling a generator function doesn't run its body either (from the generators lesson). await is the actual pause point: await asyncio.sleep(seconds) yields control back to whatever's managing the coroutines (the event loop) exactly at that line, letting another coroutine run during the wait, then resumes this one once the wait is over. asyncio.gather(...) runs multiple coroutines concurrently, and since both fetch_slow calls spend their time waiting (not computing), their waits genuinely overlap — the same overlap threading achieved, via cooperative pausing instead of OS-level thread switching.

Cooperative, not preemptive — the actual mechanical difference from threading

async def cpu_heavy():
    total = 0
    for i in range(100_000_000):   # no await anywhere in this loop
        total += i * i
    return total

Threading (from the GIL lesson) is preemptive — the OS/interpreter can switch which thread runs at almost any point, without that thread's code needing to cooperate. asyncio is cooperative — a coroutine keeps running uninterrupted until it hits an await, and only then does control get handed back to the event loop. A CPU-heavy coroutine like the one above with no await anywhere blocks the entire event loop for its whole duration — every other coroutine, no matter how ready it is to run, has to wait, because nothing forces a handoff without an explicit await. This is the direct asyncio-specific version of the GIL lesson's core lesson: asyncio genuinely helps I/O-bound work, and provides zero benefit (and a real risk of blocking everything) for CPU-bound work.

Why a blocking call inside a coroutine defeats the whole point

async def broken_fetch():
    time.sleep(2)   # WRONG — a plain, blocking sleep inside a coroutine
 
async def correct_fetch():
    await asyncio.sleep(2)   # correct — this actually yields control

time.sleep() is a normal, blocking function — calling it inside a coroutine doesn't pause cooperatively, it blocks the entire event loop exactly like it would block a plain thread, because nothing about time.sleep() knows how to hand control back. This is one of the most common real asyncio mistakes: using a library function that isn't async-aware (a blocking database driver, requests instead of an async HTTP client) inside a coroutine silently defeats the entire benefit — the coroutine "pauses" in a way that blocks everything else anyway, with no error raised to signal the mistake.

asyncio.gather vs. sequential await

# Sequential — each await fully waits before the next line runs; no overlap
result_a = await fetch_slow("A", 2)
result_b = await fetch_slow("B", 2)
# total: about 4 seconds
 
# Concurrent — both start immediately, waits overlap
result_a, result_b = await asyncio.gather(fetch_slow("A", 2), fetch_slow("B", 2))
# total: about 2 seconds

await on its own doesn't create concurrency by itself — awaiting one coroutine, then awaiting another on the next line, runs them one after another, with no overlap at all. asyncio.gather() is what actually starts multiple coroutines running concurrently and waits for all of them together — this is a common point of confusion, since both versions "use async," but only the gather() version actually overlaps the waits.

When to reach for asyncio vs. threading vs. multiprocessing

All three exist to solve genuinely different shapes of problem, echoing the GIL lesson's core distinction one level further: asyncio and threading both target I/O-bound work, with asyncio typically scaling to far more concurrent connections (thousands of open network connections) than threading can practically manage, precisely because it avoids the overhead of real OS threads — but only when the code calling it is written to cooperate with await, which ordinary blocking libraries were never designed to do.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What's the core mechanical difference between threading and asyncio's approach to overlapping I/O waits?

2. What happens when you call an async def function without awaiting it?

3. Why does calling time.sleep() inside an async def coroutine defeat the purpose of using asyncio?

4. Why does asyncio.gather(fetch_slow('A', 2), fetch_slow('B', 2)) finish faster than awaiting each one sequentially?