Hands-On Reactive Programming with Python
上QQ阅读APP看书,第一时间看更新

Event loop

The event loop is the entity of AsyncIO and is in charge of scheduling all asynchronous actions that must executes concurrently. Fundamentally, it is just an infinite loop that waits for some events to happen and execute handlers associated with these events. In the case of AsyncIO, these handlers are coroutines.

An event loop is automatically created by asyncio when a process is started. A reference to this event loop can be retrieved with the get_event_loop function of the asyncio module. Event loops inherit from the BaseEventLoop abstract class.

This class contains several methods used to execute asynchronous code. One of them is the run_until_complete method. With these two methods, the wait coroutine of the previous part can now be executed:

loop = asyncio.get_event_loop()
loop.run_until_complete(wait(2))
loop.close()

This code is available in the event_loop.py script. When executed, it shows the following output:

wait for 2 seconds at 23:8:22
waited for 2 seconds at 23:8:24

The second print statement confirms that the execution lasted for 2 seconds, which is the expected behavior. The important point in this program is the fact that during these 2 seconds, other actions could run; the process was not blocked while sleeping. This is easy to test with only one change to this program. Instead of executing the wait coroutine once, let's run it twice at the same time:

loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.gather(
wait(2),
wait(1)))
loop.close()

The code is almost the same, but instead of providing a coroutine to the run_until_complete method, the result of the gather method is provided. The gather method returns a future that aggregates the result of the futures or coroutines being passed as arguments.

The coroutines are not used directly by the run_until_complete method. Several steps are needed before the coroutine can execute some code. This was already the case in the previous example. Let's now see what happens when run_until_complete is called. First, a task is created and the coroutine is associated with it. The task is an entity that tracks the current coroutine being executed and resumes it when needed. The future is created and added to the event loop.

A future allows the event loop to know when an action has completed, but it also allows it to cancel an ongoing action. The following figure shows all these steps:

Figure 2.7: Execution of two concurrent coroutines in asyncio

The two first steps consist of creating the coroutine objects. Then the gather function creates one task per coroutine that must be executed and then wraps them in a single future. This future will complete once both tasks have completed. The future is returned to the application, and the application provides this future as a parameter to the run_until_complete method. From that point, the code of the coroutine starts being executed. First, the wait(2) coroutine calls an asynchronous sleep. This suspends the execution of the coroutine and its tasks. This allows the event loop to start executing the wait(1) coroutine, which also calls the asynchronous sleep, and so is suspended.

At that point, the event loop is suspended because there is no active task to execute. Should another task be active at that time, it could execute some code. About 1 second after that, the first sleep timer fires. This wakes up the wait(1) coroutine, which can complete. At that point, the event loop is suspended again. After another second, the second sleep timer fires. This wakes up the wait(2) coroutine that can complete its execution. Since the two tasks have now completed, the future also completes. This triggers the stopping of the event loop.

Take some time to fully understand this sequence diagram. These are all the steps that must be perfectly understood to write asyncio code.

It is also important to note two things in this code. First, even thought the wait(2) coroutine starts its execution first, the wait(1) coroutine completes before it. This happens in a single-process, single-threaded program. Such interleaving is not possible with blocking sleep calls. It would require two distinct threads.

Second, during all that time, the call to run_until_complete blocks its caller. This call is usually the end of an asyncio program, with only some cleanup after that. When the run_until_complete function returns, the event loop is stopped. It can then be closed to free up its internal resources. It is possible to restart an event loop that is stopped, but it is not possible to restart an event loop that is closed. So, this sequence is possible:

loop.run_until_complete(wait(2))
loop.run_until_complete(wait(1))

This one raises an error:

loop.run_until_complete(wait(2))
loop.close()
loop.run_until_complete(wait(1))

So now that the execution of this program is clear, you should be able to guess what it prints on the console:

wait for 1 seconds at 23:33:31
wait for 2 seconds at 23:33:31
waited for 1 seconds at 23:33:32
waited for 2 seconds at 23:33:33

Surprise! The wait(1) coroutine effectively lasted for 1 second and the wait(2) coroutine for two seconds. However, the wait(1) coroutine started its execution before the wait(2) coroutine, even though they were provided the other way to the gather call. This is normal behavior. The order of execution of the coroutines provided to gather is not guaranteed. This should not be an issue for any asynchronous code, but keep it in mind.