Async I/O (asyncio)
Python offers three main ways to run work concurrently: threading, multiprocessing, and asyncio. Threading and multiprocessing rely on the operating system to juggle multiple OS-level threads or processes. Asyncio takes a completely different approach — it runs everything on a single thread, in a single process, and switches between tasks cooperatively at points the code itself chooses to yield. This is called cooperative concurrency, and the asyncio module is the standard library's implementation of it, built around coroutines, an event loop, and the async/await keywords.
Because there's only one thread involved, asyncio sidesteps most of the classic concurrency hazards — no race conditions from two threads mutating the same variable, no need for locks around shared state. The trade-off is that every piece of code sharing that thread must cooperate: if one task hogs the CPU or blocks synchronously, everything else waiting on the event loop stalls too.
The Event Loop, Conceptually
At the heart of asyncio is the event loop — a scheduler that keeps a list of pending coroutines and repeatedly asks: "which of you is ready to make progress right now?" When a coroutine hits an await on something that isn't ready yet (like a network response), it tells the event loop "I'm waiting, go run someone else," and the loop switches to another task. When the awaited operation completes, the loop resumes the original coroutine exactly where it left off.
You rarely create or manage the event loop by hand. The standard entry point is asyncio.run(), which creates a fresh event loop, runs your top-level coroutine to completion, and cleans the loop up afterward.
import asyncio
async def main():
print("Hello")
await asyncio.sleep(1)
print("World")
asyncio.run(main())Hello World
asyncio.run() should be called once, from your program's main entry point. Calling it from inside code that is already running inside an event loop (for example, from within a Jupyter notebook cell or another coroutine) raises a RuntimeError.
asyncio.sleep() vs time.sleep()
This is the single most important distinction to internalize when moving from synchronous to asynchronous Python. time.sleep() is a blocking call — it pauses the entire thread, doing nothing useful, for the full duration. asyncio.sleep() is a coroutine — awaiting it tells the event loop "I don't need to do anything for this long, feel free to run other tasks in the meantime," and control returns to the loop immediately.
import asyncio
import time
async def bad():
print("start")
time.sleep(2) # BLOCKS the entire event loop for 2 seconds
print("done")
async def good():
print("start")
await asyncio.sleep(2) # yields control back to the event loop
print("done")If you must call a genuinely blocking, synchronous function from async code (for example, a third-party library with no async version), run it in a thread pool with asyncio.to_thread() so it doesn't block the loop itself.
Running Coroutines Concurrently with gather()
Awaiting coroutines one after another is still sequential — each await waits for the previous one to fully finish before starting the next. To actually run multiple coroutines concurrently, schedule them together with asyncio.gather(), which starts all of them and waits for every one to complete, returning their results in order.
import asyncio
import time
async def fetch(name, delay):
print(f"{name}: starting")
await asyncio.sleep(delay)
print(f"{name}: done")
return f"{name} result"
async def main():
start = time.perf_counter()
results = await asyncio.gather(
fetch("A", 2),
fetch("B", 2),
fetch("C", 2),
)
elapsed = time.perf_counter() - start
print(results)
print(f"elapsed: {elapsed:.1f}s")
asyncio.run(main())A: starting B: starting C: starting A: done B: done C: done ['A result', 'B result', 'C result'] elapsed: 2.0s
All three "requests" started immediately and ran side by side, so the whole batch finished in roughly 2 seconds — not 6. That's the entire value proposition of asyncio in one example: many independent waits, overlapped instead of stacked.
asyncio vs Threading — When Does Each Win?
Both asyncio and threading are effective at hiding I/O wait time, since Python's threads release the GIL during blocking I/O just as asyncio yields during an await. The choice between them usually comes down to scale, code style, and ecosystem support.
Factor | asyncio | Threading |
|---|---|---|
Overhead per task | Very low — coroutines are cheap Python objects, thousands can run at once | Higher — each thread has real OS overhead; thousands of threads is impractical |
Best for | Many concurrent I/O-bound tasks: web scraping hundreds of URLs, many simultaneous API/database calls | A handful of I/O-bound tasks, or wrapping existing blocking/synchronous libraries |
Code style | Requires "async-aware" libraries throughout (aiohttp, asyncpg, etc.) and explicit await points | Works with any ordinary blocking library, no special async version needed |
Debugging | Single-threaded, so no race conditions on shared state, but stack traces across awaits can be less intuitive | Classic multithreading pitfalls apply — race conditions, need for locks around shared mutable state |
Summary
asyncio runs cooperative, single-threaded concurrency driven by an event loop
asyncio.run() is the standard entry point that creates the loop, runs your top-level coroutine, and tears the loop down
asyncio.sleep() yields control back to the loop; time.sleep() blocks the whole thread and everything scheduled on it — never mix the two
asyncio.gather() runs multiple coroutines concurrently and collects their results once all are done
asyncio shines with many concurrent I/O-bound tasks; threading is simpler for a handful of blocking calls; multiprocessing is what you need for CPU-bound parallelism