A minimalistic guide for understanding asyncio in Python

Naren Yellavula
Dev bits
Published in
9 min readJan 7, 2022

--

There are already tons of articles and courses about Asyncio. Why “yet another article”? This article aims to explain concepts of asynchronous programming in Python in a straightforward way. This article explores Python asyncio API with simple examples to quickly get a developer to speed.

I used asyncio in my projects and liked how it enables concurrency with little code. I hope you will feel the same.

Before you go, are you still using Google Docs to store your favourite ChatGPT prompts ? Then, checkout this cool prompt manager called Vidura for free. You can generate text with GPT-3 and images with Stable Diffusion in one place:

What is asyncio?

Python’s asyncio is a co-routine-based concurrency model that provides elegant constructs to write concurrent python code without using threads. The mindset of designing concurrent solutions is different from traditional thread-based approaches. We know that threading is mainly used for I/O bound processing, whereas multiprocessing is advantageous for CPU-bound tasks in Python.

All the Es6, Node.js programs execute in an event loop if you ever programmed in JavaScript. Python brings the same functionality by allowing developers to create custom loops and functions to be scheduled on those loops.

Python asyncio standard library package allows developers to use async/await syntax while developing concurrent programs.

asyncio is often a perfect fit for IO-bound and high-level structured network code

Common use-cases for I/O bound processing:

  1. Metric-collection systems
  2. Chat rooms
  3. Real-time online games
  4. Streaming in Python

Basics of asyncio

Typically, a python program runs functions to process input values. Similarly, in asyncio, one needs to create unique functions called co-routines. What makes a normal function co-routine is the async keyword. Let’s see a simple add function that sums two integer numbers and returns the result. We use Python 3.9 interpreter for our examples throughout this article.

What happens when you make a function special by adding the async keyword? Now event loop can execute this particular function as a co-routine.

In the code snippet, we create a new event loop by calling `asyncio.get_event_loop method`. It returns a new loop that can execute co-routines. The method run_until_complete means three things implicitly:

  1. Start the loop
  2. Execute co-routine passed as an argument
  3. Stop the loop

The run_until_complete method returns the value from co-routine completion. It is a simple example of creating a co-routine and scheduling it on an event loop.

In the next section, we will see how to schedule multiple co-routines.

Schedule multiple co-routines (in different loops)

If we call run_until_complete multiple times, it is equivalent to running the event loop twice in a synchronous fashion. So result1 and result2 in the below program occurred synchronously.

In the program, the event loop starts, then execute the first coroutine and stops. This process is repeated for the second coroutine too. It is similar to a plain synchronous invocation of two functions running one after another.

The process looks like this:

Schedule multiple co-routines (in same loop)

What if we need to schedule multiple co-routines on the same event loop as a batch asynchronously? We can do it like this:

The important lines of code here are from 8 to 25. The algorithm is simple:

  1. Get the event loop
  2. Run event loop forever with loop.run_forever method as Line no: 23. That line blocks until someone call loop.stop method. Once the loop.stop method is called in future, loop.close will stop the event loop (Line no: 25).
  3. Create a task from co-routine get_results and schedule it on the event loop (Line no: 19)
  4. When get_results is executed, it schedules two more co-routines onto the loop using the await keyword. The await statement runs the co-routines immediately and returns the result.
  5. Finally, we stop the event loop after having the results of two co-routines (Line no: 17)

Overall process looks like this:

Using asyncio.run

The previous example looks like a lot of boilerplate code for scheduling two co-routines on an event loop and fetching results. The asyncio library package has a wrapper method called asyncio.run, which exactly does the same as above. We can re-write our program to this:

In this brief version, asyncio creates a new event loop underneath (Line no: 15), uses it to run the co-routine get_results. In this case, we don’t even need to call the stop method exclusively on the event loop and the asyncio.run() takes care of it.

Co-routine vs Task in asyncio

Python asyncio provides two basic constructs for running special functions on an event loop.

  1. Co-routine
  2. Asyncio task

Co-routines are created using async def syntax, as seen in our previous code examples. There are two ways to make an asyncio task:

# 1
loop = asyncio.get_event_loop()
loop.create_task(cor) # cor = co-routine
# 2
import asyncio
asyncio.create_task(cor)

If we control the event loop within a program, then Option #1 makes more sense. In contrast, high-level asyncio.run method suits Option #2.

One can easily confuse between a task object vs. co-routine. A co-routine is similar to a generator object, and you cannot use a generator directly but only with supporting keywords. Similarly, a co-routine must be used with the await keyword. A co-routine can also be wrapped inside a task to get fine-grained control properties like canceling a task, checking ready status, etc. We will see the usage of asyncio-tasks in upcoming examples.

Understanding concurrency via asyncio

The event loop executes the scheduled co-routines synchronously. But it achieves concurrency by skipping the blocking period of a co-routine to do work for the next co-routine, just using a single thread.

Let us see an example of how the above statement works. Assume we have two functions for adding numbers, where one is slow and the other one fast. We can create another co-routine that runs two functions concurrently with different inputs. We can schedule those functions on event-loop as co-routine-based tasks. To simulate a blocking phenomena, we can use asyncio.sleep function in those functions. So, modifying the previous program to add these two functions looks like this:

We added a few print statements to functions to debug the lifecycle. Instead of an await directly on co-routines, we are creating tasks. If we execute this program, we see the following output exactly after 5 seconds.

starting slow add
starting fast add
result ready for fast add
result ready for slow add
7 10

As we mentioned earlier, the event loop executes the co-routines synchronously in the way we create tasks as per Line numbers: 19, 20. But, once the loop sees a blocking wait, it executes the other co-routine. The below timeline chart tries to capture this phenomenon.

For I/O bound operations, this is how asynchronous co-routines can save operation time (improve latency) compared to a sequential program. Instead of eight seconds taken by a sequential program, this concurrent program only needs 5 seconds for both the results to be ready.

Schedule co-routines dynamically (in one go)

Until now, we scheduled co-routines manually in the code. What if we have to create co-routines at run-time dynamically. The most important construct to do that is asyncio.gather method.

Let us say we have ’n’ number of inputs, and we need to process them concurrently and get back results; asyncio.gather is the default way to go.

Problem statement: We have few integer tuples to be added individually and returned as a result once the operation is complete.

This example shows a usage of asyncio.gather method (Line no: 14) to schedule execution of multiple co-routines dynamically. Once the results of all co-routines are collected successfully, await call returns values as results and prints them.

Note: *cors (star syntax, Line no: 14) will unpack a list and pass them as keyword arguments to asyncio.gather. You cannot give a list of co-routines directly to that method.

Analogy: Think of this scenario: Walk into a restaurant and order four items. The waiter then prepares all four items and brings them to the order table. That is how asyncio.gather() method works.

One can also modify the above program to create tasks instead of co-routines, and the rest stays precisely the same.

Schedule co-routines dynamically (as items are ready)

Sometimes, you don’t want a waiter to bring all items at once, and you might want to consume one at a time as they are ready. That can be achieved using asyncio.as_completed method. Instead of collecting all concurrent results in one go, we can now consume the earliest available result from scheduled co-routines.

The asyncio.as_completed method takes a list of co-routines, unlike keyword arguments of asyncio.gather method. The asyncio.as_completed returns iterable co-routines that can be used with the await keyword. You can consume the result right away if you want in a for-loop.

Note: The above program: asyncio_basic_9.py also works with tasks. Maybe try it as an exercise.

Schedule co-routines with a time deadline

Sometimes, one needs to schedule a co-routine but only wait for a certain amount of time and stop execution. That is where asyncio.wait_for method comes in handy.

The asyncio.wait_for method takes a co-routine or task with a timeout and raises an exception if the result is not ready within the timeout period. Let us see an example where we have a co-routine to add numbers that take up to five seconds to execute and prepare the result. If we can’t bear that delay, we can specify our custom threshold as a timeout. We can add a simulated block using asyncio.sleep method, and a random delay using Python’s random package.

As we see from Line No: 16, we are scheduling a co-routine with a deadline of three seconds. If that deadline is exceeded by co-routine while executing, a TimeoutError is raised (this exception is from the asyncio package). By using that, we can make decisions about the result.

Thanks to high-level constructs, asynchronous programming in Python is easier than you think. One should know a few essential asyncio package methods to compose concurrent programs in Python.

Final words

I hope, as a reader, you got a basic understanding of asyncio by now. There are few advanced primitives which allow us to take more control over the execution of co-routines, for Ex: asyncio.wait method lets the developer stop executing a batch of co-routines when there is at least one exception. We will see those advanced use-cases in another post. Also, testing the asynchronous code is another aspect of learning concurrency in Python.

The asyncio package also provides synchronization primitives similar to thread and thread-safe data structures like queues to communicate between co-routines. We will discuss them in deep in upcoming articles.

Please stay safe and have a great new year!

References:

--

--