Asynchronous programming in Python with asyncio

Last updated on 23rd January 2023

This article demonstrates asynchronous programming in Python using an example of making breakfast. Suppose we have the following instructions to make a breakfast.

  1. Pour coffee.
  2. Heat a pan and fry two eggs.
  3. Toast 2 slices of bread.
  4. Add butter to toast.

There are two ways you can execute these instructions - in the first method, you do each these steps sequentially that is you pour a cup of coffee, then heat the pan and fry the eggs. You wait for the eggs to be ready and then you toast the bread and finally add butter to toast.

Now, if you have cooking experience you will execute some of the steps in this instruction concurrently. So you start by pouring a cup of coffee, then you heat a pan and while the pan is heating you put a slice of bread in the toaster. Then you crack two eggs in the pan. So now you have the eggs and toast cooking at the same time. When the toast is ready you take that out and put another bread in the toaster. You then apply butter on the first toasted bread and move on to take the fried eggs. When the second slice of bread is toasted you apply butter on it and breakfast is ready. So basically you switch your attention between toasting bread and frying eggs thereby reducing the overall time required to prepare the breakfast.

Compared to the first method, it takes less time to cook your breakfast this way. In the second method, multiple tasks are carried out at the same time and you switch between tasks whenever your current task doesn't need your attention. This is the concept of asynchronous programming.

Asynchronous programming in Python can be realised using the Python package asyncio which is used as a foundation for many asynchronous and IO-bound frameworks. asyncio provides API for running and controlling the execution of Python coroutines.

What is a coroutine?

A coroutine is a subroutine that can pause its execution to give control to another coroutine and can resume execution again when that second coroutine gives the control back. Coroutines are used for cooperative multitasking where a process volutarily relinquish control when idle or blocked by a long-running task to allow another process to run simulataneously. Python coroutines are implemented with the async def keywords. Here is an example of a coroutine named MorningRoutine.

import asyncio

async def MorningRoutine():
    print("Brush teeth")  
    await asyncio.sleep(1)
    print("Eat breakfast")
    await asyncio.sleep(2)
    print("Go to work")

asyncio.run(MorningRoutine())

In the above code, the first line imports the asyncio module. Then you have the coroutine defined with the async def keywords. The coroutine contains few print statements and couple of await asyncio.sleep() statements.

The purpose of await asyncio.sleep() function is to pause the execution for the specified number of seconds. This is just to simulate a long running process. Now you might think why not use the time.sleep() function call instead of asyncio.sleep(). The reason is time.sleep() is a blocking function, which means entire thread is blocked or suspended for the specified time while asyncio.sleep() is non-blocking which allows other coroutines run while it sleeps.

The asynchio.run() statement runs the coroutine that is passed and manages creates and manages the asyncio event loop. The asyncio.run() is the main entry point for the asynchronous operation and this function is normally called only once in Python asynchronous program.

Asynchronous program for the breakfast example

Now that you are familiar with the basics of asynchronous programming in Python, lets look write a program to asynchronously cook breakfast using the second method discussed at the beginning of this article.

Let's start by creating empty classes for each of the breakfast item.

class Coffee:
   pass
class Egg:
   pass
class Toast:
   pass

Next, we define the tasks. The first task is to pour coffee. This is not an asycnhronous task, in other words we cannot do anything else while pouring coffee. So let's define this task as a normal function. All this function does is to print the message Pouring coffee

def PourCoffee():
   print("Pouring coffee")
   return Coffee()
 

The second step is to fry two eggs, which can be done concurrently while toasting the bread. Therefore this task can be defined as a coroutine.

async def FryEggsAsync(howMany):
    print("Heat pan to fry eggs")
    await asyncio.sleep(3)
    print("Frying",howMany,"eggs")
    await asyncio.sleep(3)
    print("Eggs are ready")
    return Egg()
 

The final two steps in the instruction is to toast each slice of the bread and apply butter on toast. These two steps are related but applying the butter can be done only after the bread is toasted. Therefore these two steps have to be performed sequentially but can be done in parallel with frying eggs.

async def ApplyButter():
    print("Spreading butter on toast")
    await asyncio.sleep(1)
    
async def ToastAsync(slices):
    for slice in range(slices):
      print("Toasting bread", slice + 1)
      await asyncio.sleep(3)
      print("Bread", slice + 1, "toasted")
      await ApplyButter()
      print ("Toast", slice + 1, "ready")
    return Toast()

The two async tasks, i.e, FryEggsAsync() and ToastAsync() can be run concurrently using the function asyncio.gather().

 await asyncio.gather(FryEggsAsync(2),ToastAsync(2))

Putting all these pieces of code together, we have the full program as below.

import asyncio

class Coffee:
    pass
class Egg:
    pass
class Toast:
    pass

def PourCoffee():
    print("Pouring coffee")
    return Coffee()

async def ApplyButter():
    print("Spreading butter on toast")
    await asyncio.sleep(1)
    return
  
async def FryEggsAsync(howMany):
    print("Heat pan to fry eggs")
    await asyncio.sleep(3)
    print("Pan is ready")
    print("Frying",howMany,"eggs")
    await asyncio.sleep(3)
    print("Eggs are ready")
    return Egg()

async def ToastAsync(slices):
    for slice in range(slices):
      print("Toasting bread", slice + 1)
      await asyncio.sleep(3)
      print("Bread", slice + 1, "toasted")
      await ApplyButter()
      print ("Toast", slice + 1, "ready")
    return Toast()

async def main():
    cup = PourCoffee()
    print("Coffee is ready")
    await asyncio.gather(FryEggsAsync(2),ToastAsync(2))
    
if __name__ == "__main__":
    import time
    s = time.perf_counter()
    asyncio.run(main())
    elapsed = time.perf_counter() - s
    print("Breakfast cooked in",elapsed,"seconds.")
 

Output from the above program will be like

Pouring coffee
Coffee is ready
Heat pan to fry eggs
Toasting bread 1
Pan is ready
Frying 2 eggs
Bread 1 toasted
Spreading butter on toast
Toast 1 ready
Toasting bread 2
Eggs are ready
Bread 2 toasted
Spreading butter on toast
Toast 2 ready
Breakfast cooked in 8.008303630980663 seconds.

Post a comment

Comments

Nothing yet..be the first to share wisdom.