The asyncio event loop, and why it is not threads
Coroutines, await and the event loop explained from the ground up — including the distinction from threading that most tutorials skip.
asyncio I — the event loop, coroutines, await, and why it is not threads
The sentence to own:
awaitdoes not mean "wait". It means "suspend me here and let the loop run something else until this is ready." If nothing else is scheduled, there is nothing to run — which is why two sequentialawaits are not concurrent.You have met this three times already: the browser's loop (Day 014), the
selectorsloop that is asyncio (Day 016), and the generator machinery underneath (Day 048). Today it becomes the API.
Part 1 — Coroutines run nothing until scheduled
import asyncio
async def fetch(n):
print("start", n)
await asyncio.sleep(1) # a SUSPENSION POINT — control returns to the loop
print("done", n)
return n * 2
fetch(1) # a coroutine OBJECT. Nothing has run. Nothing will.
# ⚠️ RuntimeWarning: coroutine 'fetch' was never awaited
asyncio.run(fetch(1)) # creates a loop, runs it to completion, closes the loop
EXACTLY AS WITH GENERATORS (Day 047): calling an `async def` builds an object holding
a SUSPENDED FRAME. It executes only when something drives it — the event loop.
⇒ `asyncio.run()` is the single entry point. One per program, at the top.
⇒ ⚠️ never call asyncio.run() inside a coroutine — there is already a loop running.
⚠ The mistake that makes async pointless
# THIS IS SEQUENTIAL. It takes 3 seconds. It is `async` and it is not concurrent.
async def main():
a = await fetch(1) # suspends, and NOTHING ELSE IS SCHEDULED, so we just wait
b = await fetch(2)
c = await fetch(3)
# THIS IS CONCURRENT. It takes 1 second.
async def main():
a, b, c = await asyncio.gather(fetch(1), fetch(2), fetch(3))
WHY — and this is the single most important paragraph in the day:
`await` yields control to the LOOP. The loop then runs whatever else is READY.
⇒ if you have not SCHEDULED anything else, there is nothing else ready,
so the loop has no choice but to wait for your one operation.
⇒ CONCURRENCY COMES FROM CREATING TASKS, NOT FROM WRITING `await`.
gather() / create_task() / TaskGroup schedule them; await alone does not.
"I made it async and it's the same speed" is nearly always this. The async keyword buys the
ability to interleave; gather or create_task is what actually interleaves.
Part 2 — What the loop actually is
THE LOOP, IN ONE ITERATION — and compare Day 016's ten-line selectors loop:
1. run every callback in the READY queue until it is empty
⇒ each runs until it hits an `await` that actually suspends, or returns
2. compute the timeout: how long until the NEXT SCHEDULED timer fires
⇒ a heap of (when, callback) — this is what asyncio.sleep() uses
3. call selector.select(timeout) ⇒ epoll/kqueue — "which sockets are ready?"
⇒ THIS is where the process actually blocks, and it is the ONLY place it should
4. move the ready sockets' callbacks onto the ready queue
5. move any expired timers onto the ready queue
6. repeat
EVERYTHING FOLLOWS FROM STEP 3 BEING THE ONLY BLOCKING POINT:
· thousands of connections cost thousands of FILE DESCRIPTORS and ONE THREAD —
not thousands of stacks (Day 064)
· waiting is free; the kernel does it for you
· AND: while YOUR code is running, select() is NOT being called ⇒ nothing can
be noticed as ready ⇒ THE ENTIRE LOOP IS STOPPED. That is Day 072.
Say that to yourself once more: the loop is a while True that spends its life inside
select(). Anything that keeps control away from select() freezes every connection the process
owns.
# Seeing it:
loop = asyncio.get_running_loop() # inside a coroutine
asyncio.run(main(), debug=True) # WARNS when a callback takes >100 ms
loop.slow_callback_duration = 0.05 # tune the threshold
debug=True is the single best diagnostic in asyncio — it prints "Executing <Task ...> took
0.512 seconds" and names the coroutine that blocked the loop. Run your test suite with it.
Part 3 — Why it is not threads
| Threads (Day 066) | asyncio | |
|---|---|---|
| Scheduling | preemptive — the OS switches anywhere | cooperative — only at await |
| Races | everywhere; += is unsafe (Day 067) |
code between two awaits is atomic |
| Cost per unit | a stack + kernel structure | a small object |
| Realistic scale | hundreds | tens of thousands |
| Works with sync libraries | yes | no — and it is worse than useless |
| Failure mode | a deadlock, a race | one blocking call freezes everything |
THE CONSEQUENCE PEOPLE UNDERUSE: BETWEEN TWO AWAITS, YOUR CODE CANNOT BE INTERRUPTED.
counter += 1 ⇒ SAFE in asyncio. No lock. There is no preemption point.
⇒ so most of Day 067 simply does not apply.
⚠️ BUT: any sequence SPANNING an await is not atomic —
x = d[k]; await save(); d[k] = x + 1 ⇒ another task ran in the middle.
⇒ that is why asyncio.Lock exists: to protect an invariant ACROSS a suspension point.
asyncio.Lock is not threading.Lock — it never blocks the thread, it suspends the task.
⚠ Using a threading.Lock in async code blocks the loop, which is Day 072.
# Context propagation — the async replacement for threading.local() (Day 066):
from contextvars import ContextVar
request_id: ContextVar[str] = ContextVar("request_id")
request_id.set(rid) # per-TASK, not per-thread — and inherited by child tasks
threading.local() is wrong in asyncio because thousands of tasks share one thread — they
would all see the same value. contextvars is task-scoped, and it is how structured logging and
tracing attach a request id in an async service.
Part 4 — Writing it
import asyncio, httpx
async def fetch(client, url):
r = await client.get(url, timeout=5.0) # Day 060 — always a timeout
return r.status_code
async def main(urls):
async with httpx.AsyncClient() as client: # async context manager (Day 048)
return await asyncio.gather(*(fetch(client, u) for u in urls))
asyncio.run(main(urls))
THE RULE THAT DECIDES WHETHER ASYNCIO HELPS AT ALL:
EVERY LIBRARY IN THE PATH MUST BE ASYNC-AWARE.
⇒ httpx/aiohttp not requests · asyncpg or SQLAlchemy 2 async not psycopg2 ·
aiofiles or to_thread for files · redis.asyncio not redis
⇒ ONE synchronous library anywhere in the chain and you have an event loop that
stops dead on every call — SLOWER than threads, because there is only one thread.
⚠ This is "colour": an async function can only be awaited by an async function, so
async spreads from your I/O layer all the way to main. That is a real architectural commitment,
not a local optimisation — and it is the strongest argument for a thread pool when your stack is
synchronous (Day 069).
# The two mistakes that produce a RuntimeWarning and no work:
result = fetch(url) # ⚠️ a coroutine object, never awaited
tasks = [fetch(u) for u in urls] # ⚠️ created but never scheduled
await asyncio.gather(*tasks) # THIS schedules and awaits them
Async generators and comprehensions (Day 048) work as you would expect:
async for row in cursor: and [x async for x in agen()].
Common mistakes
| Mistake | Correction |
|---|---|
Sequential awaits and calling it concurrent |
Concurrency comes from gather/create_task. |
| Calling a coroutine without awaiting | Nothing runs; RuntimeWarning. |
A synchronous library inside async def |
Freezes the loop (Day 072). |
threading.Lock in async code |
Blocks the thread. Use asyncio.Lock. |
threading.local() in async code |
One thread, many tasks. Use contextvars. |
Locking counter += 1 in async |
No preemption between awaits — it is already safe. |
| Assuming any sequence is atomic | Only between awaits; spanning one is not. |
asyncio.run() inside a coroutine |
A loop is already running. |
Several asyncio.run() calls |
One entry point at the top. |
Not running with debug=True |
It names the coroutine that blocked the loop. |
| No timeout on an async request | Day 060 applies identically. |
Interview questions
Q: What is await?
A suspension point. It doesn't wait — it hands control back to the event loop and says "resume me when this is ready". The loop then runs whatever else is ready, and eventually resumes my coroutine through the same
sendmachinery generators use, which is exactly what Day 048's history shows. The important corollary is that if nothing else has been scheduled, the loop has nothing else to run — soawaitalone doesn't create concurrency.
Q: Why are three sequential awaits not concurrent?
Because each one suspends and the loop finds nothing else ready, so it just waits for that operation. Concurrency comes from scheduling —
gather,create_taskor aTaskGroup— which put several coroutines on the loop so that suspending one lets another run. "I made it async and it's the same speed" is almost always this:asyncbuys the ability to interleave, and creating tasks is what actually interleaves.
Q: What is the event loop, mechanically?
A loop that drains a ready queue of callbacks, computes how long until the next scheduled timer, then calls
select— epoll or kqueue — with that timeout to find out which sockets are ready, moves those callbacks onto the ready queue, and repeats. Thatselectcall is the only place the process blocks, which is why thousands of connections cost thousands of file descriptors and one thread rather than thousands of stacks. It's theselectorsloop from Day 016 with coroutines on top so the callbacks read as straight-line code.
Q: How is asyncio different from threads for correctness?
Scheduling is cooperative, so switches happen only at an
await. Code between two awaits can't be interrupted, which meanscounter += 1is safe without a lock and most of the race conditions from threading simply don't arise. What isn't atomic is any sequence spanning anawait— read a value, await a save, write it back, and another task ran in the middle. That's whatasyncio.Lockis for: protecting an invariant across a suspension point. And it's a different type fromthreading.Lock— it suspends the task rather than blocking the thread.
Q: What's the catch with asyncio?
Every library in the path has to be async-aware. One synchronous call —
requests,psycopg2, a file read — and the loop stops dead, which with a single thread is worse than threads would have been. That's an architectural commitment rather than a local optimisation, becauseasyncspreads: an async function can only be awaited by an async function, so it propagates from the I/O layer up tomain. If my stack is synchronous, a thread pool gives me concurrency today and asyncio gives me a rewrite.
Q: How do you carry a request id through async code?
contextvars.threading.local()is wrong, because thousands of tasks share one thread and would all see the same value. AContextVaris task-scoped and inherited by child tasks, which is how structured logging and distributed tracing attach a request or trace id in an async service.
Mini task
- Write three sequential
awaits of a 1-second sleep and time it. Thengatherthem and time again. (This is the whole day.) - Call a coroutine without awaiting and read the
RuntimeWarning. - Fetch 100 URLs with
httpx.AsyncClient+gather, and the same 100 withrequestsin a loop. Record both. - Run with
debug=Trueand put a 0.5-secondtime.sleepin a coroutine. Read the warning naming your coroutine. - Increment a shared counter from 1,000 tasks with no lock. Confirm it is exactly 1,000 — then explain why, referencing Day 067.
- Now put an
awaitin the middle of the read-modify-write and watch it break. Fix it withasyncio.Lock. - Use
threading.local()from 100 tasks and observe them share a value. Then use aContextVar. - Call
asyncio.run()inside a coroutine and read the error. - Write an async generator that yields pages and consume it with
async for.
Exit questions
Answer aloud, no notes.
- What does calling an
async defreturn, and what has run? - What does
awaitactually do? - Why are sequential awaits not concurrent? What creates concurrency?
- Walk the event loop's six steps.
- Where is the only place the process blocks, and what follows from that?
- Why does thousands of connections cost one thread?
- Cooperative vs preemptive — what does that mean for locks?
- When is a sequence not atomic in asyncio?
asyncio.Lockvsthreading.Lock.- Why is
threading.local()wrong here, and what replaces it? - What is "colour", and why is it an architectural commitment?
- What does
debug=Truegive you?
Articulation drill
Record two minutes: "What is asyncio, and when would you not use it?"
Define it mechanically: one thread running a loop that drains a queue of ready callbacks and then
blocks in epoll to find out which sockets are ready. await is a suspension point that hands
control back to that loop. So waiting costs a file descriptor rather than a thread, which is why one
process can hold tens of thousands of connections.
Then the correction that carries the most value: "the thing people get wrong is thinking
await creates concurrency. It doesn't — it only yields. If nothing else has been scheduled, the loop
has nothing else to run, so three sequential awaits take the sum of their times. gather,
create_task and TaskGroup are what actually interleave."
Then when not to use it, which is the half that shows judgement: "when the stack is
synchronous. Every library in the path has to be async-aware — one requests call or one blocking
file read stops the loop dead, and with a single thread that's worse than threads would have been. And
it's contagious: an async function can only be awaited by an async function, so it propagates up to
main. If I have a synchronous driver, a thread pool gives me concurrency today; asyncio gives me a
rewrite. I'd choose asyncio when the concurrency is high — thousands of simultaneous waits — and the
ecosystem is already there."
Previous: Day 069 · Tomorrow: Day 071 — asyncio II:
tasks, gather, timeouts, cancellation, and TaskGroup