Skip to main content

Python Async IO Basics

Introduction

Asynchronous programming is a powerful paradigm that allows your code to perform multiple operations concurrently without using multiple threads or processes. Python's asyncio library, introduced in Python 3.4 and significantly enhanced in later versions, provides tools for writing single-threaded concurrent code using coroutines.

In this tutorial, you'll learn:

  • What asynchronous programming is and when to use it
  • How coroutines work in Python
  • The basics of Python's asyncio library
  • Writing and running asynchronous code
  • Practical examples of async IO in real-world scenarios

Why Use Asynchronous Programming?

Traditional synchronous code executes sequentially, meaning one task must complete before the next begins. This can lead to inefficiency, especially when dealing with I/O-bound operations like:

  • Network requests
  • Database queries
  • File operations
  • API calls

Async IO allows your program to perform these operations concurrently, significantly improving performance without the complexity of multi-threading or multi-processing.

Key Concepts in Python's Asyncio

Coroutines

Coroutines are special functions that can pause execution and yield control back to the event loop. They are defined using async def syntax.

python
async def my_coroutine():
print("Hello")
await asyncio.sleep(1)
print("World")

The await Keyword

The await keyword is used to pause the execution of a coroutine until the awaited coroutine completes. You can only use await inside an async def function.

Event Loop

The event loop is the core of every asyncio application. It runs asynchronous tasks and callbacks, performs network IO operations, and runs subprocesses.

Basic Asyncio Example

Let's start with a simple example to see asyncio in action:

python
import asyncio

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

async def main():
print("started at", asyncio.get_event_loop().time())

await say_after(1, 'hello')
await say_after(2, 'world')

print("finished at", asyncio.get_event_loop().time())

# Python 3.7+
asyncio.run(main())

Output:

started at 0.0
hello
world
finished at 3.01

In this example, the total execution time is about 3 seconds because the two say_after coroutines are called sequentially.

Concurrent Execution with asyncio.gather

To run coroutines concurrently, you can use asyncio.gather:

python
import asyncio
import time

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

async def main():
start = time.time()

# Run these coroutines concurrently
results = await asyncio.gather(
say_after(1, 'hello'),
say_after(2, 'world')
)

print(results) # ['hello', 'world']
print(f"Completed in {time.time() - start:.2f} seconds")

asyncio.run(main())

Output:

['hello', 'world']
Completed in 2.00 seconds

Notice that the total execution time is now just about 2 seconds instead of 3, because both tasks run concurrently.

Working with Tasks

A Task is a wrapper around a coroutine that schedules it to run on the event loop. You can create tasks using asyncio.create_task() (Python 3.7+):

python
import asyncio

async def fetch_data():
print("Start fetching data")
await asyncio.sleep(2)
print("Data fetched")
return {"data": "important stuff"}

async def main():
print("Creating task")
task = asyncio.create_task(fetch_data())

# Do other work while the task is running
print("Doing other work")
await asyncio.sleep(1)
print("Still working...")

# Now wait for the result of the task
result = await task
print(f"Got result: {result}")

asyncio.run(main())

Output:

Creating task
Start fetching data
Doing other work
Still working...
Data fetched
Got result: {'data': 'important stuff'}

Handling Timeouts

Sometimes you'll want to limit how long an operation can take. You can do this with asyncio.wait_for:

python
import asyncio

async def long_running_task():
await asyncio.sleep(5)
return "Completed!"

async def main():
try:
# Wait for the task with a timeout of 2 seconds
result = await asyncio.wait_for(long_running_task(), timeout=2)
print(result)
except asyncio.TimeoutError:
print("Task took too long!")

asyncio.run(main())

Output:

Task took too long!

Real-World Example: Fetching Multiple URLs

Let's see a practical example of using asyncio to fetch multiple websites concurrently:

python
import asyncio
import aiohttp
import time

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

async def main():
urls = [
"https://python.org",
"https://pypi.org",
"https://docs.python.org",
"https://github.com",
"https://stackoverflow.com"
]

start = time.time()

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

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

# Print the lengths of the responses
for i, result in enumerate(results):
print(f"{urls[i]}: {len(result)} bytes")

print(f"Completed in {time.time() - start:.2f} seconds")

# Note: You need to install aiohttp first with:
# pip install aiohttp

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

Output (sample):

https://python.org: 49237 bytes
https://pypi.org: 15283 bytes
https://docs.python.org: 12568 bytes
https://github.com: 278965 bytes
https://stackoverflow.com: 257062 bytes
Completed in 0.87 seconds

In this example, all five websites are fetched concurrently, dramatically improving performance compared to sequential fetching.

Common Asyncio Patterns

Running Background Tasks

python
import asyncio

async def main():
# Schedule a background task
background_task = asyncio.create_task(background_worker())

# Continue with main logic
await asyncio.sleep(5)
print("Main logic completed")

# Wait for the background task to finish
await background_task

async def background_worker():
while True:
print("Background worker running...")
await asyncio.sleep(1)

asyncio.run(main())

Cancelling Tasks

python
import asyncio

async def long_task():
try:
print("Long task started")
await asyncio.sleep(10)
print("Long task completed")
except asyncio.CancelledError:
print("Long task was cancelled!")
raise

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

# Let it run for a bit
await asyncio.sleep(2)

# Then cancel it
task.cancel()

try:
await task
except asyncio.CancelledError:
print("Main: task was cancelled")

asyncio.run(main())

Output:

Long task started
Long task was cancelled!
Main: task was cancelled

Best Practices for Asyncio

  1. Don't block the event loop: Avoid long-running CPU-bound tasks in your coroutines. Use asyncio.to_thread() (Python 3.9+) or executor pools for CPU-intensive operations.

  2. Always await coroutines: Never leave a coroutine "unawaited" as this can lead to warnings and unexpected behavior.

  3. Use asyncio.create_task() for fire-and-forget operations: This schedules the coroutine for execution and returns immediately.

  4. Properly handle exceptions: Use try/except blocks around awaited coroutines to handle exceptions gracefully.

  5. Consider using async libraries: For example, use aiohttp instead of requests for HTTP operations, or asyncpg instead of psycopg2 for PostgreSQL database access.

Common Pitfalls

Mixing Sync and Async Code

A common mistake is calling synchronous functions that block inside coroutines:

python
import asyncio
import time

async def main():
# Bad: This blocks the event loop
time.sleep(1)

# Good: This allows other coroutines to run while sleeping
await asyncio.sleep(1)

Forgetting to Await

python
async def fetch_data():
await asyncio.sleep(1)
return "Data"

async def main():
# Bad: The coroutine is never awaited
fetch_data()

# Good: Properly await the coroutine
data = await fetch_data()

Summary

Asynchronous programming with Python's asyncio library offers a powerful way to handle concurrent operations without the complexity of multi-threading or multi-processing. The key components of asyncio include:

  • Coroutines: Functions defined with async def that can be paused and resumed
  • Event Loop: The central execution mechanism that runs and manages coroutines
  • Tasks: Wrapper objects that schedule coroutines to run on the event loop
  • Await: The keyword that pauses execution of a coroutine until the awaited operation completes

Async IO is particularly useful for I/O-bound operations like network requests, file operations, and database queries. It allows your program to execute multiple operations concurrently, significantly improving performance in many real-world scenarios.

Additional Resources

Exercises

  1. Create an asynchronous function that reads multiple files concurrently and returns their contents.
  2. Build a simple web scraper that fetches and parses content from multiple web pages concurrently.
  3. Implement a periodic task using asyncio that runs every X seconds indefinitely.
  4. Create an async function that queries multiple API endpoints and aggregates the results.
  5. Extend the URL fetching example to handle errors gracefully and implement retries.

Happy coding with asyncio! 🐍



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)