The Event Loop Mental Model
Asyncio concurrency confuses people who already know threads, because it looks like parallelism from the outside but works on a completely different principle underneath.
Every other page in this section - coroutines, tasks, gather, structured concurrency, sync/async bridging - is really one mechanism viewed from a different angle: a single scheduler running one piece of Python code at a time, switching to another piece exactly when the current one says it has nothing useful to do right now.
Understanding that scheduler, not memorizing the API surface, is what makes asyncio.gather versus asyncio.TaskGroup versus asyncio.to_thread feel like one coherent idea instead of three unrelated tools to look up.
Summary
- Asyncio achieves concurrency on a single OS thread by running exactly one coroutine's code at a time and switching to another whenever the current one is waiting on something external, not by executing code in parallel.
- Insight: Misreading this model leads to two opposite mistakes - expecting CPU parallelism asyncio never provides, or writing a synchronous-looking function that silently blocks every other coroutine sharing the loop.
- Key Concepts: event loop, coroutine, awaitable, Task, cooperative scheduling, structured concurrency.
- When to Use: I/O-bound work with many concurrent waits - network calls, database queries, file operations - where the cost is mostly waiting, not computing.
- Limitations/Trade-offs: Asyncio does not parallelize CPU-bound work, requires every library in the call chain to cooperate (a single blocking call stalls the whole loop), and makes control flow harder to reason about than straight-line synchronous code.
- Related Topics: threading, multiprocessing, structured concurrency, the Global Interpreter Lock (GIL).
Foundations
A coroutine is what you get back when you call an async def function - not the result of running it, but a paused function object sitting at its very first line, waiting to be driven forward.
Nothing inside a coroutine actually executes until something awaits it or schedules it as a Task.
The event loop is the piece that does the driving: it holds a queue of coroutines and callbacks that are ready to run, executes one until it hits an await on something not yet finished, and then moves to the next ready item in the queue.
await is the exact hand-off point - it means "I have nothing useful to do until this finishes, so let something else run in the meantime," which is the entire mechanism cooperative scheduling rests on.
A useful analogy is a single chef running several pots on one stove: the chef stirs pot A, then while pot A simmers unattended the chef moves to pot B, then to pot C, always doing exactly one thing at any instant but never standing idle waiting for a single pot to finish.
That is fundamentally different from hiring three chefs (threads) who can genuinely stir three pots at the same moment, or renting three kitchens (processes) that do not even share a stove.
Asyncio is the one-chef model: efficient because the chef is never idle while something else could progress, but still bounded by there being exactly one chef doing exactly one thing at a time.
Mechanics & Interactions
The loop's ready queue is the mechanism that turns "many pending waits" into "high throughput on one thread": a coroutine awaiting a socket read doesn't occupy the loop while the network is silent, it registers interest and steps aside, and the loop spends that time running other coroutines whose data has already arrived.
A Task is what wraps a coroutine so the loop can track and resume it independently - asyncio.create_task schedules a coroutine to run on the loop without the caller awaiting it immediately, which is what makes concurrent execution of multiple coroutines possible in the first place.
This is also where the two ways of running several coroutines together genuinely diverge, and it's a common source of surprise: asyncio.gather does not cancel the other awaitables when one of them raises - by default it immediately propagates that first exception while the rest keep running in the background, unwatched, unless the caller does something about it.
asyncio.TaskGroup (3.11+) exists specifically to close that gap: it ties every child task's lifetime to the enclosing async with block, and if one child raises, the group actively cancels its siblings before the block exits - a real behavioral difference, not just a nicer syntax over the same guarantee.
"Blocking the loop" is the single most consequential mental model failure in asyncio code: any synchronous call that doesn't yield - a tight CPU loop, a blocking file read, a synchronous DB driver - occupies the one chef entirely, so every other coroutine scheduled on that loop stalls for exactly as long as that call takes, not just the coroutine that made it.
import asyncio
async def blocks_everyone() -> None:
total = 0
for i in range(50_000_000): # pure CPU, never yields
total += i
# every other coroutine on this loop waited the whole time
async def cooperates() -> None:
await asyncio.to_thread(lambda: sum(range(50_000_000)))
# CPU work ran on a worker thread; the loop kept serving othersSync/async bridging - asyncio.to_thread, loop.run_in_executor, asyncio.run - is the mechanism for crossing between "code the loop is driving" and "code that cannot cooperate," in both directions: offloading a blocking call to a thread pool so the loop stays free, or entering the loop at all from ordinary synchronous code via asyncio.run.
Advanced Considerations & Applications
A subtlety that trips up experienced engineers moving from threads to asyncio: there is exactly one event loop per thread, asyncio.run creates one, runs it to completion, and closes it, and you cannot nest a second asyncio.run call inside a running loop - servers instead keep one long-lived loop for the process's lifetime rather than creating one per request.
The loop's scheduling fairness also depends entirely on every coroutine actually yielding at reasonable intervals; a well-behaved async codebase is really a codebase where every I/O-adjacent operation is either natively async or explicitly bridged to a thread, because the loop cannot preempt a coroutine that refuses to await.
Alternative event loop implementations exist for the same scheduling contract with different performance characteristics - uvloop, for instance, replaces the default pure-Python loop with a libuv-backed one for lower-latency I/O, without changing anything about the coroutine/Task/await model described here.
The Global Interpreter Lock still matters inside a single asyncio loop: even on the experimental free-threaded CPython build introduced in 3.13 and continued in 3.14, asyncio's contract of "one coroutine's bytecode runs at a time on this loop" is unchanged - free-threading affects true multi-core parallelism of separate OS threads, not the cooperative single-loop scheduling model itself.
Observability matters more in asyncio than in synchronous code precisely because failures are scheduling failures, not just logic failures: asyncio debug mode (PYTHONASYNCIODEBUG=1, or asyncio.run(main(), debug=True)) surfaces slow callbacks and never-awaited coroutines that would otherwise fail silently.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
Native async/await coroutines | Cheap to schedule, thousands of concurrent waits on one thread | Useless for CPU-bound work; every dependency must cooperate | High-fan-out I/O - APIs, DB calls, network fetches |
threading | True OS-level concurrency for blocking calls; simple mental model per call | GIL still serializes Python bytecode; costs more memory per worker | Bridging one or two blocking libraries into an async app |
multiprocessing | Real parallel CPU execution across cores | Heavy process startup cost, no shared memory, pickling overhead | CPU-bound work - parsing, compression, numeric batches |
asyncio.to_thread / run_in_executor | Keeps the loop responsive while a blocking call runs elsewhere | Adds a thread-pool hop; still not true CPU parallelism | Isolated blocking calls inside an otherwise async codebase |
Common Misconceptions
- "asyncio runs my code in parallel." It runs one coroutine's code at a time on one thread; concurrency comes from switching at
awaitpoints while something else waits, not from simultaneous execution. - "gather cancels the other tasks when one fails." By default it does not - the remaining awaitables keep running unless you handle them explicitly;
TaskGroupis the tool that actually cancels siblings on failure. - "Creating a Task and moving on is fine - it'll finish on its own." An orphaned task with no reference and nothing awaiting it can be silently garbage collected mid-flight or simply never checked for exceptions, which is exactly the leak structured concurrency (
TaskGroup) closes off. - "await asyncio.sleep(0) doesn't really do anything." It is a genuine yield point - it hands control back to the loop for one iteration even though no real time passes, which is sometimes used deliberately to let other coroutines make progress.
- "Making a function async automatically makes it faster." It only helps when the function spends real time waiting on I/O; wrapping CPU-bound work in
async defwithout offloading it to a thread or process changes nothing about how long it takes and can make the whole loop worse by blocking it.
FAQs
What exactly is the event loop?
The scheduler that holds a queue of coroutines and callbacks ready to run, executes one at a time, and switches to the next whenever the current one awaits something not yet finished.
Is asyncio actually parallel?
No. It is concurrent on a single thread - one coroutine's Python code runs at any instant, and progress on others happens by interleaving at await points, not by running simultaneously.
What is the difference between a coroutine, a Task, and a Future?
A coroutine is a paused function object that does nothing until awaited or scheduled. A Task wraps a coroutine so the loop can run and track it independently of the caller. A Future is the lower-level primitive representing an eventual result that Tasks are built on.
How does await actually hand control back to the loop?
When a coroutine reaches an await on something not yet ready, it suspends at that point and returns control to the loop, which then runs whichever other ready coroutine or callback is next in its queue.
Why can I only have one event loop per thread?
The loop owns the thread's scheduling for as long as it runs; a second loop on the same thread would have no way to interleave with the first, so asyncio enforces one active loop per thread and asyncio.run will not nest inside an already-running loop.
What is the real difference between gather and TaskGroup?
gather runs awaitables concurrently and, by default, lets the others keep running in the background if one raises. TaskGroup ties every child's lifetime to its async with block and actively cancels the remaining children when one fails, giving stronger structured-concurrency guarantees.
What does "blocking the event loop" actually mean?
It means running a synchronous operation inside a coroutine that never yields - a CPU-bound loop, a blocking file or DB call - which occupies the loop entirely and stalls every other coroutine scheduled on it for the full duration of that call.
When should I reach for asyncio.to_thread instead of just making something async?
When the operation is provided by a library that has no async version - a blocking DB driver, a CPU-bound computation, a legacy synchronous API - to_thread moves it off the loop's thread so the loop keeps serving other coroutines while it runs.
Does the GIL still matter in asyncio code?
Yes - within a single loop, one coroutine's bytecode executes at a time regardless of GIL status, because asyncio's cooperative model already serializes execution; even on the free-threaded CPython build (experimental since 3.13, continuing in 3.14), that scheduling contract for a single loop does not change.
What is uvloop and why would I use it?
It is a drop-in replacement event loop implementation backed by libuv, offering lower latency and higher throughput for I/O-heavy workloads than the default pure-Python loop, without changing the coroutine/Task/await programming model.
How do I debug scheduling problems instead of logic bugs?
Enable asyncio debug mode (PYTHONASYNCIODEBUG=1 or asyncio.run(main(), debug=True)), which surfaces slow callbacks, never-awaited coroutines, and other scheduling-level issues that a normal traceback would not show.
Should I rewrite my whole codebase to asyncio for performance?
Only if the bottleneck is time spent waiting on I/O with high concurrency; CPU-bound work gains nothing from asyncio alone and needs multiprocessing or a thread/process offload regardless of whether the surrounding code is async.
Why does a single un-awaited blocking call anywhere in the call chain matter so much?
Because the loop cannot preempt a coroutine mid-execution the way an OS scheduler preempts a thread - it only regains control at an await, so one function that never yields blocks the entire loop for its full duration, not just its own logical unit of work.
Related
- Asyncio Basics - hands-on coroutine, gather, and timeout examples
- Tasks & Gather - the Task API this scheduling model is built on
- Structured Concurrency -
TaskGroup's cancellation guarantees in depth - Sync/Async Bridging -
to_threadandrun_in_executorin practice - Debugging Async Code - diagnosing stalls and orphaned tasks
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.