Sync waits block the thread. Async waits yield it.
I spent an evening this week getting async into my body, not just my head. The whole thing collapses into one sentence:
Sync waits block the thread. Async waits yield the thread.
Everything else — coroutines, the event loop, asyncio.gather, the "I forgot to await" bug — falls out of that.
Two ways to "wait two seconds"
Both of these functions sleep for two seconds. They look almost identical. They behave nothing alike.
# SYNC — uses blocking time.sleep def handle(name): print(f"{name} start") time.sleep(2) # 🔴 freezes the thread print(f"{name} done") # server processes one at a time handle("A") # blocks 2s handle("B") # then blocks 2s handle("C") # then blocks 2s # total: 6s
# ASYNC — uses await asyncio.sleep async def handle(name): print(f"{name} start") await asyncio.sleep(2) # 🟢 yields the loop print(f"{name} done") # all three run concurrently await asyncio.gather( handle("A"), handle("B"), handle("C"), ) # total: ~2s
One word changed (time.sleep → await asyncio.sleep) and the program runs three times faster.
Watch it run
Both timelines below tick against the same six-second wall clock so you can see one finish while the other is still on its first task. Hit Run.
Sync — each task blocks the thread
A holds the thread for 2s. B can't start until A finishes. C can't start until B finishes.
await yields the loop, so the waits overlapThe mental model that finally made it click
I kept tripping over the same two things until I separated them clearly:
async def is a declaration. It marks a function as a coroutine — a function that has permission to pause. It does no waiting itself.
await is the action. It's the actual pause. It suspends the current coroutine, hands control back to the event loop, and only resumes when the result is ready. The event loop is free to run anything else in the meantime.
So when three async requests come in: each one runs a tiny bit of code, hits await, hands the loop back. The loop picks up the next one. Within milliseconds all three are paused, all three two-second waits are counting down in parallel, and the loop is sitting idle until any of them wakes up. That's the whole trick — there's nothing magical, just cooperative scheduling at await points.
Where async actually helps
Async wins when functions spend most of their time waiting — network calls, database queries, disk I/O. Those waits are exactly where await yields and lets the loop do something useful.
Async does not help when functions are doing real CPU work (math, image processing, parsing a million-row CSV). There's no await in the hot path, so nothing yields, and you've just written a slower sync program. For CPU-bound work you need multiprocessing, not asyncio.
One more thing that bit me before it stopped biting me: await only works inside async def. Putting await in a regular def is a syntax error. And calling an async def without await doesn't run the body — it just returns a coroutine object that Python eventually warns you about: "coroutine was never awaited." That warning is the single most common async bug.
Five small puzzles to check the model
I had a friend hand me these. They're tiny — a few lines each — and they're the best test I've found of whether the mental model is actually in your fingers. Setup:
async def op1(): await asyncio.sleep(1); return 1 # 1 second async def op2(): await asyncio.sleep(2); return 2 # 2 seconds
① register one, await the other
async def main(): t = asyncio.create_task(op1()) # op1 scheduled, not yet running await op2() # main yields here — both now run await t
~2 seconds. create_task registers op1 with the loop but doesn't actually start it — the loop has to wait for the current coroutine to yield. That yield happens at await op2(). From that instant both op1 and op2 are running concurrently. op1 finishes at t=1, op2 at t=2. The trailing await t is instant because op1 already returned.
② register both, then await
async def main(): t1 = asyncio.create_task(op1()) # 1s t2 = asyncio.create_task(op2()) # 2s await t1 await t2
~2 seconds. Both tasks are scheduled before any await. await t1 yields; both run concurrently from t=0. t1 finishes at 1, then await t2 waits until 2. awaiting tasks in sequence is fine — they're already running.
③ await before create_task
async def main(): await op1() # blocks main until op1 done t = asyncio.create_task(op2()) # only registered AFTER op1 finishes await t
~3 seconds. await op1() at the top means op2 hasn't even been scheduled yet. main is stuck on op1 for 1s, then op2 is created and awaited for another 2s. 1 + 2 = 3. There's no concurrency because the second task didn't exist while the first was running.
④ the trap — registered, but the loop never gets to run
import time async def main(): t = asyncio.create_task(op2()) # scheduled, not yet started time.sleep(2) # 🔴 BLOCKING — loop is frozen await t
~4 seconds. The canonical "async ruined by one blocking call" bug. create_task registers op2, but op2 needs the event loop to actually run, and time.sleep(2) freezes the loop for two full seconds. op2 sits in the queue, untouched. After the sleep returns, main hits await t, finally yields, and only NOW does op2 start its own 2-second asyncio.sleep. Total: 2 + 2 = 4. One blocking call inside one async def erased all the concurrency.
⑤ back to the GIL — threads vs processes
An 8-core machine. A pure-Python CPU computation that takes 4 seconds single-threaded. Split it.
- (a) 4 threads → ~4 seconds. The GIL only lets one thread execute Python bytecode at a time. The four threads time-slice on a single core. Total work is unchanged (often slightly worse from lock contention).
- (b) 4 processes → ~1 second. Each process has its own Python interpreter and its own GIL, so the four run genuinely in parallel on four cores. 4 seconds of work ÷ 4 cores ≈ 1 second.
The reason this matters: asyncio, threads, and multiprocessing are three different tools for three different problems. asyncio overlaps I/O waits on a single thread. Threads help when you're stuck with a blocking I/O library. Multiprocessing is the only one of the three that gives you real CPU parallelism in Python. Reach for the wrong one and you get one of the answers above where you expected a different one.
The takeaway
I think the reason async confused me for so long is that everyone explains it with the keywords first (async, await, coroutine, event loop) and the behavior second. It's much clearer the other way around:
Two functions that both "wait two seconds." One freezes the thread. The other lets the thread keep working. The keywords are just the API for choosing which one you want.
Onto building something with it.
← Back to all posts