Intro to Asynchronous Programming in Python with Async IO

A Python tutorial on asynchronous programming with Async IO to run tasks concurrently
async_programming

Writing sequential (or synchronous) code is familiar to many programmers especially when they are getting started. It's the kind of code that is executed one line at a time, one instruction at a time.

In the asynchronous world, the occurrence of events is independent of the main program flow. This means that actions are executed in the background, without waiting for the completion of the previous action.

In other words, the lines of code are executed concurrently.

Imagine you have certain independent tasks and each one takes a lot of computation time to finish. Their outputs are not dependent on each other. So you want to start them all at once. If these tasks are executed sequentially, the program will have to wait for each task to finish before starting the next one. This waiting time is blocking the program.

You want to use the asynchronous programming paradigm to execute these tasks concurrently and beat that waiting time and use the resources more efficiently.

Python 3 has a native support for async programming which is asyncio that provides a simple way to execute concurrent tasks.

Let's first set up our environment and get started.

Setting up the environment

In this tutorial, we will use asyncio module in Python 3.7 and above so we need to create a new Python 3.7 environment. A clean Python way is to set up a virtual environment with conda and then activate it with the following commands:

$ conda create -n py3.7 python=3.7
$ conda activate py3.7

Asynchronous programming building blocks

There are 3 main building blocks of Python async programming:

  1. The main task is the event loop which is responsible for managing the asynchronous tasks and distribute them for execution.
  2. Coroutines are functions that schedule the execution of the events.
  3. Futures are the result of the execution of the coroutine. This result may be an exception.

Introducing async in Python

Two main components are introduced in Python:

  1. asyncio which is a Python package that allows an API to run and manage coroutines.
  2. async/await to help you define coroutines.

The functionality and behavior of code is different when you choose async or sync to design your code.

To make it clear, to make HTTP calls, consider using aiohttp which is a Python package that allows you to make HTTP calls asynchronously. You'll need it especially when you're blocked because of the requests library.

Similarly, if you're working with the Mongo driver, instead of relying on the synchronous drivers like mongo-python, you have to use an async driver like moto to access MongoDB asynchronously.

In the asynchronous world, everything runs in an event loop. This allows you to run several coroutines at once. We'll see what a coroutine is in this tutorial.

Everything inside async def is asynchronous code, and everything else is synchronous.

Writing async code is not as easy as writing sync code. The Python async model is based on concepts such as events, callbacks, transports, protocols, and futures.

Things go fast in the async world for Python so keep an eye on the latest updates.

How asyncio works

The asyncio package provides two keys, async and await.

Let's look at this async hello-world example:

import asyncio


async def hello():
    print('Hello world!')
    await asyncio.sleep(1)
    print('Hello again!')

asyncio.run(hello())

# Hello world!
# Hello again!

At first glance, you might think that this is a synchronous code because the second print is waiting 1 second to print "Hello again!" after "Hello world!". But this code is actually asynchronous.

Coroutines

Any function defined as a async def is a coroutine like hello() above. Note that calling the hello() function is not the same as wrapping it inside asyncio.run() function.

To run the coroutine, asyncio provides three main mechanisms:

  • asyncio.run() function which is the main entry point to the async world that starts the event loop and runs the coroutine.
  • await to await the result of the coroutine and passes the control to the event loop.
import asyncio
import time


async def say_something(delay, words):
    print(f"Before {words}")
    await asyncio.sleep(delay)
    print(f"After {words}")


async def main():
    print(f"Started: {time.strftime('%X')}")
    await say_something(1, "Task 1")
    await say_something(2, "Task 2")
    print(f"Finished: {time.strftime('%X')}")

asyncio.run(main())

# Started: 15:59:52
# Before Task 1
# After Task 1
# Before Task 2
# After Task 2
# Finished: 15:59:55

The previous snippet still waits for the say_something() coroutine to finish so it executes task 1 in 1 second, and then executes the second task after waiting for 2 seconds.

To make the coroutine run concurrently, we should create tasks which is the third mechanism.

  • asyncio.create_task() function which is used to schedule the coroutine for execution.
import asyncio
import time


async def say_something(delay, words):
    print(f"Before {words}")
    await asyncio.sleep(delay)
    print(f"After {words}")


async def main():
    print(f"Started: {time.strftime('%X')}")
    task1 = asyncio.create_task(say_something(1, "Task 1"))
    task2 = asyncio.create_task(say_something(2, "Task 2"))
    await task1
    await task2
    print(f"Finished: {time.strftime('%X')}")

asyncio.run(main())

# Started: 16:07:35
# Before Task 1
# Before Task 2
# After Task 1
# After Task 2
# Finished: 16:07:37

The above code is now running concurrently and the say_something() coroutine is no longer waiting for the say_something() coroutine to finish. It's rather running the same coroutine with different parameters concurrently.

What happens is the following:

  • The say_something() coroutine starts with the parameter's first task (1 second and a string "Task 1"). This task is called task1.
  • It then suspends the execution of the coroutine and waits 1 second for the say_something() coroutine to finish as it encounters the await keyword. It returns the control to the event loop.
  • Similarly for the second task, it suspends the execution of the coroutine and waits 2 seconds for the say_something() coroutine to finish as it encounters the await keyword.
  • After the task1 control returns to the event loop, the event loop resumes the second task (task2) because asyncio.sleep has not finished yet.

The asyncio.create_task() wraps the say_something() function and makes it run the coroutine concurrently as an asynchronous task. As you can see, the above snippet shows that it runs 1 second faster than before.

The coroutine is automatically scheduled to run in the event loop when asyncio.create_task() is called.

Tasks help you to run multiple coroutines concurrently, but this is not the only way to achieve concurrency.

Running concurrent tasks with asyncio.gather()

Another way to run multiple coroutines concurrently is to use the asyncio.gather() function. This function takes coroutines as arguments and runs them concurrently.

import asyncio
import time


async def greetings():
    print("Welcome")
    await asyncio.sleep(1)
    print("Goodbye")


async def main():
    start = time.time()
    await asyncio.gather(greetings(), greetings())
    elapsed = time.time() - start
    print(f"{__name__} executed in {elapsed:0.2f} seconds.")

asyncio.run(main())

# Welcome
# Welcome
# Goodbye
# Goodbye
# __main__ executed in 1.00 seconds.

In the previous code, the greetings() coroutine is executed twice concurrently.

Awaitable objects

An object is called awaitable if it can be used with the await keyword. There are 3 main types of awaitable objects: coroutines, tasks, and futures.

Coroutines

import asyncio


async def mult(first, second):
    print("Calculating multiplication...")
    await asyncio.sleep(1)
    mul = first * second
    print(f"{first} multiplied by {second} is {mul}")
    return mul


async def add(first, second):
    print("Calculating sum...")
    await asyncio.sleep(1)
    sum = first + second
    print(f"Sum of {first} and {second} is {sum}")
    return sum


async def main(first, second):
    await mult(first, second)
    await add(first, second)

asyncio.run(main(10, 20))

# Calculating multiplication...
# 10 multiplied by 20 is 200
# Calculating sum...
# Sum of 10 and 20 is 30

In the previous example, the mult() and add() coroutine functions are awaited by the main() coroutine.

Let's say you omit the await keyword before the mult coroutine. You'll then get the following error: RuntimeWarning: coroutine 'mult' was never awaited.

Tasks

To schedule a coroutine to run in the event loop, we use the asyncio.create_task() function.

import asyncio


async def mult(first, second):
    print("Calculating multiplication...")
    await asyncio.sleep(1)
    mul = first * second
    print(f"{first} multiplied by {second} is {mul}")
    return mul


async def add(first, second):
    print("Calculating sum...")
    await asyncio.sleep(1)
    sum = first + second
    print(f"Sum of {first} and {second} is {sum}")
    return sum


async def main(first, second):
    mult_task = asyncio.create_task(mult(first, second))
    add_task = asyncio.create_task(add(first, second))
    await mult_task
    await add_task

asyncio.run(main(10, 20))

# Calculating multiplication...
# Calculating sum...
# 10 multiplied by 20 is 200
# Sum of 10 and 20 is 30

Futures

A Future is a low-level awaitable object that represents the result of an asynchronous computation. It is created by calling the asyncio.Future() function.

from asyncio import Future

future = Future()
print(future.done())
print(future.cancelled())
future.cancel()
print(future.done())
print(future.cancelled())

# False
# False
# True
# True

Timeouts

Use asyncio.wait_for(aw, timeout, *) to set a timeout for an awaitable object to complete. Note that aw here is the awaitable object. This is useful if you want to raise an exception if the awaitable object takes too long to complete. The exception as asyncio.TimeoutError.

import asyncio


async def slow_operation():
    await asyncio.sleep(400)
    print("Completed.")


async def main():
    try:
        await asyncio.wait_for(slow_operation(), timeout=1.0)
    except asyncio.TimeoutError:
        print("Timed out!")

asyncio.run(main())

# Timed out!

The timeout here in the Future object is set to 1 second although the slow_operation() coroutine is taking 400 seconds to complete.

Final thoughts

In this tutorial, we introduced asynchronous programming in Python with Async IO built-in module. We defined what coroutines, tasks, and futures are.

We also covered how to run multiple coroutines concurrently with different ways and saw how a concurrent code might be your best option when you need to optimize performance for certain tasks.

Originally published on Andela


Buy me a cup of coffee

Join the conversation

Download the ebook

Download the eBook to write cleaner Python code

Get the ebook