Ever written a Python script that spends most of its time waiting?
Good news – Python’s asyncio library lets you do other work while you wait, without the complexity of threads or multiprocessing!
In this post, we’ll build up the intuition for how it works from scratch.
The Synchronous Café #
Imagine you’re a barista at a coffee shop.
Let’s define the steps to make coffee for our customers.
We’ll need to start by explaining the menu:
|
|
Calling this function:
>>> explain_menu()
Welcome to the Synchronous Café!
On our coffee menu we have Latte, Cappuccino, Flat White, Mocha.
On our flavours menu we have Vanilla, Chocolate, Pistachio, Caramel.Once we’ve explained the menu, we’ll need to ask the customer what they’d like:
def take_order(flavour: str, coffee: str) -> None:
# simulate asking a customer what they'd like and receiving their response
print(f"What would you like? - A {flavour} {coffee}.")
print(f"One {flavour} {coffee} coming right up!")Once we know what they’d like, we’ll set about making it:
def make_coffee(flavour: str, coffee: str):
print(f"\nMaking your {flavour} {coffee}...")
# simulate making the order
time.sleep(5)Finally, we’ll serve their order:
def serve_order(flavour: str, coffee: str):
print(f"\nYour {flavour} flavoured {coffee} is ready, enjoy!\n")If there are more customers, we’ll serve them too:
if len(customer_orders) > 0:
print("Next customer please!\n")Finally, we’ll put it all together. We’ll add timings to see how long it takes us to serve 2 customers synchronously:
|
|
Saving this to sync_cafe.py and running:
$ python3 sync_cafe.py
Welcome to the Synchronous Café!
On our coffee menu we have Latte, Cappuccino, Flat White, Mocha.
On our flavours menu we have Vanilla, Chocolate, Pistachio, Caramel.
What would you like? - A Vanilla Latte.
One Vanilla Latte coming right up!
Making your Vanilla Latte...
Your Vanilla flavoured Latte is ready, enjoy!
Next customer please!
What would you like? - A Caramel Mocha.
One Caramel Mocha coming right up!
Making your Caramel Mocha...
Your Caramel flavoured Mocha is ready, enjoy!
Serving 2 customers took 10.00 seconds
~10 seconds to serve 2 customers? Not very good.
The problem is our make_coffee function must wait for time.sleep(5) to finish for each order - while the coffee machine is making their order, we do nothing until it’s finished.
Wouldn’t it be nice to serve other customers while the coffee machine is working?
async/await
#
To make code asynchronous in Python, we import the asyncio package, and make use of 2 keywords: async to create co-operative routines (coroutines), and await to pause coroutine execution.
So what does asynchronous actually mean?
A function marked async:
import asyncio
async def foo():
print("Hello from foo")
await asyncio.sleep(5)can pause its execution using await when it encounters something that may take a while to complete, such as sleep(5).
This makes the function non-blocking: the program won’t wait for the long-running operation to complete. Instead, it can do other work, and when the long-running operation has completed, the remaining code for the function will be executed.
To pause execution of a long-running asynchronous operation, you await it, as shown in the foo function above. await is like saying “this function will take a while, so go and do other work until it’s finished”.
The Asynchronous Café #
Let’s modify our code so that rather than staring at the coffee machine waiting for it to finish, we’ll serve other customers while it’s working.
As I said earlier, the issue is our make_coffee function calls time.sleep(5), a synchronous, blocking function which stops the whole program until it completes. This is why we can’t serve any other customers until it finishes.
We’ll start by making the make_coffee function asynchronous:
import asyncio
async def make_coffee(flavour: str, coffee: str) -> None:
print(f"\nMaking your {flavour} {coffee}...")
# simulate making the order
await asyncio.sleep(5)We start by importing the asyncio package, as it has asynchronous utility functions, including an asynchronous version of sleep!
We’ll move our customer-serving logic out of our run_cafe function to a separate function:
async def handle_customer(flavour: str, coffee: str) -> None:
take_order(flavour, coffee)
await make_coffee(flavour, coffee)
serve_order(flavour, coffee)Notice that handle_customer must be marked async since it awaits make_coffee - another asynchronous function.
Next we’ll tweak run_cafe so that it can serve customers asynchronously:
|
|
We’ll explain_menu, then start the timer.
Then we create asynchronous tasks for each handle_customer using asyncio.create_task, and store their return values (sometimes called promises) in a list called tasks.
Next, we kick off all the tasks concurrently with await asyncio.gather(*tasks) and wait for them all to finish.
*tasks is Python shorthand for “each element in the tasks list”.
Putting everything together:
|
|
Another little addition is the asyncio.run at the bottom, which we need to manage the event loop.
Ok, let’s run it and see the results:
$ python3 async_cafe.py
Welcome to the Asynchronous Café!
On our coffee menu we have Latte, Cappuccino, Flat White, Mocha.
On our flavours menu we have Vanilla, Chocolate, Pistachio, Caramel.
What would you like? - A Vanilla Latte.
One Vanilla Latte coming right up!
Making your Vanilla Latte...
What would you like? - A Caramel Mocha.
One Caramel Mocha coming right up!
Making your Caramel Mocha...
Your Vanilla flavoured Latte is ready, enjoy!
Your Caramel flavoured Mocha is ready, enjoy!
Serving 2 customers took 5.01 seconds
Look at that. 5 seconds instead of 10 - half the time!
What’s actually going on here? Let’s take a look at the execution flow:
Wrapping Up #
We’ve taken our humble café from serving customers one at a time to handling them concurrently, halving our serving time in the process.
The key takeaways are:
async defmarks a function as a coroutine meaning it can pause its execution.awaithands control back to the event loop while a slow operation completes.asyncio.create_taskschedules coroutines to run concurrently, andasyncio.gatherwaits for all of them to finish.
Async/await shines whenever your code spends time waiting.
If your code is CPU-bound (crunching numbers, processing images), you’d want to look at multiprocessing instead. But for I/O-bound work, asyncio lets you keep the queue moving without hiring more staff.
Next time you find your Python script staring blankly at the coffee machine, you’ll know what to do.