Understanding Event Loops in asyncio

But before we get started, what an event loop is and why it matters.

An event loop is essentially a fancy way of saying “a thing that manages events.” In the context of asyncio, this means that our program will have one or more tasks (also known as coroutines) running simultaneously without blocking each other. This allows us to write code that can handle multiple inputs and outputs at once, which is especially useful for things like web servers, network programming, and I/O-bound operations.

Now let’s take a look at how asyncio works under the hood. When we run our program using `asyncio.run()`, it creates an event loop object that manages all of the tasks in our program. This is where things get interesting instead of running each task sequentially, like you might expect from traditional synchronous programming, asyncio uses a technique called cooperative multitasking to allow multiple tasks to run simultaneously without blocking each other.

This is achieved through the use of coroutines and yield statements (which are essentially shorthand for “yield control back to the event loop”). When we call `async def my_task():` at the beginning of our function, Python creates a new coroutine object that can be scheduled by the event loop. Whenever this coroutine is ready to run again, it yields control back to the event loop using the `yield from` statement (which essentially says “hey, I’m done for now let someone else have a turn”).

So how does asyncio actually manage all of these tasks and make sure they don’t block each other? Well, that’s where the magic happens. When we call `asyncio.run()`, it creates an event loop object (which is essentially just a fancy data structure) that keeps track of all of our coroutines and their current state. Whenever one of these coroutines yields control back to the event loop using `yield from` or another yield statement, the event loop adds it to its queue of tasks waiting to be run.

The event loop then selects the next task in this queue (based on a variety of factors like priority and deadline) and runs it until that task yields control back again. This process continues iteratively until all of our coroutines have finished running, at which point `asyncio.run()` returns and we’re done!

Now let’s take a look at some examples to help illustrate how this works in practice. To kick things off, let’s create a simple “Hello World” program using asyncio:

# Import the asyncio module to use its functions
import asyncio

# Define a coroutine function called hello_world
def hello_world():
    # Print "Hello world!" to the console
    print("Hello world!")
    
    # Yield control back to the event loop
    # (this is where we wait for another task to run)
    yield from asyncio.sleep(1)
    
    # When this coroutine finishes running, it will be added back to the queue of tasks waiting to be run
    print("Goodbye world!")
        
# Create an event loop object
loop = asyncio.get_event_loop()

# Use "loop.run_until_complete()" to schedule our hello_world() function and run the event loop until all tasks have finished running
if __name__ == '__main__':
    try:
        loop.run_until_complete(hello_world())
        
    finally:
        # Close the event loop object once we're done using it
        loop.close()

# The event loop runs the coroutine function "hello_world()" and prints "Hello world!" to the console
# The "yield from" statement allows the event loop to switch to another task while waiting for the "asyncio.sleep()" function to finish
# Once the sleep function finishes, the event loop switches back to the "hello_world()" function and prints "Goodbye world!" before adding it back to the queue of tasks waiting to be run
# The "loop.run_until_complete()" function ensures that all tasks have finished running before the event loop ends
# The "loop.close()" function closes the event loop object once we're done using it

In this example, our `hello_world()` function prints “Hello world!” and then yields control back to the event loop using `yield from asyncio.sleep(1)`. This allows another task (if there is one waiting in the queue) to run before we continue with our own coroutine.

When all of our tasks have finished running, `asyncio.run()` returns and we’re done! But what if we want to schedule multiple tasks at once? Well, that’s where things get really interesting asyncio allows us to create a “task graph” using the `asyncio.gather()` function:

import asyncio
import time

# define our first task as a coroutine
async def task1():
    print("Task 1 started...")
    
    # yield control back to the event loop (this is where we wait for another task to run)
    await asyncio.sleep(2) # use "await" instead of "yield from" to wait for the task to finish
    
    print("Task 1 finished!")
        
# define our second task as a coroutine
async def task2():
    print("Task 2 started...")
    
    # yield control back to the event loop (this is where we wait for another task to run)
    await asyncio.sleep(3) # use "await" instead of "yield from" to wait for the task to finish
    
    print("Task 2 finished!")
        
# create an event loop object and schedule our tasks using "loop.run_until_complete(asyncio.gather(*tasks))"
if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    try:
        # run the event loop until all tasks have finished running (i.e., when there are no more tasks waiting to be run)
        loop.run_until_complete(asyncio.gather(task1(), task2())) # use "asyncio.gather()" to schedule multiple tasks at once
        
    finally:
        # close the event loop object once we're done using it
        loop.close()

In this example, our `task1()` and `task2()` functions both print “Task X started…” and then yield control back to the event loop using `yield from asyncio.sleep(X)`. This allows us to create a task graph where each task runs simultaneously (without blocking each other), and we can see which tasks finish first by looking at their output:


# Import the asyncio library to use its functions
import asyncio

# Define a function called task that takes in a parameter x
def task(x):
    # Print a string indicating that the task has started
    print("Task {} started...".format(x))
    # Use the yield from function to yield control back to the event loop
    yield from asyncio.sleep(x)
    # Print a string indicating that the task has finished
    print("Task {} finished!".format(x))

# Create a list of tasks, each with a different value for x
tasks = [task(1), task(2)]

# Use the asyncio library's run function to run the tasks simultaneously
asyncio.run(asyncio.wait(tasks))

# Output:
# Task 1 started...
# Task 2 started...
# Task 1 finished!
# Task 2 finished!

# Explanation:
# The script starts by importing the asyncio library, which allows us to use its functions for asynchronous programming.
# Next, a function called task is defined, which takes in a parameter x. This function prints a string indicating that the task has started, and then uses the yield from function to yield control back to the event loop. This allows the task to run simultaneously with other tasks without blocking them.
# The tasks list is then created, with two tasks using different values for x.
# Finally, the asyncio library's run function is used to run the tasks simultaneously, and the output shows that both tasks have started and finished in the order they were created.

While this is just the tip of the iceberg, I hope that this article has helped demystify some of the more complex concepts involved with asyncio and given you a better understanding of how it works under the hood.

SICORPS