user@techdebt:~/blog$_
$ cd ..

async/await Under the Hood

loop ready queue: executing: Coroutine A queued Coroutine B queued Coroutine C queued pending I/O: none 0ms
mode:
$ event loop inspector
Step through to inspect state
$ simulation.log

the event loop

At the center of asyncio is a loop. An actual while True that does two things: check which I/O operations are ready, then run the coroutines waiting on them. One thread, checking for ready work, executing it, repeat.

When a coroutine hits an await, it tells the loop “I’m waiting for something, go run other stuff and come back to me when it’s ready.”

import asyncio

async def say_hello():
    print("hello")
    await asyncio.sleep(1)  # yields control back to the loop
    print("world")

asyncio.run(say_hello())

asyncio.run() creates an event loop, runs the coroutine, then shuts down. async def defines a coroutine function. await suspends the coroutine and gives control back to the loop. Between await points, your code runs uninterrupted. This is cooperative scheduling.

concurrent I/O

The real power shows when you run multiple I/O operations at once:

async def main():
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(
            fetch(session, "https://api.example.com/users"),
            fetch(session, "https://api.example.com/posts"),
            fetch(session, "https://api.example.com/comments"),
        )
        print(f"Got {len(results)} responses")

asyncio.gather() runs all three concurrently. When fetch hits an await for the first URL, the loop starts the second request. Then the third. All three are in flight at the same time, on a single thread. Three requests that each take 200ms finish in roughly 200ms total, not 600ms.

the blocking mistake

This is where people get burned:

async def bad_sleep():
    print("starting")
    time.sleep(3)  # blocks the ENTIRE event loop
    print("done")

async def ticker():
    for i in range(5):
        print(f"tick {i}")
        await asyncio.sleep(1)

await asyncio.gather(bad_sleep(), ticker())

You’d expect ticks while bad_sleep waits. They don’t happen. time.sleep(3) freezes the thread. Since the event loop runs on that thread, everything stops. The same applies to requests.get(), heavy file I/O, and CPU-bound computation. If it doesn’t await, it blocks.

The fix: use asyncio.sleep() instead. It yields control to the loop. For blocking libraries, use run_in_executor():

async def fetch_sync(url):
    loop = asyncio.get_event_loop()
    response = await loop.run_in_executor(
        None,  # default ThreadPoolExecutor
        requests.get, url,
    )
    return response.text

The blocking call runs in a separate thread. The event loop stays free. Use the simulation above to see these patterns in action.