An Async Mental Model That Sticks
· #python #async
The bug report was: two coroutines that increment the same counter
somehow never race. New Python users expect a lock there, because
they’ve been taught threads first and asyncio looks like threads with
async sprinkled on. It isn’t. It’s one thread, and understanding why
that counter never races is the whole mental model in one example.
counter = 0
async def increment():
global counter
counter += 1
async def main():
await asyncio.gather(*(increment() for _ in range(1000)))
print(counter) # always 1000, no lock needed
That never races because counter += 1 never yields control mid-
operation — there’s no await inside it, so once it starts it runs to
completion before anything else gets a turn. The moment you add an
await inside that function, the guarantee is gone.
The event loop is a single-threaded scheduler, not a thread pool
Threads give you preemptive concurrency — the OS can interrupt a thread at almost any instruction and hand the CPU to another one, which is exactly why threaded code needs locks around shared state.
asyncio gives you cooperative concurrency. Only one coroutine runs
at a time, on one thread, and it keeps running until it hits an await
that actually suspends — at that point, and only at that point, the
event loop is free to run something else.
task A: runs -----await-----> [suspended] ...... resumes -----returns
task B: runs -----await-----> [suspended] ......
resumes
No two of those “runs” segments overlap in time. Ever. That’s the whole
trick behind why plain Python data structures — dicts, lists, a plain
integer — are safe to share across coroutines without a lock, as long as
you never await in the middle of a read-modify-write.
await is the only place anything else can happen
Once that clicks, most “impossible” async bugs stop being mysterious.
Every one of them reduces to: something ran between two lines you
assumed were atomic, and the reason is there’s an await between them
you didn’t notice was a handoff point.
async def transfer(from_acct, to_acct, amount):
balance = await get_balance(from_acct) # <- yields here
if balance >= amount:
# another transfer() could have run entirely in between
await debit(from_acct, amount)
await credit(to_acct, amount)
Between the await get_balance and the await debit, the event loop is
free to run any other ready task — including another transfer() call
against the same account, reading the same now-stale balance. This is a
real race, and it’s real because of the await, not despite it.
Synchronous code never has this class of bug because nothing else ever
gets a turn mid-function. Async code has exactly this class of bug, at
exactly the points marked await, and nowhere else.
Blocking calls are the model’s real enemy
If cooperative scheduling only works because coroutines yield at
await, then a coroutine that never yields — because it’s doing
something synchronous and slow — freezes the entire program, not just
itself.
async def bad_handler():
time.sleep(2) # blocks the ONE thread; every task waits
return "done"
async def good_handler():
await asyncio.sleep(2) # yields; everything else keeps running
return "done"
time.sleep doesn’t know it’s running inside an event loop. It just
blocks the OS thread, and since asyncio only has the one thread doing
all the work, every other task — every other request your server is
handling — stalls for those two seconds too. This is the single most
common way people accidentally serialize a server they built to be
concurrent, and it’s silent: no error, no warning, just a service that
mysteriously gets slower under load in a way that doesn’t match request
volume.
What sticks
Three sentences, and they cover almost everything:
- One thread. Only one coroutine’s code is ever actually executing at a given instant.
- Control only changes hands at
await(or equivalent suspension points) — never mid-statement, never for free. - Anything that blocks without yielding — a
time.sleep, a synchronous DB driver, a CPU-heavy loop — blocks the entire program, not just its caller.
Every asyncio bug I’ve debugged is one of those three facts being forgotten in a specific place. Keep them loaded and most async code stops looking mysterious and starts looking like ordinary sequential logic with clearly marked handoff points.