
In 2015, Python 3.5 introduced two new keywords that would fundamentally change how developers approach concurrent programming: async
and await
. These keywords weren't just syntactic sugar—they represented a paradigm shift in how Python handles operations that involve waiting, such as network requests, file I/O, or database queries. This article explores what these keywords are, why they matter, and how they can make your code more efficient and readable.
The Problem: Concurrency vs. Parallelism
Before diving into async
and await
, it's important to understand the problem they solve. Modern applications often need to handle multiple operations simultaneously. There are two primary approaches to achieving this:
- Parallelism: Executing multiple tasks at exactly the same time, typically using multiple CPU cores (think of multiple people working on different tasks)
- Concurrency: Managing multiple tasks that start, run, and complete in overlapping time periods (think of one person juggling multiple tasks by switching between them)
Python has long had tools for both approaches. For parallelism, the multiprocessing
library leverages multiple CPU cores. For concurrency, Python traditionally relied on threads (threading
library) or callback-based approaches.
However, these traditional concurrency models came with significant challenges: threads can be resource-intensive and introduce complex synchronization issues, the Global Interpreter Lock (GIL) limits the effectiveness of threads for CPU-bound tasks, and callback-based code can become difficult to read and maintain (often called "callback hell").
Enter Async and Await
The async
and await
keywords introduced a new concurrency model based on coroutines. This model allows developers to write asynchronous code that looks and behaves like synchronous code, making it significantly easier to read and reason about.
Here's what makes these keywords special:
- They enable cooperative multitasking where functions voluntarily yield control when waiting for I/O
- They allow multiple operations to progress concurrently without blocking the main thread
- They maintain code readability by avoiding deeply nested callbacks
The Basics: How Async and Await Work
Let's break down the fundamental concepts:
Coroutines
A coroutine is a special type of function that can pause execution and yield control back to the event loop, allowing other code to run during periods of waiting. You define a coroutine using the async def
syntax:
async def fetch_data():
# This is a coroutine
print("Starting to fetch data...")
# Some operation that might take time
print("Data fetched!")
return "Here's your data"
However, simply calling this function doesn't execute it immediately:
# This just creates a coroutine object - it doesn't run the code!
result = fetch_data()
print(result) # Outputs a coroutine object, not "Here's your data"
Awaiting Coroutines
To actually execute a coroutine, you need to await it from within another coroutine using the await
keyword:
async def main():
result = await fetch_data() # Pauses main() until fetch_data() completes
print(f"Got result: {result}")
The Event Loop
The true magic happens with the event loop, which orchestrates the execution of coroutines. When a coroutine awaits a long-running operation, the event loop can switch to other tasks, then return when the operation completes.
Python's asyncio
module provides tools for working with the event loop:
import asyncio
# Run the main coroutine until completion
asyncio.run(main())
Real-World Example: Web Scraping
Let's see how async
and await
can dramatically improve performance for I/O-bound tasks like web scraping:
import asyncio
import aiohttp # Async HTTP client
import time
async def fetch_page(session, url):
async with session.get(url) as response:
return await response.text()
async def scrape_sites(sites):
async with aiohttp.ClientSession() as session:
tasks = []
for url in sites:
tasks.append(fetch_page(session, url))
# Wait for all requests to complete simultaneously
results = await asyncio.gather(*tasks)
return results
# List of websites to scrape
sites = [
"https://python.org",
"https://pypi.org",
"https://docs.python.org",
"https://peps.python.org",
"https://github.com/python"
]
# Measure execution time
start_time = time.time()
results = asyncio.run(scrape_sites(sites))
duration = time.time() - start_time
print(f"Scraped {len(results)} sites in {duration:.2f} seconds")
This example demonstrates the power of asynchronous programming. Instead of waiting for each request to complete before starting the next one, all requests are initiated concurrently. The result? What might take several seconds sequentially can be completed in a fraction of the time.
When to Use Async and Await
Asynchronous programming isn't a silver bullet for all performance problems. It shines in specific scenarios:
Ideal Use Cases
- I/O-bound tasks: Network requests, file operations, database queries
- High-concurrency applications: Web servers, chat applications, real-time dashboards
- Tasks involving waiting: APIs with rate limits, scheduled operations
Less Ideal Use Cases
- CPU-bound tasks: Complex calculations, image processing (use
multiprocessing
instead) - Simple sequential scripts: The overhead might not be worth it
- Code that needs to be compatible with older Python versions (pre-3.5)
Common Pitfalls and Best Practices
While async
and await
make concurrent programming more accessible, there are still some common mistakes to avoid:
Blocking the Event Loop
The most common mistake is performing blocking operations in a coroutine without awaiting them:
async def bad_practice():
# This blocks the entire event loop!
import time
time.sleep(5) # Use await asyncio.sleep(5) instead
# This also blocks!
with open('large_file.txt', 'r') as f: # Use async file I/O instead
data = f.read()
Forgetting to Await
Another common mistake is forgetting to await a coroutine:
async def main():
# This creates a coroutine object but doesn't execute it!
fetch_data() # Should be: await fetch_data()
Best Practices
- Use async libraries for I/O operations (aiohttp, asyncpg, etc.)
- Structure your code to limit the number of places where you interact with the event loop
- Use tools like
asyncio.gather()
to run multiple coroutines concurrently - Consider using
async with
andasync for
for resource management and iteration in async contexts
The Ecosystem: Async Libraries
One of the strengths of Python's async model is the rich ecosystem of libraries that support it:
- Web Frameworks: FastAPI, Starlette, Sanic, AIOHTTP
- Database: asyncpg, aiomysql, motor
- HTTP Clients: aiohttp, httpx
- Testing: pytest-asyncio
These libraries are built from the ground up to leverage async/await, providing significant performance benefits over their synchronous counterparts.
Conclusion
The introduction of async
and await
in Python 3.5 represented a significant milestone in the language's evolution. These keywords brought a more intuitive and maintainable approach to concurrent programming, enabling developers to write efficient code without sacrificing readability.
As the Python ecosystem continues to embrace asynchronous programming, understanding these concepts has become increasingly important. Whether you're building web applications, working with APIs, or processing data from multiple sources, async
and await
provide powerful tools to make your code more responsive and efficient.
The next time you find yourself waiting on network requests or file operations, consider whether your application might benefit from the concurrency model that async
and await
provide. Your users—and future you—will thank you for the improved performance and maintainability.