All articles
October 11, 2025 7 min read

asyncio: asynchronous Python without the weight of threads or processes

For I/O-bound work — network calls, databases, LLM APIs — asyncio runs thousands of tasks concurrently on a single thread. Here's the mental model: coroutines, the event loop, cooperative multitasking, and gather.

Written forEngineering
PythonasyncioConcurrency

Python gives you three ways to do more than one thing at once: threading, multiprocessing, and asyncio. For I/O-bound work — waiting on network calls, databases, or LLM APIs — asyncio is the lightweight one. It runs thousands of concurrent tasks on a single thread, with no OS-level threads to schedule and no processes to spin up. If your program spends most of its time waiting, this is the tool. Four ideas make the whole model click.

BlockingABCone after another ≈ 6sAsyncABCoverlap the waiting ≈ 2s
Three I/O tasks run one after another take about six seconds; with asyncio they overlap their waiting on one thread and finish together in about two.

1. Calling an async def gives you a coroutine

This is the first surprise. Calling a normal function runs it. Calling an async def function does not run it — it returns a coroutine object, a paused computation that does nothing until something drives it. You start it with await (or by scheduling it on the event loop). Forgetting this is the classic beginner bug: you call the function, nothing happens, and there's no error.

async def returns a coroutine, it doesn't run
import asyncio

async def greet(name):
    await asyncio.sleep(1)          # stand-in for a network call
    return f'hello {name}'

c = greet('ada')                    # does NOT run — c is a coroutine object
print(c)                            # <coroutine object greet at 0x...>

result = asyncio.run(greet('ada'))  # THIS actually runs it

2. The event loop runs the coroutines

A coroutine can't run itself; it needs a driver, and that driver is the event loop. The loop is a scheduler that keeps a set of tasks and runs them on one thread, handing control from one to the next. In practice you rarely touch the loop directly — asyncio.run() creates it, runs your top-level coroutine to completion, and cleans it up. Everything async happens inside that one running loop.

asyncio.run starts and manages the loop
async def main():
    print(await greet('ada'))

asyncio.run(main())   # creates the event loop, runs main() to completion, closes it

3. Cooperative multitasking: await yields control

Here's the heart of it, and the word that matters is cooperative. Threads are preemptive — the OS can interrupt one at any moment and switch to another, which is why threaded code needs locks. asyncio is different: a coroutine runs uninterrupted until it hits an await on something that isn't ready — a network response, a timer — and at that point it voluntarily hands control back to the event loop, which runs another coroutine while the first one waits. Tasks take turns, and they only switch at await points.

That's exactly why asyncio is lightweight — no thread overhead, and far fewer locks — but it's also its one sharp edge: if a coroutine does blocking work and never yields (a heavy CPU loop, or time.sleep() instead of asyncio.sleep()), it freezes the entire loop, because nothing can preempt it. The rule is simple: never block the loop, and use the async-aware version of anything that waits.

4. asyncio.gather runs coroutines concurrently

Awaiting coroutines one at a time is still sequential — three one-second calls take three seconds. asyncio.gather is how you get concurrency: it schedules many coroutines on the loop at once and waits for all of them, so their waiting overlaps. Three one-second waits finish in about one second, not three. This is the overlap the diagram shows, and it's the payoff of the whole model.

gather: schedule many at once, await them together
async def main():
    results = await asyncio.gather(
        greet('ada'),
        greet('linus'),
        greet('grace'),
    )
    print(results)   # ['hello ada', 'hello linus', 'hello grace']

asyncio.run(main())
# total time ≈ 1s, not 3s — the three sleeps overlap on one thread

Which of the three should you reach for?

  • asyncio — I/O-bound work with lots of concurrency: many network or API calls, sockets, streaming. One thread, thousands of tasks.
  • Threading — I/O-bound work where you're stuck with blocking libraries that have no async version.
  • Multiprocessing — CPU-bound work: crunching numbers across cores, bypassing the GIL. asyncio won't speed this up.

The one-line test: asyncio doesn't make your code compute faster — it makes it wait better. If your program is mostly waiting on other systems (as most LLM and web backends are), that's precisely the win you want.

asyncio isn't about doing many things at the same instant. It's about never sitting idle while you wait — one thread, taking turns, wasting no time.
Building something with LLMs?
I help teams ship GenAI that’s reliable and cost-efficient.
Let’s talk