How Await Yields Control to the Event Loop in Python
In Python's asynchronous programming model, the await
keyword pauses the execution of an enclosing coroutine and yields
control back to the event loop. This article explains the underlying
mechanics of this process, detailing how coroutines operate as state
machines, how the __await__ protocol interacts with
generator-style yielding, and how the event loop manages task scheduling
to achieve cooperative multitasking without blocking the thread.
The Foundation: Coroutines as Generators
Under the hood, Python coroutines created with async def
are syntactic and structural evolutions of generators. While regular
functions run from start to finish and return a single value, generators
and coroutines can pause their execution state—including local
variables, instruction pointers, and exception states—and resume
later.
When you define an async def function, Python compiles
it into a coroutine object. Like a generator, a coroutine object cannot
execute on its own; it requires a driver to advance its execution step
by step. In asynchronous Python, the event loop acts as this driver.
The __await__ Dunder
Method
When Python encounters the expression await obj, it does
not immediately halt the entire thread. Instead, it follows these exact
steps:
- Python checks if
objis an awaitable by looking for the__await__()magic method. - It calls
obj.__await__(), which must return an iterator. - Python then iterates through this iterator, propagating whatever values the iterator yields up through the call stack.
Most commonly, the object being awaited is an
asyncio.Task or an asyncio.Future.
The Actual Yield Mechanism
The magic of transferring control back to the event loop relies on a
standard yield statement hidden deep inside the standard
library.
At the lowest level of asyncio, an
asyncio.Future implements __await__ roughly
like this:
def __await__(self):
if not self.done():
self._asyncio_future_blocking = True
yield self # Control is yielded here
return self.result()When a future is not yet resolved, it yields itself. Because the
coroutine executing await delegates to this iterator, the
yield bubbles up through any nested coroutine calls until
it reaches the task runner inside the event loop.
How the Event Loop Receives Control
- Execution Steps Forward: The event loop runs a
scheduled task by calling its
.send(None)method. This starts or resumes the coroutine. - Hit the Suspension Point: The coroutine runs synchronously until it reaches an I/O operation, sleep, or another awaited future that is not complete.
- Yielding Back: The uncompleted future executes
yield self. This suspends the execution frame of the coroutine and returns theFutureobject back to the event loop's task-driving loop. - Registration: The event loop sees that the task has
yielded a future that is not done. It attaches a callback to that
future:
future.add_done_callback(loop._wakeup). - Loop Continues: Because the coroutine has paused and returned execution back to the loop's caller frame, the event loop is now free to poll the OS for network I/O selectors, execute scheduled timers, or step another ready task forward.
Resuming the Coroutine
Once the background operation finishes (such as the operating system notifying Python that socket data has arrived, or a timer expiring):
- The low-level callback fires and marks the
Futureas completed with a result or exception. - The event loop moves the associated task back into its "ready" queue.
- On a subsequent iteration of the loop, the task is driven forward
again using
coroutine.send(result). - The coroutine receives the result directly at the point of the
original
awaitexpression and continues execution until it completes or hits anotherawait.