13.5 Asynchronous Programming with asyncio

Asynchronous programming allows a program to perform non-blocking operations, which means it can execute other tasks while waiting for slow operations (such as I/O tasks) to complete. In Python, asyncio is a library that provides tools to write concurrent code using the async/await syntax. It enables programs to handle many tasks simultaneously without using threads or processes, making it ideal for I/O-bound and high-level structured network code.

In this section, we will explore how to use the asyncio module for asynchronous programming, understand key concepts like event loops, tasks, coroutines, and demonstrate how asyncio can be used for I/O-bound tasks like network requests.


13.5.1 What is Asynchronous Programming?

In asynchronous programming, instead of waiting for a task (such as reading from a file or making a network request) to complete before moving on to the next task, the program can switch to another task. This leads to more efficient execution, especially in I/O-bound tasks where the program spends a lot of time waiting.

Key characteristics of asynchronous programming:

  • Non-blocking: Functions yield control when waiting, allowing other tasks to run.
  • Concurrency: Multiple tasks are run concurrently, but not necessarily in parallel (they take turns).
  • Efficient I/O handling: Ideal for handling multiple I/O-bound tasks like database queries, network requests, or file operations.

13.5.2 Key Concepts in asyncio

  • Coroutines: Special functions defined with async def that can be paused and resumed.
  • Event Loop: The core of asyncio that runs asynchronous tasks and manages their execution.
  • Tasks: Units of work created from coroutines, managed by the event loop.
  • Futures: Objects representing results that will be available in the future.

Coroutines and async def

A coroutine is a function defined with the async def syntax. When called, it returns a coroutine object, which needs to be scheduled on the event loop to execute.

Example: Defining and Running a Simple Coroutine

import asyncio

# Define a simple coroutine
async def say_hello():
    print("Hello")
    await asyncio.sleep(1)  # Simulate an I/O-bound task
    print("World")

# Run the coroutine
asyncio.run(say_hello())

Output:

Hello
# 1 second delay
World

In this example:

  • async def defines a coroutine, which is similar to a generator but can use the await keyword to pause execution and release control back to the event loop.
  • await asyncio.sleep(1) simulates a non-blocking I/O operation that waits for 1 second.

13.5.3 The Event Loop

The event loop is the core mechanism of asyncio. It runs and schedules asynchronous tasks. You can think of it as a scheduler that decides which task runs when. The event loop repeatedly checks for tasks that are ready to run (such as those that have completed waiting for I/O) and runs them.

  • asyncio.run(coroutine): Runs the event loop until the given coroutine completes.
  • await: Pauses the coroutine, allowing other tasks to run, and resumes it once the awaited task is completed.

13.5.4 await and Non-blocking I/O

The await keyword is used to wait for the result of an asynchronous operation without blocking the rest of the program. When a coroutine calls await, it suspends its execution and allows other tasks in the event loop to run until the awaited operation is complete.

Example: Using await to Run Multiple Tasks Concurrently

import asyncio

async def task_1():
    print("Task 1 started")
    await asyncio.sleep(2)  # Simulate a 2-second I/O operation
    print("Task 1 completed")

async def task_2():
    print("Task 2 started")
    await asyncio.sleep(1)  # Simulate a 1-second I/O operation
    print("Task 2 completed")

# Run the tasks concurrently
async def main():
    await asyncio.gather(task_1(), task_2())  # Run both tasks concurrently

asyncio.run(main())

Output:

Task 1 started
Task 2 started
Task 2 completed
Task 1 completed

In this example:

  • asyncio.gather() runs both task_1() and task_2() concurrently. Task 2 completes before Task 1 because it has a shorter sleep duration.

13.5.5 Creating and Managing Tasks

In asyncio, you can create tasks to schedule coroutines on the event loop. A task is a wrapper around a coroutine that runs asynchronously.

  • asyncio.create_task(): Schedules a coroutine as a task on the event loop, allowing it to run concurrently with other tasks.

Example: Creating and Managing Multiple Tasks

import asyncio

async def task(name, delay):
    print(f"Task {name} starting")
    await asyncio.sleep(delay)
    print(f"Task {name} completed after {delay} seconds")

async def main():
    # Create multiple tasks
    task_1 = asyncio.create_task(task("A", 2))
    task_2 = asyncio.create_task(task("B", 3))

    print("Tasks have been created")
    
    # Wait for both tasks to complete
    await task_1
    await task_2

asyncio.run(main())

Output:

Tasks have been created
Task A starting
Task B starting
Task A completed after 2 seconds
Task B completed after 3 seconds

In this example:

  • Two tasks are created using asyncio.create_task(), allowing them to run concurrently.
  • The main coroutine waits for both tasks to complete using await task_1 and await task_2.

13.5.6 Using asyncio.gather to Run Coroutines Concurrently

asyncio.gather() is a convenient way to run multiple coroutines concurrently. It schedules all the provided coroutines and waits for all of them to complete.

Example: Running Multiple Coroutines Concurrently with asyncio.gather()

import asyncio

async def download_file(filename, duration):
    print(f"Start downloading {filename}")
    await asyncio.sleep(duration)  # Simulate a download delay
    print(f"Finished downloading {filename}")

async def main():
    # Download three files concurrently
    await asyncio.gather(
        download_file("file1.txt", 2),
        download_file("file2.txt", 3),
        download_file("file3.txt", 1)
    )

asyncio.run(main())

Output:

Start downloading file1.txt
Start downloading file2.txt
Start downloading file3.txt
Finished downloading file3.txt
Finished downloading file1.txt
Finished downloading file2.txt

In this example:

  • asyncio.gather() schedules all the coroutines (download_file() in this case) to run concurrently, and the event loop switches between them based on the I/O delays.

13.5.7 Async I/O: aiohttp for Asynchronous HTTP Requests

Asynchronous programming is particularly powerful for I/O-bound tasks such as making network requests. The aiohttp library allows you to make asynchronous HTTP requests using asyncio.

Example: Making Asynchronous HTTP Requests with aiohttp

import asyncio
import aiohttp

async def fetch_url(session, url):
    async with session.get(url) as response:
        print(f"Fetching {url}")
        return await response.text()

async def main():
    async with aiohttp.ClientSession() as session:
        urls = [
            'https://example.com',
            'https://example.org',
            'https://example.net'
        ]
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks)  # Run all requests concurrently
        for result in results:
            print(result[:100])  # Print the first 100 characters of each response

asyncio.run(main())

In this example:

  • aiohttp.ClientSession() is used to make asynchronous HTTP requests.
  • asyncio.gather() is used to run multiple HTTP requests concurrently, allowing the program to fetch multiple URLs without blocking.

13.5.8 Timeouts and Cancellation

In asyncio, you can set timeouts for asynchronous tasks and cancel them if they take too long to complete. The asyncio.wait_for() function allows you to set a timeout for a task, and the task.cancel() method can be used to cancel a running task.

Example: Using Timeouts with asyncio.wait_for()

import asyncio

async def long_task():
    await asyncio.sleep(5)
    print("Task completed")

async def main():
    try:
        await asyncio.wait_for(long_task(), timeout=2)  # Timeout after 2 seconds
    except asyncio.TimeoutError:
        print("Task timed out

")

asyncio.run(main())

Output:

Task timed out

In this example:

  • asyncio.wait_for() sets a timeout of 2 seconds. Since the long_task() takes 5 seconds to complete, it raises a TimeoutError after 2 seconds.

13.5.9 Async Context Managers

In asyncio, context managers can be asynchronous. For example, network connections or file handling tasks that require setup and cleanup can use async context managers.

Example: Using Async Context Managers

import asyncio
import aiohttp

async def fetch_data(url):
    async with aiohttp.ClientSession() as session:  # Async context manager
        async with session.get(url) as response:
            return await response.text()

async def main():
    result = await fetch_data('https://example.com')
    print(result[:100])  # Print the first 100 characters of the response

asyncio.run(main())

In this example:

  • async with is used with aiohttp.ClientSession() to manage the lifecycle of the HTTP session asynchronously.

13.5.10 Summary

  • asyncio is Python's library for writing asynchronous code that allows multiple tasks to run concurrently without blocking each other.
  • Coroutines: Functions defined with async def that can be paused and resumed using await.
  • Event Loop: The core of asyncio that schedules and manages the execution of asynchronous tasks.
  • await: Used to pause a coroutine and wait for a result without blocking other tasks.
  • asyncio.gather(): Runs multiple coroutines concurrently and waits for all of them to complete.
  • asyncio.create_task(): Schedules a coroutine as a task to run asynchronously.

asyncio is particularly useful for handling many I/O-bound tasks like network requests, file operations, and database access, providing an efficient and scalable way to manage asynchronous tasks in Python.