PythonAsync I/O (asyncio)

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.

Python
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.

Warning
Never call `time.sleep()` (or any other blocking call — a synchronous `requests.get()`, a blocking file read, a tight CPU-bound loop) inside an `async def` function without offloading it. Because asyncio is single-threaded, a blocking call freezes the entire event loop — every other coroutine you're running concurrently stops making progress until the blocking call returns. This is the most common bug in async Python code, and it silently defeats the entire point of using asyncio.

Python
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.

Python
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

Tip
Reach for asyncio when you have a large number of independent, I/O-bound operations to run at once — scraping thousands of pages, hammering an API with many parallel requests, or handling thousands of simultaneous network connections in a server. Its low per-task overhead lets it scale to concurrency levels threads simply can't reach efficiently. For a small number of blocking calls, or when you're stuck using a library that has no async equivalent, plain threading is often simpler and just as effective.
Note
Neither asyncio nor threading speeds up CPU-bound work in CPython — both are still subject to the Global Interpreter Lock for actual Python bytecode execution. For CPU-heavy work, you want multiprocessing instead, which runs on genuinely separate processes with separate interpreters.
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