Python AsyncIO Event Loop
asyncio is a library for efficient single-thread concurrent applications. Ever since I started to use it, unlike Python
threading, it has been like a mysterious black box to me. Although I could still use
asyncio for some simple high-level concurrent applications by taking advantage of the open source
asyncio libraries, such as
httpx, I have no idea how those
asyncio libraries were implemented from scratch. To understand the
asyncio mechanism, it might be necessary to look at its low level implementation details.
Event loop is the core of Python
Task would be scheduled as
callback and be executed by an event loop. In this blog post, I would like to look into Python event loop at the low-level implementation superficially.
asyncio widely uses
Task, it is not necessary to use them in order to run tasks on an event loop. Event loop ultimately runs scheduled
callbacks. To see this, let’s check the implementation of
loop.run_forever from Python 3.8.
Without going into the details, our gut feeling tells us that the key function call in the
run_forever is the
self._run_once function is described as follows.
This information is somewhat reflected in the loop.run_forever documentation. Event loop must have loops and run iteration by iteration, otherwise its name would not have been event loop. But what exactly is an iteration of the event loop? Before checking the actual implementation, I imagined an iteration of even loop is a fixed finite length of time frame where
callbacks could be executed. We could use a
while loop in the iteration and the end of iteration could be determined by measuring the UNIX time at the end of each
while execution. But this raises a problem. What if there is a
callback that takes very long time to run in the
while loop and keeps blocking the thread, then the fixed length of the time frame could not be guaranteed. It turns out that the design of an actual event loop iteration in Python is somewhat similar but more delicate.
All the scheduled
callbacks for the current event loop iteration are placed in
self._ready. By looking at the implementation superficially, it seems that we have a (heap/priority) queue of scheduled
callbacks, some of which might have been delayed and canceled. Although the
loop.run_forever runs forever, it does have timeout for each event loop iteration. For the “call later”
callbacks that are scheduled to run after the current UNIX time, they are not ready so they will not be put into the
sched_count = len(self._scheduled)
callbacks in the
self._ready will be executed in order.
# This is the only place where callbacks are actually *called*.
This means that in an event loop iteration, the number of
callbacks being executed is dynamically determined. It does not have fixed time frame, it does not have an fixed number of
callbacks to run. Everything is dynamically scheduled and thus is very flexible.
Notice that this
self._run_once is only called in the
loop.run_forever method, but not others. Let’s further check the more commonly used method loop.run_until_complete which is being called by
asyncio.run under the hood.
def run_until_complete(self, future):
The most prominent function call is
self.run_forever() surprisingly. But where are the
Future scheduled as
callbacks in the event loop.
tasks.ensure_future which takes both
loop as inputs scheduled the
callbacks. In the
tasks.ensure_future, it calls
loop.create_task(coro_or_future) to set the
callback schedules in the event loop. Also note that there is additional
_run_until_complete_cb added to the event loop so that the
self.run_forever() will not actually run forever.
def ensure_future(coro_or_future, *, loop=None):
loop.create_task is a public interface and the documentation could be found from the Python website.
def create_task(self, coro, *, name=None):
Although we did not go through all the code about the event loop, we have become more knowledgeable about how a Python event loop executes call backs.
Python AsyncIO Event Loop