Async I/O with asyncio
Master async I/O in Python using asyncio. Handle I/O-bound tasks efficiently with coroutines, event loops, and awaitable objects. Includes hands-on exercises and next-step connections.
Focus: use asyncio for asynchronous i/o
Your Python program hangs when waiting for a database query, an HTTP response, or a file read. For I/O-bound tasks like these, threads can help, but they add overhead, synchronization bugs, and memory pressure. The asyncio module gives you a concurrent, single-threaded alternative: you write asynchronous coroutines that pause and resume without blocking the entire program, letting you handle hundreds of I/O operations concurrently in a clean, readable style.
The problem this lesson solves
I/O operations—network requests, file reads, database queries—are orders of magnitude slower than CPU instructions. A single synchronous HTTP call can block your application for hundreds of milliseconds, during which the entire program waits. This is called I/O-bound blocking. Traditional solutions like threading or multiprocessing add complexity: threads share memory and require locks; processes are heavy and don't share state easily.
Imagine a web scraper that fetches 100 pages. A synchronous loop fetches one at a time. With threading, you might use ThreadPoolExecutor—but each thread takes ~8 MB of memory, and you need careful coordination. asyncio solves this by providing a cooperative multitasking model within a single thread.
Core concept / mental model
Think of asyncio as a task scheduler inside your Python process. Instead of many threads, you have an event loop that runs multiple coroutines (async functions). When a coroutine hits an I/O operation, it says: "I'm going to wait—run someone else." The event loop saves its state and executes another ready coroutine. When the I/O finishes, the loop resumes the original coroutine right where it left off.
Key definitions
- Coroutine: An
async deffunction that can be paused and resumed. - Awaitable: An object you can
await(coroutine,asyncio.Task,asyncio.Future). - Event loop: The engine that schedules and runs coroutines.
- Task: A wrapper around a coroutine that schedules it on the event loop.
🧠 Mental image: Coroutines are like cooperative workers. Each worker picks up a task, begins working, and when it needs to wait (e.g., fetch data from disk), it voluntarily hands control back to the boss (event loop), who assigns another worker. No one is forcibly interrupted.
How it works step by step
- Define a coroutine: Use
async definstead ofdef. - Inside the coroutine, use
awaitto pause execution until an awaitable completes. - Run the event loop with
asyncio.run(main())—this creates the loop, runsmain(), and shuts down the loop. - Create multiple tasks using
asyncio.create_task()to run coroutines concurrently. - Gather results with
asyncio.gather(*tasks)to wait for all tasks to finish.
A simple example
import asyncio
async def say_hello(name: str, delay: float):
await asyncio.sleep(delay)
print(f"Hello, {name}!")
async def main():
# Run two coroutines concurrently
task1 = asyncio.create_task(say_hello("Alice", 2))
task2 = asyncio.create_task(say_hello("Bob", 1))
# Wait for both to complete
await task1
await task2
asyncio.run(main())
Expected output:
Hello, Bob!
Hello, Alice!
Bob's 1-second delay finishes first, so his message appears before Alice's, even though the coroutines started together. This demonstrates concurrent execution.
The event loop in action
import asyncio
async def count_to_n(name: str, n: int, delay: float):
for i in range(1, n + 1):
print(f"{name}: {i}")
await asyncio.sleep(delay)
return f"{name} finished"
async def main():
results = await asyncio.gather(
count_to_n("A", 3, 0.5),
count_to_n("B", 2, 0.8)
)
print(results)
asyncio.run(main())
Expected output (order may vary slightly):
A: 1
B: 1
A: 2
A: 3
B: 2
['A finished', 'B finished']
Here asyncio.gather runs both coroutines concurrently. The event loop interleaves their execution based on await asyncio.sleep() pauses.
Hands-on walkthrough
Let's build a realistic example: fetch multiple URLs concurrently using aiohttp (an async HTTP library). Install it first:
pip install aiohttp
Then write:
import asyncio
import aiohttp
async def fetch_url(session: aiohttp.ClientSession, url: str) -> str:
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/delay/3"
]
async with aiohttp.ClientSession() as session:
tasks = [asyncio.create_task(fetch_url(session, url)) for url in urls]
pages = await asyncio.gather(*tasks)
print(f"Fetched {len(pages)} pages")
asyncio.run(main())
Expected output:
Fetched 3 pages
All three URLs are fetched concurrently. The total time is roughly the slowest single request (~3 seconds) instead of 6 seconds if done synchronously.
💡 Pro tip: Always use
async withfor session management—it ensures proper cleanup even if an exception occurs.aiohttp.ClientSessionacts as an async context manager.
Compare options / when to choose what
| Approach | Best for | Memory | Complexity | Overhead |
|---|---|---|---|---|
asyncio |
Many concurrent I/O-bound tasks (e.g., web scraping, API calls, database queries) | Very low (coroutine ≈ few KB) | Medium | Minimal |
| Threading | I/O-bound with blocking libraries (no async support) | High (each thread ≈ 8 MB) | High (locks, race conditions) | Context switch overhead |
| Multiprocessing | CPU-bound tasks (heavy computation) | Very high (each process ≈ 30 MB+) | Very high (IPC needed) | Process creation overhead |
| Synchronous loop | Simple sequential I/O (rarely acceptable for production) | Low | Low | N/A |
Rule of thumb: Use asyncio when all your I/O libraries support it (the ecosystem is mature: aiohttp, asyncpg, httpx, aioredis, etc.). For blocking libraries, consider threading with run_in_executor.
Troubleshooting & edge cases
Common pitfalls
- Forgetting
await: Calling anasyncfunction withoutawaitreturns a coroutine object, not the result. Alwaysawaitasync calls.
python
result = fetch_url(session, url) # ❌ Returns coroutine, not data
result = await fetch_url(session, url) # ✅ Correct
- Mixing sync and async: You cannot
awaita synchronous function. If you need to run blocking code in an async context, useasyncio.to_thread()(Python 3.9+).
```python import time
async def main(): # This will block the event loop! time.sleep(1) # ❌ # Use this instead: await asyncio.to_thread(time.sleep, 1) # ✅ ```
asyncio.run()only once: In a script, callasyncio.run()once. Nestingasyncio.run()inside a coroutine raises aRuntimeError. Useasyncio.get_event_loop()if needed for advanced use cases.
Error handling
import asyncio
async def failing_coro():
raise ValueError("Something went wrong")
async def main():
try:
await failing_coro()
except ValueError as e:
print(f"Caught: {e}")
asyncio.run(main())
Expected output:
Caught: Something went wrong
When using asyncio.gather(), you can use return_exceptions=True to collect exceptions as results instead of propagating them.
async def main():
results = await asyncio.gather(
failing_coro(),
asyncio.sleep(1),
return_exceptions=True
)
print(results) # [ValueError('Something went wrong'), None]
What you learned & what's next
You now understand:
- The problem asyncio solves: I/O blocking in single-threaded code.
- The mental model: cooperative multitasking with an event loop.
- The step-by-step mechanics: coroutines, await, tasks, and gather.
- The hands-on application: fetching URLs concurrently.
- When to choose asyncio over threading or multiprocessing.
- Common troubles like missing await or mixing sync/async.
Next, you'll learn how to integrate async code with synchronous libraries using asyncio.to_thread and run_in_executor, enabling you to unify blocking and non-blocking code in one application.
Practice recap
Now try modifying the URL fetcher to handle 10 URLs with random delays. Use asyncio.as_completed() to print each page as soon as it arrives, regardless of order. Aim for a two-second runtime with URLs that vary from 0 to 3 seconds.
Common mistakes
- Forgetting
awaitbefore an async call — returns a coroutine object instead of the result. - Calling blocking synchronous functions (like
time.sleep) inside a coroutine — this freezes the entire event loop. - Nesting
asyncio.run()inside another coroutine — causes aRuntimeError. - Not using
return_exceptions=Trueingather— silently swallows exceptions in concurrent tasks.
Variations
- Use
asyncio.to_thread()to offload blocking I/O to a thread pool without losing async benefits. - Use
asyncio.gather()for structured concurrency;asyncio.as_completed()processes results as they finish. - Third-party libraries like
triooranyiooffer alternative async runtimes with different philosophies.
Real-world use cases
- Web scraping hundreds of URLs concurrently — each fetch is an I/O wait, perfect for asyncio.
- Building a real-time dashboard that polls multiple APIs and databases at different intervals.
- Handling thousands of concurrent WebSocket connections in a chat server without per-connection threads.
Key takeaways
asyncioenables single-threaded concurrent I/O via coroutines and an event loop.- Use
async defto define coroutines andawaitto pause until an awaitable completes. - Create tasks with
asyncio.create_task()and run them concurrently withasyncio.gather(). - Always
awaitasync calls — missingawaitreturns a coroutine object, not data. - Do not use blocking synchronous functions inside coroutines; use
asyncio.to_thread()instead. asyncio.run(main())starts the event loop — call it exactly once per script.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.