Skip to content
Path to Engineer
All lessons
PythonConcurrency15 min read

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.

Day 70 of the 488-day pathway. Published in full — nothing held back.

asyncio I — the event loop, coroutines, await, and why it is not threads

The sentence to own: await does 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 sequential awaits are not concurrent.

You have met this three times already: the browser's loop (Day 014), the selectors loop 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 send machinery 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 — so await alone 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_task or a TaskGroup — 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: async buys 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. That select call 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 the selectors loop 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 means counter += 1 is safe without a lock and most of the race conditions from threading simply don't arise. What isn't atomic is any sequence spanning an await — read a value, await a save, write it back, and another task ran in the middle. That's what asyncio.Lock is for: protecting an invariant across a suspension point. And it's a different type from threading.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, because async spreads: an async function can only be awaited by an async function, so it propagates from the I/O layer up to main. 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. A ContextVar is 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

  1. Write three sequential awaits of a 1-second sleep and time it. Then gather them and time again. (This is the whole day.)
  2. Call a coroutine without awaiting and read the RuntimeWarning.
  3. Fetch 100 URLs with httpx.AsyncClient + gather, and the same 100 with requests in a loop. Record both.
  4. Run with debug=True and put a 0.5-second time.sleep in a coroutine. Read the warning naming your coroutine.
  5. Increment a shared counter from 1,000 tasks with no lock. Confirm it is exactly 1,000 — then explain why, referencing Day 067.
  6. Now put an await in the middle of the read-modify-write and watch it break. Fix it with asyncio.Lock.
  7. Use threading.local() from 100 tasks and observe them share a value. Then use a ContextVar.
  8. Call asyncio.run() inside a coroutine and read the error.
  9. Write an async generator that yields pages and consume it with async for.

Exit questions

Answer aloud, no notes.

  1. What does calling an async def return, and what has run?
  2. What does await actually do?
  3. Why are sequential awaits not concurrent? What creates concurrency?
  4. Walk the event loop's six steps.
  5. Where is the only place the process blocks, and what follows from that?
  6. Why does thousands of connections cost one thread?
  7. Cooperative vs preemptive — what does that mean for locks?
  8. When is a sequence not atomic in asyncio?
  9. asyncio.Lock vs threading.Lock.
  10. Why is threading.local() wrong here, and what replaces it?
  11. What is "colour", and why is it an architectural commitment?
  12. What does debug=True give 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