Inside Python’s asyncio Event Loop
asyncio’s event loop is a scheduler that repeatedly runs small pieces of Python code, stopping each piece when it must wait and resuming it when the wait is over. When a coroutine reaches await, it usually does not block the thread: its Task pauses, and the loop runs something else.
The problem the loop solves
Without asynchronous scheduling, a program handling several network requests could wait inside one request for a socket, timer, or database response. During that wait, the thread would do no useful work for the other requests. One traditional answer is a thread per request, but threads bring memory costs, synchronization concerns, and context switching.
asyncio instead uses cooperative concurrency: running code keeps control until it reaches an explicit suspension point such as await. A single thread can then make progress on many operations whose time is mostly spent waiting.
This is concurrency, not automatic parallelism. If a coroutine performs a long CPU-bound calculation without yielding, it prevents the event loop from running other tasks.
What await means at runtime
Consider this small example:
import asyncio
async def fetch_after_delay():
await asyncio.sleep(1)
return "done"
Calling fetch_after_delay() creates a coroutine object: an object representing work that can be started and resumed. It does not execute the function body immediately. When the coroutine is wrapped in an asyncio.Task, the task arranges for the event loop to start running it.
A Task is asyncio’s bookkeeping object for one coroutine. It records whether the coroutine is running, waiting, finished, or failed, and it stores the value or exception that should be sent back into the coroutine when it resumes.
The first time the task runs, Python advances the coroutine until one of three things happens:
- it returns a result;
- it raises an exception; or
- it reaches
awaiton an object that represents unfinished work.
That third case is the important one. The awaited object is generally a Future, an object that will hold a result later. asyncio.sleep() uses a timer to complete a Future after the requested delay. Network operations use similar futures connected to the event loop’s I/O monitoring.
The coroutine is not saved as a new function call on the stack. Python’s coroutine machinery preserves its current execution state—roughly, the next instruction, local variables, and exception-handling state—so the task can continue from that point later.
How suspension works
Conceptually, the sequence looks like this:
- The task runs the coroutine.
- The coroutine evaluates the expression after
await. - That expression produces an unfinished Future or another awaitable, meaning an object that can eventually provide a result.
- The task registers a callback on that object.
- The task stops advancing the coroutine and becomes waiting.
A callback is a function scheduled to be called when some event occurs. In this case, the callback is associated with the Future’s completion. The task itself is not placed back in the ready queue merely because it is waiting; that would run it too early.
The event loop can now run other ready tasks. For asyncio.sleep(1), it also records a timer deadline. For socket I/O, it asks the operating system to report when a file descriptor—such as a network socket—is readable or writable.
The operating system’s I/O mechanism is commonly called a selector: it waits for activity on many file descriptors in one call. The selector does not run Python code. It reports events, and asyncio turns those events into Python callbacks.
The ready queue
The loop keeps a ready queue, a FIFO queue of callback handles that can run now. A handle is asyncio’s small record of a callable and its arguments. Starting a task, waking a task, and handling completed I/O all eventually result in handles being added to this queue.
A simplified event-loop iteration is:
move expired timers into the ready queue
ask the operating system for I/O events
turn those events into ready callbacks
run the callbacks currently in the ready queue
repeat
In CPython’s asyncio implementation, the ready queue is an internal deque, and the loop’s _run_once() method performs this cycle. These are implementation details, not interfaces application code should depend on, but they explain observed scheduling behavior.
The loop usually waits in the selector only when there is no immediately runnable work. If the ready queue already contains callbacks, it polls without blocking so it can process them promptly.
The loop takes a snapshot of how many ready handles exist at the beginning of the run phase. It runs that batch from left to right. If a callback schedules another callback while it is running, the new callback is normally appended for the next iteration rather than inserted into the current batch. This prevents one callback chain from continually extending the current run and gives other work a chance to run.
“FIFO” is therefore a useful description of the ready queue, not a promise that all asynchronous events have one global ordering. Timers, I/O notifications, and callbacks become ready at different points, and callbacks can schedule more callbacks.
How the task resumes
When the awaited Future completes, asyncio schedules its completion callback. The callback retrieves either the Future’s result or its exception and advances the suspended task.
The task resumes the coroutine as if the await expression had produced that result. For example, after the one-second timer completes, await asyncio.sleep(1) finishes and execution continues with the next statement. If the Future failed, the task resumes by raising that exception at the await point instead.
The task may then reach another await, return, or raise. If it reaches another unfinished awaitable, the same suspension process repeats. If it returns, asyncio marks the task complete and makes its result available to code awaiting the task. If it raises and nobody retrieves the exception, the familiar “Task exception was never retrieved” diagnostic may appear.
This explains why a log line can show a coroutine paused for a long time without a blocked Python thread: its task was waiting for a Future, while the event loop was selecting other ready callbacks or waiting for external I/O. It also explains why a missing await, a blocking library call, or a CPU-heavy loop can stall unrelated requests: the event loop can switch tasks only when the currently running callback or coroutine gives it control.