Beginners Guide to Asynchronous Programming in Python

Bibek Joshi
Practical Data Science and Engineering
5 min readAug 15, 2022

By Bibek Joshi

Application in the real world is dependent on the IO-heavy operations like accessing Database, Event Queues, Cache, etc. Due to this most of the application time is wasted just waiting for the response from these systems. This causes the system to be in the ideal state most of the time and is the topmost reason for the performance issues.

There are multiple ways to tackle this problem in computer science. The most common of them are as follows

  1. Multi-Processing
  2. Multi-Threading
  3. Asynchronous Programming

In this blog, we will only be focusing on Asynchronous programming.

Building blocks of Async Programming

Async Programming uses different constructs to achieve concurrency in the application.

  1. Event loop
  2. Coroutine
  3. Tasks
  4. Future

In python, we mostly use the asyncio module for performing the async programming. We can also use other libraries such as AnyIO to achieve the same. We will be focusing on the asyncio module only.

Event loop

The event loop is the core of every asyncio application. This is one of the most common design patterns that has existed for a long time. Creating the basic event loop is simple. Every event loop has a queue that is used to store its events and messages. The event loop loops over that queue and processes the messages as required.

Simple Event Loop

In a real implementation, the event loop is a little more complex than the above example. The job of the event loop is to run any tasks that are not blocked by I/O or are not paused. Once they are blocked the event loop needs to create the watcher for this event and start processing the next task till it is notified if the I/O is completed or the task is unpaused. If you want to learn more about how these notifications and polling work at the OS level you can search for select, poll, or epoll in Linux.

Event loop working

There are multiple ways to run the asyncio event loop in python.

Running event loop till coroutine is complete

In the above example, the event loop is created and it runs till it completes the execution of the first_async coroutine

You can also run the coroutine forever without it being dependent on any of the coroutines.

Running event loop forever

I have used coroutine and task to show how to run the event loop. I will explain these concepts later in the blog.

These two are the most common way to run the event loop in python. You can explore more in Python Documentation for the event loop.

Coroutine

A coroutine is a special function in python which are like python generators, containing an async keyword, which on await can pause its execution and return the control to the event loop. Once the long-running operation of the coroutine is completed, the event loop will wake up the coroutine.

To create and pause the coroutine we will only use two python keywords async and await.

Coroutine Creation

You must be still wondering how the above program runs concurrently. In the above example, it doesn’t. The idea of the above example is to make sure you understand how to write the async code in python.

We waited for hello() to finish first then only we started the execution of the world()

We will look at the tasks next to see how we run the multiple coroutines at the same time.

Tasks

Tasks schedule the coroutines to be run on the event loop as soon as possible. They act as a wrapper for the coroutines.

In the previous example, we couldn't run the hello() and the world() at the same time. We waited for hello() to complete then executed the world() coroutine.

Using the tasks we can run both hello() and the world() concurrently. And wait for the results in the end.

To create the task in python we use asyncio.create_task. Tasks are also available. To fetch the return of the coroutine we use await on the task.

Simple Task Creation

Here you will observe in the output that the world() coroutine is completed first then hello(). hello() and world () both ran concurrently in this case. world () was not blocked due to the long-running operation of hello().

We needed the return value of this coroutine so we awaited the tasks. Let’s look at a little more advanced use case.

Here the process coroutine will process the given argument for different selected delayed choices. An executor is trying to mimic the continuous stream of data that the application is receiving and it needs to process the data concurrently

Background task example

As you can see the tasks were being processed concurrently due to which processing the tasks that were submitted later was completed first.

In the above output Task 5 (Event though it was submitted last), was completed first then Task 2, 3, and 4 because the tasks were processed concurrently.

We also didn’t need the return value and the loop was running forever of the task so we didn’t await the task.

But if you are expecting the loop to be closed or you have used a loop.run_until_complete() or you need to wait for some task to finish make sure the tasks are awaited before the loop is closed.

One of the things that I encounter often during the code review is the blocking code (Code that is not compatible with asyncio is used). This causes the event loop to be blocked. Even if you create the tasks they will be executed sequentially.

Let me replace asyncio.sleep with time.sleep to mimic non-async compatible code.

Blocking task example

You can see that task 2 was only submitted to the event loop when task 1 completes. So just by putting async and await or creating the tasks, your code won’t be automatically fast.

There are multiple ways to execute the blocking code in asyncio. We will discuss that strategy in other blogs.

You can perform a lot of things with tasks. You can cancel the tasks, shield the task from canceling, set the timeout for the tasks, delay the tasks to be run later, etc.

Futures

A Future is a special low-level available object that represents an eventual result of an asynchronous operation. It is the python object that contains a single value that you expect to get at some point in the future but may not yet have.

Futures can be awaited multiple times. It has a flag called done, if set to true it means that it either contains a result or an exception. If the flag is false then you can call the method that can set the result or an exception.

Tasks internally are built using futures.

Normally there is no need to create Future objects at the application level code. Future objects, sometimes exposed by libraries and some asyncio APIs, can be awaited. We will dive deep into futures in some other blog.

Summary

--

--

Bibek Joshi
Practical Data Science and Engineering

Technical Lead Enginner, working in sweet spot of Distributed System, Data Enginnerng and Cloud native technologies.