How does asyncio's event loop work, and what does `async`/`await` actually do?
Quick Answer
The **event loop** is a single-threaded scheduler that runs one coroutine at a time, switching to another whenever the running one hits an `await` on something not yet ready (I/O, a timer, another coroutine) — it's cooperative multitasking, not preemptive. `async def` defines a coroutine function; calling it returns a coroutine object (nothing runs yet); `await` suspends the current coroutine until the awaited thing completes, yielding control back to the event loop to run other ready tasks in the meantime.
Detailed Answer
async def creates a coroutine function
async def fetch_data():
print("start")
await asyncio.sleep(1) # suspend here; event loop runs other work meanwhile
print("done")
return 42
coro = fetch_data() # nothing has run yet -- just a coroutine object
Calling fetch_data() does not execute the body — like a generator,
it returns a coroutine object that must be driven (via await, asyncio.run,
or scheduled as a task) for its code to actually execute.
The event loop: single-threaded cooperative scheduling
import asyncio
async def worker(name, delay):
print(f"{name} starting")
await asyncio.sleep(delay)
print(f"{name} done")
async def main():
await asyncio.gather(
worker("A", 2),
worker("B", 1),
)
asyncio.run(main())
# A starting
# B starting
# B done <- after ~1s
# A done <- after ~2s (not 3s! -- they ran concurrently)
asyncio.gather schedules both worker coroutines as concurrent tasks.
When worker("A", 2) hits await asyncio.sleep(2), it tells the event
loop "wake me up in 2 seconds, and meanwhile run something else" — the
loop then runs worker("B", 1) until it also suspends. This is why the
total time is ~2s (the max), not ~3s (the sum): the two sleep calls
overlap because a single thread is interleaving them, not running them in
true parallel, but scheduling them so neither blocks the other while
waiting.
What await actually does
await only works on awaitables (coroutines, Tasks, Futures). It:
- Suspends the current coroutine at that point, saving its state (much like a generator's suspended frame — coroutines are, in fact, implemented on the same underlying mechanism as generators).
- Registers a callback so the event loop knows to resume this coroutine once the awaited thing completes.
- Returns control to the event loop, which picks another ready task/ callback to run.
- When the awaited operation finishes, the event loop resumes the
original coroutine exactly where it left off, and
awaitevaluates to the awaited thing's result.
Cooperative, not preemptive
Because there's no operating-system-level time-slicing, a coroutine that
never awaits anything (e.g., a tight CPU-bound loop with no suspension
points) blocks the entire event loop — no other coroutine gets to run
until it returns. This is the single most important asyncio rule: only
await genuinely yields control; ordinary synchronous code inside an
async def function runs to completion without interruption.
Interview-ready summary: The event loop is a single-threaded
scheduler running one coroutine at a time; await is the only point
where control can be voluntarily handed back to the loop, letting other
coroutines make progress while the current one waits on I/O. This
cooperative model gives massive I/O concurrency on one thread, but a
coroutine that blocks without awaiting stalls every other task.