Skip to main content

Brewing Performance With async/await

Table of Contents

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:

1
2
3
4
5
6
7
8
9
from typing import List, Tuple

FLAVOUR_MENU: List[str] = ["Vanilla", "Chocolate", "Pistachio", "Caramel"]
COFFEE_MENU: List[str] = ["Latte", "Cappuccino", "Flat White", "Mocha"]

def explain_menu() -> None:
    print("Welcome to the Synchronous Café!\n")
    print(f"On our coffee menu we have {', '.join(COFFEE_MENU)}.\n")
    print(f"On our flavours menu we have {', '.join(FLAVOUR_MENU)}.\n")

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import time
from typing import List, Tuple

FLAVOUR_MENU: List[str] = ["Vanilla", "Chocolate", "Pistachio", "Caramel"]
COFFEE_MENU: List[str] = ["Latte", "Cappuccino", "Flat White", "Mocha"]

def explain_menu() -> None:
    print("Welcome to the Synchronous Café!\n")
    print(f"On our coffee menu we have {', '.join(COFFEE_MENU)}.\n")
    print(f"On our flavours menu we have {', '.join(FLAVOUR_MENU)}.\n")


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!")


def make_coffee(flavour: str, coffee: str):
    print(f"\nMaking your {flavour} {coffee}...")
    # simulate making the order
    time.sleep(5)


def serve_order(flavour: str, coffee: str):
    print(f"\nYour {flavour} flavoured {coffee} is ready, enjoy!\n")


def run_cafe():

    customer_orders = [("Vanilla", "Latte"), ("Caramel", "Mocha")]

    explain_menu()

    start = time.time()

    while customer_orders:
        flavour, coffee = customer_orders.pop(0)
        take_order(flavour, coffee)
        make_coffee(flavour, coffee)
        serve_order(flavour, coffee)

        if len(customer_orders) > 0:
            print("Next customer please!\n")

    end = time.time()

    print(f"Serving 2 customers took {(end-start):.2f} seconds")


run_cafe()

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.

Execution flow of The Synchronous Café

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
async def run_cafe() -> None:
    customer_orders: List[Tuple[str, str]] = [
        ("Vanilla", "Latte"),
        ("Caramel", "Mocha"),
    ]

    explain_menu()

    start = time.time()

    # Schedule all orders concurrently — the cafe handles them at the same time!
    tasks = [
        asyncio.create_task(handle_customer(flavour, coffee))
        for flavour, coffee in customer_orders
    ]

    await asyncio.gather(*tasks)

    end = time.time()

    print(f"Serving 2 customers took {(end-start):.2f} seconds")

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.

Tip

*tasks is Python shorthand for “each element in the tasks list”.

Putting everything together:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import asyncio
import time
from typing import List, Tuple

# asynchronous cafe
FLAVOUR_MENU: List[str] = ["Vanilla", "Chocolate", "Pistachio", "Caramel"]
COFFEE_MENU: List[str] = ["Latte", "Cappuccino", "Flat White", "Mocha"]

def explain_menu() -> None:
    print("Welcome to the Asynchronous Café!\n")
    print(f"On our coffee menu we have {', '.join(COFFEE_MENU)}.\n")
    print(f"On our flavours menu we have {', '.join(FLAVOUR_MENU)}.\n")


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!")


async def make_coffee(flavour: str, coffee: str) -> None:
    print(f"\nMaking your {flavour} {coffee}...")
    # simulate making the order — yields control while waiting
    await asyncio.sleep(5)


def serve_order(flavour: str, coffee: str) -> None:
    print(f"\nYour {flavour} flavoured {coffee} is ready, enjoy!\n")


async def handle_customer(flavour: str, coffee: str) -> None:
    take_order(flavour, coffee)
    await make_coffee(flavour, coffee)
    serve_order(flavour, coffee)


async def run_cafe() -> None:
    customer_orders: List[Tuple[str, str]] = [
        ("Vanilla", "Latte"),
        ("Caramel", "Mocha"),
    ]

    explain_menu()

    start = time.time()

    # Schedule all orders concurrently — the cafe handles them at the same time!
    tasks = [
        asyncio.create_task(handle_customer(flavour, coffee))
        for flavour, coffee in customer_orders
    ]

    await asyncio.gather(*tasks)

    end = time.time()

    print(f"Serving 2 customers took {(end-start):.2f} seconds")


asyncio.run(run_cafe())

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:

Execution flow of The Asynchronous Café

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 def marks a function as a coroutine meaning it can pause its execution.
  • await hands control back to the event loop while a slow operation completes.
  • asyncio.create_task schedules coroutines to run concurrently, and asyncio.gather waits 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.