Python AsyncIO Event Loop
Introduction
Python asyncio is a library for efficient single-thread concurrent applications. Ever since I started to use it, unlike Python multiprocessing and 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 asyncssh and 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 asyncio. Every coroutine, Future, or 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.
Event Loop
Although asyncio widely uses coroutine, Future, or 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.
1 | def run_forever(self): |
Without going into the details, our gut feeling tells us that the key function call in the run_forever is the self._run_once(). The self._run_once function is described as follows.
1 | def _run_once(self): |
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 for/while loop in the iteration and the end of iteration could be determined by measuring the UNIX time at the end of each for/while execution. But this raises a problem. What if there is a callback that takes very long time to run in the for/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 self._ready.
1 | sched_count = len(self._scheduled) |
Only the callbacks in the self._ready will be executed in order.
1 | # 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.
1 | 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 Future and 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 callback _run_until_complete_cb added to the event loop so that the self.run_forever() will not actually run forever.
1 | def ensure_future(coro_or_future, *, loop=None): |
The loop.create_task is a public interface and the documentation could be found from the Python website.
1 | def create_task(self, coro, *, name=None): |
Conclusions
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