A coroutine is a function that can suspend its execution at an await point and resume later without blocking the OS thread—CPython implements this by saving the frame state (~200 bytes) onto a heap object rather than the stack, enabling millions of concurrent suspensions where millions of threads would exhaust memory and the scheduler.
<svg viewBox="0 0 760 470" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="12">
<rect width="760" height="470" fill="#0d1117"/>
<text x="380" y="32" fill="#e6edf3" font-size="13" text-anchor="middle" font-weight="bold">I/O-Bound Concurrency: Throughput vs Concurrent Tasks</text>
<!-- Axes -->
<line x1="80" y1="60" x2="80" y2="380" stroke="#8b949e" stroke-width="1.5"/>
<line x1="80" y1="380" x2="700" y2="380" stroke="#8b949e" stroke-width="1.5"/>
<text x="20" y="230" fill="#8b949e" font-size="11" transform="rotate(-90,20,230)">Throughput (req/s, normalized)</text>
<text x="390" y="418" fill="#8b949e" font-size="11" text-anchor="middle">Concurrent Tasks / Threads</text>
<!-- Y ticks -->
<line x1="78" y1="380" x2="82" y2="380" stroke="#8b949e"/>
<text x="70" y="384" fill="#8b949e" font-size="10" text-anchor="end">0</text>
<line x1="78" y1="300" x2="82" y2="300" stroke="#8b949e"/>
<text x="70" y="304" fill="#8b949e" font-size="10" text-anchor="end">0.25</text>
<line x1="78" y1="220" x2="82" y2="220" stroke="#8b949e"/>
<text x="70" y="224" fill="#8b949e" font-size="10" text-anchor="end">0.50</text>
<line x1="78" y1="140" x2="82" y2="140" stroke="#8b949e"/>
<text x="70" y="144" fill="#8b949e" font-size="10" text-anchor="end">0.75</text>
<line x1="78" y1="60" x2="82" y2="60" stroke="#8b949e"/>
<text x="70" y="64" fill="#8b949e" font-size="10" text-anchor="end">1.00</text>
<!-- X ticks: log10 scale: 1,10,100,1000,10000 -->
<line x1="80" y1="378" x2="80" y2="382" stroke="#8b949e"/>
<text x="80" y="394" fill="#8b949e" font-size="10" text-anchor="middle">1</text>
<line x1="238" y1="378" x2="238" y2="382" stroke="#8b949e"/>
<text x="238" y="394" fill="#8b949e" font-size="10" text-anchor="middle">10</text>
<line x1="390" y1="378" x2="390" y2="382" stroke="#8b949e"/>
<text x="390" y="394" fill="#8b949e" font-size="10" text-anchor="middle">100</text>
<line x1="545" y1="378" x2="545" y2="382" stroke="#8b949e"/>
<text x="545" y="394" fill="#8b949e" font-size="10" text-anchor="middle">1,000</text>
<line x1="700" y1="378" x2="700" y2="382" stroke="#8b949e"/>
<text x="700" y="394" fill="#8b949e" font-size="10" text-anchor="middle">10,000</text>
<!-- Threading curve: rises to ~100 threads peak, then collapses -->
<!-- normalized throughput: 1=0.05, 10=0.28, 100=0.88, 1000=0.60, 10000=0.28 -->
<!-- y = 380 - t*320: 364, 291, 99, 188, 291 -->
<polyline points="80,364 238,291 390,99 545,188 700,291"
fill="none" stroke="#f85149" stroke-width="2.5"/>
<text x="705" y="293" fill="#f85149" font-size="10">threads</text>
<!-- asyncio curve: rises steeply, plateaus at ~10000 -->
<!-- throughput: 1=0.05, 10=0.30, 100=0.75, 1000=0.95, 10000=0.90 -->
<!-- y: 364, 284, 140, 76, 92 -->
<polyline points="80,364 238,284 390,140 545,76 700,92"
fill="none" stroke="#388bfd" stroke-width="2.5"/>
<text x="705" y="90" fill="#388bfd" font-size="10">asyncio</text>
<!-- uvloop curve: ~33% higher than asyncio -->
<!-- throughput: 1=0.05, 10=0.35, 100=0.88, 1000=1.00, 10000=0.96 -->
<!-- y: 364, 268, 99, 60, 73 -->
<polyline points="80,364 238,268 390,99 545,60 700,73"
fill="none" stroke="#e3b341" stroke-width="2.5"/>
<text x="705" y="70" fill="#e3b341" font-size="10">uvloop</text>
<!-- 4-process asyncio reference line -->
<line x1="80" y1="315" x2="700" y2="315" stroke="#3fb950" stroke-dasharray="5,3" stroke-width="1.5"/>
<text x="705" y="317" fill="#3fb950" font-size="10">4-proc</text>
<!-- Context switch collapse annotation -->
<rect x="446" y="170" width="122" height="36" fill="#161b22" rx="3"/>
<text x="454" y="184" fill="#f85149" font-size="10">context switch</text>
<text x="454" y="197" fill="#f85149" font-size="10">overhead wins</text>
<!-- asyncio sweet spot annotation -->
<rect x="462" y="56" width="80" height="18" fill="#161b22" rx="3"/>
<text x="468" y="69" fill="#388bfd" font-size="10">10k+ coros OK</text>
<!-- Legend -->
<rect x="96" y="68" width="180" height="82" fill="#161b22" rx="4"/>
<line x1="106" y1="84" x2="134" y2="84" stroke="#f85149" stroke-width="2.5"/>
<text x="140" y="88" fill="#e6edf3" font-size="11">threading.Thread</text>
<line x1="106" y1="102" x2="134" y2="102" stroke="#388bfd" stroke-width="2.5"/>
<text x="140" y="106" fill="#e6edf3" font-size="11">asyncio (CPython)</text>
<line x1="106" y1="120" x2="134" y2="120" stroke="#e3b341" stroke-width="2.5"/>
<text x="140" y="124" fill="#e6edf3" font-size="11">uvloop (libuv)</text>
<line x1="106" y1="138" x2="134" y2="138" stroke="#3fb950" stroke-dasharray="5,3" stroke-width="1.5"/>
<text x="140" y="142" fill="#e6edf3" font-size="11">4-process asyncio</text>
</svg>
A Python coroutine object costs ~200 bytes of heap memory versus the ~8 KB default stack allocation for a threading.Thread, meaning 1 million concurrent coroutines consume 200 MB while 1 million threads would require 8 GB of stack plus kernel overhead—a 40× memory advantage that makes coroutine-based concurrency the only practical model at the scales modern web services require. The heap object stores a PyFrameObject snapshot: local variables, the bytecode instruction pointer, the evaluation stack, and a reference to the enclosing generator frame. When an await expression suspends the coroutine, CPython writes the current frame state into this heap object and returns control to the event loop—resuming later by restoring the frame and re-entering the bytecode evaluator at the saved instruction pointer. No OS kernel call, no TLB flush, no register file save: the switch costs ~100 ns versus ~5 µs for an OS thread context switch, a 50× improvement.
The asyncio event loop multiplexes thousands of I/O-bound coroutines onto one OS thread by delegating all I/O readiness detection to the kernel via epoll (Linux), kqueue (macOS), or IOCP (Windows), each of which returns only the file descriptors that are ready—making the loop's per-iteration cost O(events) rather than the O(N) scan that select() imposes with its hard 1,024 fd limit. The SelectorEventLoop's _run_once() method calls epoll_wait() with a computed timeout, drains the ready callbacks, and advances all pending coroutines in turn. Dispatch overhead per callback is ~1 µs in pure CPython; uvloop, which wraps libuv, reduces this to ~0.3 µs by moving the I/O poller and timer heap into C, yielding ~90,000 HTTP requests/s versus ~50,000 for the pure-Python asyncio loop in aiohttp benchmarks at 1,000 concurrent connections.
Every await expression is a cooperative yield point: a coroutine that never awaits will hold the event loop hostage for its entire duration, starving all other tasks, because asyncio uses cooperative rather than preemptive scheduling—the programmer, not the runtime, decides where execution yields. asyncio.sleep(0) is the canonical yield-to-event-loop idiom, costing ~1 µs of round-trip dispatch overhead; CPU-bound loops inside async functions must be punctuated with await asyncio.sleep(0) every 1–10 ms or offloaded to loop.run_in_executor(), which runs the function in a ThreadPoolExecutor and releases the GIL. asyncio.TaskGroup (Python 3.11+) enforces structured concurrency: if any child task raises, all siblings are cancelled before the exception propagates, eliminating the fire-and-forget task leak that plagued asyncio.gather in earlier versions.
Creating a task via asyncio.create_task costs ~7 µs—wrapping the coroutine in a Task object, scheduling it on the event loop's ready queue, and registering a done callback—which means the practical lower bound for async work is tasks whose I/O wait exceeds 7 µs, covering every network call but excluding in-process queues where Trio or anyio nurseries add less overhead. The Task object subclasses Future, holds a strong reference to the coroutine, and attaches to the running event loop via a weakref. Cancellation sends CancelledError into the coroutine at its current await point via throw(); catching it requires a re-raise rather than suppression to avoid breaking structured cancellation chains in Python 3.11+ TaskGroups, where a suppressed cancellation would leave the parent TaskGroup waiting indefinitely for a task that considers itself done.
A threading.Thread peaks in I/O throughput at ~100–200 threads, then regresses as OS scheduling overhead, lock contention, and GIL handoff compound: at 1,000 threads, aggregate scheduling overhead across ~5 µs context switches leaves less than half the CPU time for actual I/O work, whereas 1,000 asyncio coroutines share one OS thread and switch in ~100 ns each. This crossover—roughly 200 threads—is the regime where asyncio was designed to operate: hundreds to tens of thousands of concurrent network connections, each spending >99% of its lifetime waiting for remote I/O. For workloads with shorter waits (in-memory queues, local Unix sockets), asyncio's ~1 µs dispatch overhead becomes a measurable fraction of wait time and threading's simpler scheduling recovers ground.
The correct architecture for a Python service that needs both I/O parallelism and CPU parallelism is a process-per-core pool of asyncio event loops: each process runs a uvloop event loop handling tens of thousands of coroutines, and CPU-bound tasks are dispatched to loop.run_in_executor() or a separate ProcessPoolExecutor, separating the two orthogonal concerns that neither asyncio alone nor threading alone can satisfy. Gunicorn with the uvicorn worker (ASGI) implements exactly this pattern: N worker processes each running a single asyncio event loop, fronted by Nginx as a reverse proxy. Frameworks built on this stack—FastAPI, Starlette, Litestar—achieve 50,000–90,000 HTTP requests/s per worker on I/O-bound handlers. Adding more processes scales linearly until the bottleneck shifts from the Python event loop to the downstream database or network.
| Concurrency Model | Max Practical Tasks | Switch Cost | Memory/Task | CPU-bound? |
|---|---|---|---|---|
| asyncio (CPython) | ~100,000 | ~100 ns | ~200 bytes | No (blocks loop) |
| uvloop | ~100,000 | ~60 ns | ~200 bytes | No (blocks loop) |
| threading.Thread | ~500–1,000 | ~5 µs | ~8 KB stack | No (GIL) |
| multiprocessing | ~N cores | N/A | ~20 MB | Yes |
| asyncio + executor | ~100,000 | mixed | mixed | Yes (in thread/proc) |
[asyncio EVENT LOOP — one iteration of _run_once()]
Compute timeout (next scheduled callback - now)
|
+---> epoll_wait(fds, timeout) [O(1) per ready event]
| |
| Returns list of ready (fd, event) pairs
|
+---> For each ready fd: schedule callback on _ready queue
|
+---> Drain _ready queue (FIFO):
| |
| +---> callback() or coroutine.send(None)
| | |
| | Coroutine hits 'await':
| | +---> registers fd/timer with loop
| | +---> suspends, returns to loop (~100 ns)
| | Coroutine hits 'return':
| | +---> Task marks done, fires callbacks
| |
| +---> next callback ...
|
+---> Check cancelled tasks, fire done callbacks
+---> Loop back to epoll_wait
Read coroutines through a frame suspension lens rather than a lightweight thread lens: a coroutine is not a thread that happens to be cheap—it is a heap-allocated frame snapshot that the event loop resumes by restoring saved state and calling into the bytecode evaluator at the saved instruction pointer. The difference matters because threads are preempted by the OS at arbitrary points and must protect every shared mutation with a lock, while coroutines are preempted only at explicit await points and can mutate shared state between awaits without synchronization. This makes coroutine code easier to reason about than threaded code but requires discipline: every CPU-intensive section between awaits is a potential starvation hazard, and every await is an implicit "this state may have changed" boundary that the programmer must account for.
Explore 500+ Semiconductor & AI Topics
From EUV lithography to CUDA optimization — search the full knowledge base or chat with our AI assistant.