Async I/O with asyncio
When a program must handle massive numbers of small, I/O-heavy tasks, traditional threading begins to show its limits. Each thread carries memory overhead, and switching between thousands of them can slow a program down.
That’s where asyncio enters the picture.
asyncio is Python’s framework for asynchronous, event-driven programming. Instead of creating many threads, it runs all tasks inside one single thread, but lets those tasks cooperate by yielding control whenever they need to wait.
This model is perfect for programs that: - wait for network responses - handle thousands of client connections - coordinate APIs or microservices - manage background events or timers
The key ingredients are: - coroutines, defined with async def, which act like lightweight tasks - await, which pauses a coroutine so others can run - an event loop that schedules and coordinates all coroutines
Think of it as a well-organized single-lane road where every driver takes turns smoothly, rather than a chaotic multi-lane highway.
How Asyncio Works (Intuition First)
When a coroutine performs an operation that would normally block—like waiting for a network response—it does not freeze the entire program. Instead, it says: “I’m waiting; someone else can run now.”
await works with an awaitable operation such as asyncio.sleep. Calling a blocking synchronous function inside a coroutine would still block that event-loop thread.
While one coroutine pauses, another continues, and so on.
The event loop orchestrates all of this, switching between coroutines extremely quickly.
The result: true concurrency without threads, ideal for workloads that spend most of their time waiting.
Example: Running Multiple Asynchronous Tasks
Below is a simple but representative example.
It simulates several asynchronous “fetch” operations by using asyncio.sleep(), which behaves like a non-blocking network wait.
Before showing the code, note the structure: - async_fetch() is a coroutine that simulates an asynchronous task. - main() creates many such tasks and runs them together. - asyncio.gather() waits for all of them to finish. - asyncio.run() starts the event loop.
Save this program as async_demo.py and run it from a terminal. In a Jupyter notebook, an event loop is already running; replace the final asyncio.run(main()) with await main(). The list below holds coroutine objects; asyncio.gather schedules them and returns results in their original order.
Code Example
import asyncio
import random
import time
async def async_fetch(name):
"""Simulate asynchronous I/O with asyncio.sleep()."""
delay = 0.1 + random.random() * 0.2
await asyncio.sleep(delay) # Non-blocking delay
return f"OK {name} ({delay:.2f}s)"
async def main():
# Launch multiple coroutines at the same time
tasks = [async_fetch(f"task-{i}") for i in range(5)]
start = time.perf_counter()
# Run all tasks concurrently and collect their results
results = await asyncio.gather(*tasks)
for r in results:
print(r)
elapsed = time.perf_counter() - start
print(f"Total time ~ {elapsed:.2f}s (async I/O)")
# Start the event loop
asyncio.run(main())What’s Happening?
- All tasks run inside one thread
This example runs on one event-loop thread. Tasks can still interleave around await, so shared mutable state may require coordination.
- Coroutines pause themselves with
await
await asyncio.sleep() doesn’t block; it yields control back to the event loop.
- Other coroutines run while one waits
Tasks take rapid turns, creating smooth, efficient concurrency.
- The waits overlap
The example usually takes roughly the longest wait plus scheduling and printing overhead. Exact timings depend on the environment.
- Designed for thousands of I/O-bound tasks
Coroutines are extremely lightweight, enabling large-scale systems like web servers, chat apps, and async API clients.