June 2026

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.sleepawait 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 t = 0.0s async t = 0.0s

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.

A
B
C
Async — every await yields the loop, so the waits overlap
A
B
C

The 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.

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