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:
- The main task is the event loop which is responsible for managing the asynchronous tasks and distribute them for execution.
- Coroutines are functions that schedule the execution of the events.
- 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:
asyncio
which is a Python package that allows an API to run and manage coroutines.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 calledtask1
. - It then suspends the execution of the coroutine and waits 1 second
for the
say_something()
coroutine to finish as it encounters theawait
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 theawait
keyword. - After the
task1
control returns to the event loop, the event loop resumes the second task (task2
) becauseasyncio.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