A Practical Guide to Python Async Programming

AKRAM BOUTZOUGA
6 min readOct 14, 2024

--

Asynchronous programming is crucial when dealing with tasks that can take a long time to complete, such as file I/O, network requests, or database queries. Python provides excellent support for asynchronous programming through its asyncio module, allowing you to write non-blocking code using the async and await syntax. In this guide, we'll cover everything you need to know to get started, including executing async programs, running coroutines concurrently, and integrating modules like random.

Table of Contents

  1. Understanding Async and Await Syntax
  2. How to Execute an Async Program with asyncio
  3. Running Concurrent Coroutines
  4. Creating and Managing asyncio Tasks
  5. Using the random Module in Async Code
  6. More about Async
  7. Conclusion

1. Understanding async and await Syntax

The backbone of Python’s asynchronous programming model is the async and await syntax. These keywords allow you to define and pause the execution of coroutines.

  • async def: Declares a function as asynchronous. This function is a coroutine and can contain await expressions.
  • await: Pauses the execution of the coroutine until the awaited task is completed. Only functions that are asynchronous can use await.

Here’s a simple example:

import asyncio

async def say_hello():
await asyncio.sleep(1) # Simulating an I/O task
print("Hello, Async World!")

# This function will be run asynchronously
async def main():
await say_hello()

# Running the async function using asyncio.run
asyncio.run(main())

2. How to Execute an Async Program with asyncio

In Python, asynchronous programs are executed using an event loop, which manages the asynchronous tasks. The function asyncio.run() is the simplest way to run an async program, as it automatically manages the event loop for you.

Here’s how it works:

async def say_hello():
await asyncio.sleep(1)
print("Hello, Async World!")

# Run the async function
asyncio.run(say_hello())

In this example:

  • say_hello() is an async function, but nothing happens until it is awaited or run inside an event loop using asyncio.run()

3. Running Concurrent Coroutines

One of the greatest benefits of asynchronous programming is the ability to run coroutines concurrently. This allows Python to perform multiple tasks at once without blocking the main thread.

To run multiple coroutines at the same time, we can use asyncio.gather().

Example:

import asyncio

async def fetch_data_1():
await asyncio.sleep(2)
return "Data from Task 1"

async def fetch_data_2():
await asyncio.sleep(3)
return "Data from Task 2"

async def main():
# Running multiple coroutines concurrently
results = await asyncio.gather(fetch_data_1(), fetch_data_2())
print(results)

asyncio.run(main())

In this example:

  • Both fetch_data_1 and fetch_data_2 run concurrently, so the total runtime will be around 3 seconds (the longest wait time), rather than 5 seconds.

4. Creating and Managing asyncio Tasks

Tasks allow you to schedule the execution of a coroutine. Unlike await, which pauses the execution of a coroutine until it completes, tasks run asynchronously and don't block the main event loop. You can create tasks using asyncio.create_task().

Example of creating and managing tasks:

import asyncio

async def task_1():
await asyncio.sleep(2)
print("Task 1 finished")

async def task_2():
await asyncio.sleep(1)
print("Task 2 finished")

async def main():
# Create tasks
t1 = asyncio.create_task(task_1())
t2 = asyncio.create_task(task_2())

# Wait for tasks to complete
await t1
await t2

asyncio.run(main())

In this case:

  • Both tasks are started, and while task_1 is waiting for sleep(2) to complete, task_2 is already running. The tasks finish in the order of their completion times.

5. Using the random Module in Async Code

The random module can be useful for generating random delays, numbers, or other values in your async code. It works well with async functions and can be used to add variability to your program’s behavior.

Here’s an example where we use random to simulate tasks with random delays:

import asyncio
import random

async def random_task(task_name):
delay = random.uniform(1, 3) # Random delay between 1 and 3 seconds
await asyncio.sleep(delay)
print(f"{task_name} finished after {delay:.2f} seconds")

async def main():
tasks = [
asyncio.create_task(random_task("Task 1")),
asyncio.create_task(random_task("Task 2")),
asyncio.create_task(random_task("Task 3"))
]

# Wait for all tasks to complete
await asyncio.gather(*tasks)

asyncio.run(main())

In this example:

  • The random.uniform(1, 3) function generates a random float between 1 and 3 seconds, making the completion time for each task variable.
  • All tasks still run concurrently, and we use asyncio.gather() to ensure they finish before exiting.

Why Use Async Programming?

Asynchronous programming lets you run concurrent coroutines without creating multiple threads. This avoids the overhead of managing threads while still allowing your program to handle multiple I/O-bound tasks simultaneously. It’s a simpler and often more efficient way to perform non-blocking tasks, such as waiting for a file to load or sending a web request, without stalling the main program’s flow.

In contrast to multithreading, asynchronous programming doesn’t run tasks truly in parallel (i.e., multiple CPU cores aren’t used simultaneously), but it allows you to efficiently schedule multiple tasks to run concurrently in a single thread, switching between tasks when one needs to wait (like for a network response). This is ideal for tasks that involve waiting for I/O operations, as it allows your program to remain responsive and perform other tasks in the meantime.

Can Async Improve Performance?

Indeed, asynchronous programming can greatly enhance performance, particularly for I/O-bound operations, such as file reading, network communication, or database interactions. Utilizing asyncio allows Python to handle multiple tasks simultaneously without waiting for one to finish before beginning another, thus boosting overall efficiency. For instance, retrieving data from multiple APIs concurrently will be quicker than fetching them one after another.

To illustrate, let’s consider a real-world example. Suppose you’re booking a movie ticket while casually scrolling through Instagram. In a synchronous program, you would wait for each task to complete one by one, causing a delay:

#!/usr/bin/env python3
import time

def get_movie_ticket():
time.sleep(7)
print("got my ticket! let's go")

def scroll_ig():
time.sleep(3)
print("scrolling!")

def main():
start = time.time()
get_movie_ticket() # Takes 7 seconds
scroll_ig() # Takes 3 seconds
print("Runtime:", time.time() - start)

main()

Synchronous Execution:

Output:

got my ticket! let's go
scrolling!
Runtime: 10.0 seconds

Here, fetching a movie ticket takes 7 seconds and scrolling Instagram takes 3 seconds. The total runtime is 10 seconds because the tasks execute sequentially, one after the other.

Now, compare this with an asynchronous version:

#!/usr/bin/env python3
import asyncio
import time

async def get_movie_ticket():
await asyncio.sleep(7)
print("got my ticket! let's go")

async def scroll_ig():
await asyncio.sleep(3)
print("scrolling!")

async def main():
start = time.time()
task1 = asyncio.create_task(get_movie_ticket())
task2 = asyncio.create_task(scroll_ig())

await task1
await task2
print("Runtime:", time.time() - start)

asyncio.run(main())

Asynchronous Execution:

Output:

scrolling!
got my ticket! let's go
Runtime: 7.0 seconds

With async execution, both tasks run concurrently. Although get_movie_ticket() still takes 7 seconds and scroll_ig() takes 3 seconds, they overlap, reducing the overall runtime to just 7 seconds.

However, it’s crucial to note that async doesn’t improve performance for CPU-bound tasks (e.g., calculations, data analysis). Since async doesn’t execute in parallel but cooperatively alternates between tasks when one is waiting, processor-intensive operations won’t benefit and might even perform slower with async due to increased complexity.

When to Avoid Async?

While async offers great benefits, it’s not always the right choice. Here are cases where you might want to skip it:

  • Processor-heavy tasks: For jobs that need lots of number crunching (like complex math or big data analysis), async can actually slow things down. In these situations, using threads or multiple processes works better as they allow true parallel work.
  • Basic, quick scripts: If your program is simple and doesn’t involve much waiting (like reading files one after another), adding async will just make the code more complex without much upside.
  • Tasks that don’t wait on input/output: When your work doesn’t involve network calls, file reading/writing, or other operations with waiting periods, async won’t give you any real advantage.

Using async when it’s not needed can make your code harder to fix and understand without any real speed boost.

Conclusion

Asynchronous programming in Python with asyncio is a powerful tool for managing I/O-bound tasks in a non-blocking way. Key components include:

  • The async and await keywords, which make your code pause and resume efficiently.
  • Running coroutines concurrently with asyncio.gather(), allowing multiple tasks to be processed at once.
  • Creating tasks with asyncio.create_task(), which lets you run coroutines in the background while still being able to await their results later.
  • Using random delays with the random module to add variability to async tasks.

With these concepts, you can build more responsive and efficient Python applications that handle large amounts of I/O or waiting time without blocking execution. Async is particularly useful in web servers, network applications, and any program that requires significant I/O without locking the user experience.

--

--

AKRAM BOUTZOUGA
AKRAM BOUTZOUGA

Written by AKRAM BOUTZOUGA

Junior Calisthenics Engineer, Ai Enthusiast. Coding and Flexing! 💻💪

No responses yet