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 def returns a coroutine object, a description of work, not the work. Calling it runs nothing.
  • await pauses 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

  1. It keeps a queue of tasks ready to run or resume.
  2. It monitors events — sockets readable/writable, timers expiring, file operations finishing — using select / poll / epoll, without blocking.
  3. It runs a ready task until that task hits an await.
  4. At the await, the task is registered as waiting and control returns to the loop.
  5. When the awaited operation completes, the loop puts the coroutine back in the queue, to resume exactly where it paused.
  6. 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:

LayerWhat it is
CoroutineThe unit of async work from async def. The loop cannot schedule it directly.
TaskA coroutine wrapped so the loop can schedule and manage it — asyncio.create_task(). Supports cancel(), done(), result(), exception().
FutureA 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 await blocks 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 await points, so the race conditions that plague threaded code are largely absent.

Async vs threads vs processes

asynciothreadingmultiprocessing
Task typeI/O-boundCPU-bound with I/OPure computation
ParallelismNo (within a loop)Limited by the GILYes, true parallelism
OverheadLowModerateHigh
GILUnaffectedLimited by itBypasses it
SwitchingCooperative (light)Preemptive (OS)Preemptive (OS)
MemoryLowerModerateHigher
ComplexityModerateModerateHigher — needs IPC
FitsWeb servers, network apps, UIsConcurrent I/O plus some CPUCPU-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 / aiohttp dependencies 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