Skip to content

Latest commit

 

History

History
175 lines (118 loc) · 9.12 KB

File metadata and controls

175 lines (118 loc) · 9.12 KB

Chapter 21: Asynchronous Programming (asyncio, event loops, async/await, aiohttp)

In modern applications, especially those dealing with network I/O, waiting for operations to complete can severely limit performance and responsiveness. Asynchronous programming provides a way to achieve concurrency without using threads or processes, allowing a single thread to manage multiple I/O-bound operations efficiently. Python's asyncio library, along with the async and await keywords, forms the foundation of asynchronous programming in Python. This chapter will explore these concepts, including event loops and the aiohttp library for asynchronous HTTP requests.

The Need for Asynchronous Programming

Consider a web server that needs to handle many concurrent client requests. If each request is handled synchronously (one after another), the server will spend most of its time waiting for I/O operations (e.g., reading from a database, fetching data from another API) to complete. During this waiting time, the server is idle and cannot process other requests, leading to poor performance and scalability.

Traditional multithreading (as discussed in Chapter 20) can help with I/O-bound tasks by releasing the GIL, but it introduces overhead due to context switching and the complexities of shared state. Asynchronous programming offers an alternative: cooperative multitasking.

In cooperative multitasking, a single thread runs multiple tasks. When a task needs to wait for an I/O operation, it explicitly yields control back to an event loop, allowing the event loop to switch to another task that is ready to run. When the I/O operation completes, the event loop resumes the waiting task. This avoids the overhead of context switching between threads and the complexities of shared memory, as all tasks run within the same thread.

asyncio: The Asynchronous I/O Framework

asyncio is Python's built-in library for writing concurrent code using the async/await syntax. It is used for high-performance network and web servers, database connection libraries, distributed task queues, etc.

async and await Keywords

  • async def: Defines a coroutine. A coroutine is a special type of function that can be paused and resumed. When you call an async def function, it doesn't execute immediately; instead, it returns a coroutine object.
  • await: Can only be used inside an async def function. It pauses the execution of the current coroutine until the awaitable (another coroutine, a Task, or a Future) it's waiting on completes. While the current coroutine is paused, the event loop can switch to other tasks.
import asyncio
import time

async def say_after(delay, what):
    await asyncio.sleep(delay) # Pause this coroutine, allow event loop to run other tasks
    print(what)

async def main():
    print(f"started at {time.strftime('%X')}")

    await say_after(1, 'hello') # Wait for 1 second
    await say_after(2, 'world') # Wait for 2 seconds

    print(f"finished at {time.strftime('%X')}")

# To run an async function, you need an event loop
# asyncio.run(main()) # This is the simplest way to run the top-level async function

# Output (approximately):
# started at 14:00:00
# hello
# world
# finished at 14:00:03 (total 3 seconds, because operations are sequential)

In the example above, say_after is a coroutine. In main, await say_after(1, 'hello') pauses main for 1 second. Then await say_after(2, 'world') pauses main for 2 seconds. The total time is 3 seconds because the await calls are sequential.

To run tasks concurrently, you need to schedule them:

import asyncio
import time

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(what)

async def main_concurrent():
    print(f"started at {time.strftime('%X')}")

    # Schedule tasks to run concurrently
    task1 = asyncio.create_task(say_after(1, 'hello'))
    task2 = asyncio.create_task(say_after(2, 'world'))

    # Await their completion
    await task1
    await task2

    print(f"finished at {time.strftime('%X')}")

# asyncio.run(main_concurrent()) # Run the concurrent version

# Output (approximately):
# started at 14:00:00
# hello (after 1 second)
# world (after 2 seconds from start)
# finished at 14:00:02 (total 2 seconds, because tasks run concurrently)

Here, asyncio.create_task() schedules say_after coroutines to run. When say_after(1, 'hello') awaits asyncio.sleep(1), control returns to the event loop, which then starts say_after(2, 'world'). Both run concurrently, and main_concurrent waits for both to finish. The total time is dominated by the longest task (2 seconds).

Event Loop

The event loop is the heart of every asyncio application. It is responsible for:

  • Registering and scheduling tasks (coroutines).
  • Monitoring I/O events (e.g., network sockets, file descriptors).
  • Dispatching events to the appropriate tasks when an I/O operation completes.
  • Managing the execution flow, switching between tasks when one awaits an I/O operation.

When you call asyncio.run(), it creates a new event loop, runs the given coroutine until it completes, and then closes the loop.

Practical Asynchronous I/O: aiohttp

For real-world asynchronous network operations, you'll often use libraries built on top of asyncio. aiohttp is a popular asynchronous HTTP client/server framework.

Installation

pip install aiohttp

Asynchronous HTTP Client

Let's fetch multiple URLs concurrently using aiohttp.

import asyncio
import aiohttp
import time

async def fetch_url(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main_aiohttp():
    urls = [
        "https://www.google.com",
        "https://www.bing.com",
        "https://www.yahoo.com",
        "https://www.duckduckgo.com"
    ]

    print(f"started at {time.strftime('%X')}")

    async with aiohttp.ClientSession() as session:
        tasks = []
        for url in urls:
            tasks.append(fetch_url(session, url))

        # Run all tasks concurrently and wait for them to complete
        responses = await asyncio.gather(*tasks)

        for i, response_text in enumerate(responses):
            print(f"Fetched {urls[i]}: {len(response_text)} bytes")

    print(f"finished at {time.strftime('%X')}")

# asyncio.run(main_aiohttp())

Explanation:

  1. aiohttp.ClientSession() is an asynchronous context manager, ensuring the session is properly closed.
  2. session.get(url) returns an awaitable response object.
  3. async with session.get(url) as response: handles the asynchronous request and response.
  4. await response.text() asynchronously reads the response body.
  5. asyncio.gather(*tasks) takes multiple awaitables (our fetch_url coroutines) and runs them concurrently, waiting for all of them to complete. It returns a list of results in the order the tasks were passed.

This pattern allows for highly efficient fetching of multiple resources, as the program doesn't block while waiting for each network request to complete; instead, it switches to other requests.

Common asyncio Patterns and Best Practices

  • asyncio.sleep(): The asynchronous equivalent of time.sleep(). It releases control to the event loop, allowing other tasks to run.
  • asyncio.run(): The simplest way to run the top-level async function. It handles event loop creation and shutdown.
  • asyncio.create_task(): Used to schedule a coroutine to run concurrently as an asyncio.Task. Tasks run in the background until awaited or completed.
  • asyncio.gather(): Runs multiple awaitables concurrently and waits for all of them to complete, returning their results in order.
  • asyncio.wait(): A more flexible alternative to gather(), allowing you to wait for a subset of tasks (e.g., the first one to complete).
  • async for and async with: Asynchronous versions of for loops and with statements, used with asynchronous iterators and context managers respectively.

When to Use Asynchronous Programming

Asynchronous programming with asyncio is ideal for:

  • I/O-bound operations: Network requests (HTTP, WebSockets), database queries, file I/O.
  • High-concurrency applications: Web servers, API gateways, chat applications.
  • Situations where responsiveness is critical: UI applications that need to remain interactive while performing background tasks.

It is generally not suitable for CPU-bound tasks, as it still runs on a single thread. For CPU-bound parallelism, multiprocessing is the appropriate choice.

Conclusion

Asynchronous programming with Python's asyncio library, leveraging the async and await keywords, provides a powerful and efficient model for handling I/O-bound concurrency. By understanding event loops and how to schedule and manage coroutines, developers can build highly scalable and responsive applications that effectively utilize system resources. Libraries like aiohttp extend this capability to common tasks like HTTP communication, making asyncio an indispensable tool in the modern Python developer's toolkit for high-performance and concurrent systems.