Python Async and the asyncio Event Loop
Definition
Async Python is concurrency without parallelism. async def defines a coroutine function, await marks a point where the coroutine hands control back to a single-threaded scheduler — the event loop — which runs something else until the awaited thing is ready.
The mental model in five lines:
async defreturns a coroutine object, a description of work, not the work. Calling it runs nothing.awaitpauses the current coroutine and yields to the event loop.- The event loop is a single-threaded scheduler. There is no parallelism — just efficient turn-taking, and turns only change at an
await. - Async helps when you are I/O-bound. It does nothing for CPU-bound work.
- One blocking call stalls everything. A
time.sleep()or a synchronous DB driver anywhere freezes the whole loop, not just that coroutine.
Core Ideas
async def and await
await only works inside async def, and only on awaitables — other coroutines, Future objects, or objects implementing __await__. Ordinary synchronous functions are not awaitable. To run a coroutine you need a loop: asyncio.run(coro).
The most common mistake is assuming await creates concurrency. It does not — awaiting in sequence is sequential:
# Slow — one at a time, despite being "async"
for url in urls:
await fetch(url)
# Fast — all in flight at once
await asyncio.gather(*(fetch(url) for url in urls))Concurrency comes from gather and tasks, not from await itself.
How the event loop works
- It keeps a queue of tasks ready to run or resume.
- It monitors events — sockets readable/writable, timers expiring, file operations finishing — using
select/poll/epoll, without blocking. - It runs a ready task until that task hits an
await. - At the
await, the task is registered as waiting and control returns to the loop. - When the awaited operation completes, the loop puts the coroutine back in the queue, to resume exactly where it paused.
- It repeats until nothing is left to run.
Implementations. The stdlib asyncio loop is written in Python and fine for most work. uvloop is a drop-in replacement written in Cython on top of libuv (the library behind Node.js) — noticeably faster for high-load network I/O. Install it and call uvloop.install() before asyncio.run().
Coroutines, tasks, futures
Three layers, often confused:
| Layer | What it is |
|---|---|
| Coroutine | The unit of async work from async def. The loop cannot schedule it directly. |
| Task | A coroutine wrapped so the loop can schedule and manage it — asyncio.create_task(). Supports cancel(), done(), result(), exception(). |
| Future | A placeholder for a result that hasn’t arrived. States: pending, running, done, cancelled. Supports add_done_callback(). |
Task is a subclass of Future. Awaiting either means waiting for it to become done and then taking its result or its exception.
Cooperative, not preemptive
Threads and processes use preemptive multitasking — the OS interrupts them whenever it likes. Async Python uses cooperative multitasking — a coroutine runs until it chooses to yield at an await.
What follows from that:
- No true parallelism inside one loop. A long CPU-bound stretch with no
awaitblocks every other coroutine. - High responsiveness for I/O — waiting time becomes someone else’s turn.
- Lower switching overhead than OS-level context switches.
- More predictable flow. Switches happen only at visible
awaitpoints, so the race conditions that plague threaded code are largely absent.
Async vs threads vs processes
| asyncio | threading | multiprocessing | |
|---|---|---|---|
| Task type | I/O-bound | CPU-bound with I/O | Pure computation |
| Parallelism | No (within a loop) | Limited by the GIL | Yes, true parallelism |
| Overhead | Low | Moderate | High |
| GIL | Unaffected | Limited by it | Bypasses it |
| Switching | Cooperative (light) | Preemptive (OS) | Preemptive (OS) |
| Memory | Lower | Moderate | Higher |
| Complexity | Moderate | Moderate | Higher — needs IPC |
| Fits | Web servers, network apps, UIs | Concurrent I/O plus some CPU | CPU-intensive work on many cores |
Rules of thumb: I/O-bound with high concurrency → asyncio. CPU-bound needing responsiveness → threads, mindful of the GIL. CPU-bound needing real speed → processes. Hybrids are normal — async for the network edge, multiprocessing for heavy background computation.
Why it exists, and where it stops
Threading and multiprocessing were Python’s original answers, but the Global Interpreter Lock and thread-management overhead cap both, especially for I/O-bound work where the CPU is idle anyway. Async is the alternative for exactly that case, and nothing else in Python matches its overhead there.
It is not a silver bullet: it won’t speed up CPU work, it doesn’t replace threads or processes, and a single blocking call undoes all of it.
Relationships
- Django — the Python web framework whose request cycle this concurrency model applies to
- Go — the contrasting model: real parallelism via goroutines and CSP, no GIL
- Database Transactions — concurrency control at the data layer, where a synchronous driver blocks the loop
- Observability — latency under concurrency is what you end up measuring
- Poetry — managing the
uvloop/aiohttpdependencies this pulls in - System Design — concurrency model as a scalability decision
- Software Engineering Practices — parent topic
References
- Python Async Programming: A Deep Dive into async/await and the Event Loop — Alex Jacobs, 2024-01-28