Master asyncio in Python: A Comprehensive Step-by-Step Guide

We will go over asyncio fundamentals while mastering Coroutines, Tasks and Event Loops with detailed examples

Arun Suresh Kumar
PythonIQ
6 min readApr 6, 2023

--

Photo by Gabriel Gusmao on Unsplash

Context

For a historic context, you should know that asyncio was introduced in Python 3.4 as a provisional module and due to its wide acceptance has since become a standard library in Python 3.7. It was developed to address the challenges of asynchronous programming in Python and make it easier to write concurrent code. Prior to asyncio, developers had to rely on threading, multiprocessing or third-party libraries like Twisted or Tornado.

Introduction

Asyncio is an asynchronous I/O framework that allows you to write concurrent code using the async and await syntax. It's based on an event loop, which is responsible for managing I/O operations, executing coroutines and handling other asynchronous tasks.

Environment Setup

To start using asyncio, you need to have Python 3.7 or higher installed. You can check your Python version by running the following command in your terminal or command prompt:

python --version

If you need to install or upgrade Python, visit the official Python website (https://www.python.org/downloads/) to download the latest version.

Basic Concepts

Before diving into the code, let’s understand the two core concepts of asyncio: event loop and coroutines.

Event Loop

The event loop is the core of asyncio’s execution model. It schedules and manages tasks, handles I/O operations and coordinates the execution of coroutines. You can think of it as the central manager of all asynchronous tasks in your program.

Coroutines

Coroutines are the building blocks of asynchronous code in asyncio. They are functions defined with the async def syntax and can be paused and resumed during execution. Coroutines use the await keyword to yield control back to the event loop, allowing other tasks to run concurrently.

Simple asyncio application

Let’s start by creating a simple asyncio application to demonstrate the use of coroutines and the event loop.

import asyncio

async def greet(name, delay):
await asyncio.sleep(delay)
print(f"Hello, {name}!")

async def main():
await asyncio.gather(
greet("Alice", 2),
greet("Bob", 1),
greet("Charlie", 3),
)

if __name__ == "__main__":
asyncio.run(main())

Output

Hello, Bob!
Hello, Alice!
Hello, Charlie!

We defined a coroutine greet() that takes a name and a delay as parameters. It sleeps for the given delay using await asyncio.sleep(delay) and then prints a greeting message. The main() coroutine gathers all the greet coroutines and runs them concurrently using asyncio.gather(). Finally, we use asyncio.run(main()) to execute the main coroutine and start the event loop. Notice the prints happening asynchronous based on the delay.

Working with Tasks and Coroutines

Next let’s create tasks to schedule coroutines for execution. Tasks are created using the asyncio.create_task() function and can be awaited like coroutines.

import asyncio

async def long_operation(n):
print(f"Starting operation {n}")
await asyncio.sleep(n)
print(f"Finished operation {n}")

async def main():
task1 = asyncio.create_task(long_operation(2))
task2 = asyncio.create_task(long_operation(4))

# Wait for tasks to complete
await task1
await task2

if __name__ == "__main__":
asyncio.run(main())

Output

Starting operation 2
Starting operation 4
Finished operation 2
Finished operation 4

We created two tasks, task1 and task2, that run the long_operation() coroutine with different parameters. We then await both tasks to ensure they complete before the main coroutine finishes.

Error Handling and Timeouts

You can handle exceptions in asyncio coroutines using the standard try and except blocks. Additionally, you can use asyncio.wait_for() to set a timeout for coroutines to complete.


import asyncio

async def might_fail():
try:
await asyncio.sleep(2)
print("Success!")
except asyncio.CancelledError:
print("Operation cancelled")

async def main():
task = asyncio.create_task(might_fail())

try:
await asyncio.wait_for(task, timeout=1)
except asyncio.TimeoutError:
print("Operation timed out")
task.cancel()
await task

if __name__ == "__main__":
asyncio.run(main())

Output

Operation timed out
Operation cancelled

Here we use asyncio.wait_for() to set a timeout of 1 second for the might_fail() coroutine. If the coroutine doesn't complete within the timeout, an asyncio.TimeoutError is raised. We then cancel the task and await it to ensure proper cancellation.

Using asyncio with Networking

Asyncio makes it easy to work with networking using asynchronous I/O. Let’s visit an example of an echo server that uses asyncio’s StreamReader and StreamWriter:

import asyncio

async def echo(reader, writer):
while True:
data = await reader.read(100)
if not data:
break

writer.write(data)
await writer.drain()

writer.close()
await writer.wait_closed()

async def main():
server = await asyncio.start_server(echo, "127.0.0.1", 8888)

async with server:
await server.serve_forever()

if __name__ == "__main__":
asyncio.run(main())

In this example, we create an echo server that listens on port 8888. The echo() coroutine handles incoming connections, reads data from the StreamReader and writes the same data back to the StreamWriter. The server is started using asyncio.start_server() and runs indefinitely with server.serve_forever().

asyncio and 3rd party libraries

To fully leverage the power of asyncio, it’s important to know how to work with third-party libraries that support asynchronous programming. Many popular libraries have been developed or updated to support asyncio, such as aiohttp for HTTP clients and servers, aiomysql for MySQL and aioredis for Redis.

Example: Using aiohttp

To demonstrate how to use asyncio with third-party libraries, let’s create a simple application that fetches data from an API using aiohttp. First, you need to install the aiohttp library:

pip install aiohttp

Then, create the following Python script:

import aiohttp
import asyncio

async def fetch(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()

async def main():
url = "https://jsonplaceholder.typicode.com/todos/1"
result = await fetch(url)
print(result)

if __name__ == "__main__":
asyncio.run(main())

In this example, we define a fetch() coroutine that takes a URL as a parameter. Using aiohttp.ClientSession(), we perform an asynchronous GET request and read the response text. The main() coroutine awaits the fetch() coroutine and prints the response.

Debugging and Profiling

Debugging and profiling asyncio applications can be slightly different from traditional synchronous applications. Fortunately, Python provides built-in tools and features to help with debugging and profiling asyncio code.

Debugging

To enable asyncio’s debug mode, set the PYTHONASYNCIODEBUG environment variable to 1 or call asyncio.set_debug(True) before running your event loop. This will enable additional checks and warnings that can help you identify issues in your code.

Profiling

Profiling asyncio applications can be done using Python’s built-in cProfile module or third-party tools like py-spy. When using cProfile, you can profile your event loop by running the following command:

python -m cProfile your_script.py

This will output the time spent in each function, making it easier to identify performance bottlenecks in your code.

Best Practices for Asyncio

Finally, let’s review some best practices for using asyncio in Python:

  1. Use async def and await keywords to define coroutines and pause their execution.
  2. Use asyncio.gather() or asyncio.create_task() to run coroutines concurrently.
  3. Properly handle exceptions using try and except blocks.
  4. Use asyncio.wait_for() to set timeouts for coroutines.
  5. Always close resources, like network connections or file handles, when they are no longer needed.
  6. Use third-party libraries that support asyncio for better performance and compatibility.
  7. Enable asyncio’s debug mode and use profiling tools to identify and fix issues in your code.

By following these best practices, you can create efficient, scalable and maintainable asyncio applications in Python.

Conclusion

In this tutorial, we explored the asyncio library in Python, covering its historical context, core concepts and various features. We demonstrated how to create and manage tasks, handle errors, implement timeouts, work with networking and integrate third-party libraries. Additionally, we discussed best practices, debugging and profiling techniques to help you build efficient, scalable and maintainable asyncio applications.

Note from me!

Asynchronous programming in Python is a powerful way to enhance your applications and make them more responsive. By mastering asyncio and its principles, you’ll be well-equipped to tackle complex concurrent programming challenges and create high-performance applications that can handle a variety of tasks simultaneously.

Go ahead and dive deeper into the asyncio library and explore its many features, as it will undoubtedly become an invaluable tool in your Python developer toolkit.

--

--