Python’s asyncio has a reputation that divides people who use it. Some find it powerful and adequate once you learn the conventions. Others find it fundamentally uncomfortable, a feeling that persists even after reading the documentation cover-to-cover. That discomfort has a structural explanation, and this deep-dive into reinventing asyncio makes it concrete.
The short version: asyncio was not designed once. It was designed four times, each layer added on top of the previous one, and all four layers are still visible in the current API.
The Layers Problem
Start at the bottom. Python’s asyncio began with an event loop and callbacks, the same model Node.js made famous. You schedule work by registering callback functions against I/O events or timers. This works, but callback hell is real, and the code structure inverts the logical flow of what you are trying to express.
# The callback layer, still present in asyncio today
loop = asyncio.get_event_loop()
loop.call_later(1.0, my_callback)
loop.run_forever()
On top of callbacks came Future objects, a way to represent a value that will exist eventually. Futures let you attach callbacks with .add_done_callback(), which is only marginally better than raw callbacks because the inversion of control is still there.
Then came coroutines with yield from in Python 3.4, and eventually async/await in Python 3.5. Coroutines are genuinely better. They restore the sequential appearance of code even when the underlying execution is interleaved. The await keyword suspends the current coroutine until the awaitable completes, and the event loop orchestrates the rest.
async def fetch_data(url: str) -> bytes:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.read()
Finally, Task wraps a coroutine and schedules it on the event loop, giving you a handle to cancel it, check its status, or await its result.
The problem is that all four layers are public API. The asyncio documentation exposes loop.call_soon(), loop.call_later(), asyncio.Future, asyncio.coroutine (now deprecated), async/await, and asyncio.Task. Each layer made sense when it was added. Together, they form an API surface that requires knowing which layer applies to your situation, and those layers have subtly different semantics around cancellation, exception propagation, and lifecycle management.
Bob Nystrom’s “What Color is Your Function?” describes the fundamental problem: async functions and sync functions are different colors, and you cannot call a colored function from a non-matching context without paying a cost. Asyncio does not eliminate this problem. It accepts it and hands you tools to manage it. Whether that is a reasonable trade-off depends on what you are building and how much of the codebase is async.
Cancellation Is Still Difficult
Cancellation is where asyncio’s layered design causes the most practical pain. When you cancel a Task, asyncio injects a CancelledError at the next await point. This is better than abrupt termination, but the exception propagation rules are tricky.
Before Python 3.8, CancelledError was a subclass of Exception, which meant a bare except Exception clause would catch and suppress a cancellation. This was a frequent source of bugs. Python 3.8 fixed this by making CancelledError a subclass of BaseException instead.
# This used to silently eat cancellations pre-3.8
async def risky():
try:
await asyncio.sleep(10)
except Exception:
pass # CancelledError swallowed here in Python < 3.8
Python 3.11 added asyncio.timeout(), which replaced the older pattern of wrapping tasks with asyncio.wait_for() and hoping the timeout behavior matched your expectations. The new API is cleaner:
# Python 3.11+
async def fetch_with_timeout(url: str) -> bytes:
async with asyncio.timeout(5.0):
return await fetch_data(url)
But cancellation still has edge cases. If you catch CancelledError to do cleanup and then raise a different exception, the task is no longer treated as cancelled. If you shield a coroutine from cancellation with asyncio.shield(), the shield only applies to the outer task; the inner coroutine can still be collected by the garbage collector if nothing holds a reference. Structured cleanup requires discipline that the API does not enforce.
Python 3.11 TaskGroups and the Structured Concurrency Retrofit
Python 3.11 introduced TaskGroup alongside ExceptionGroup (PEP 654), which together represent the biggest ergonomic improvement asyncio has received in years. Before this, spawning multiple concurrent tasks and waiting for all of them required manual bookkeeping:
# Pre-3.11 pattern
tasks = [asyncio.create_task(worker(i)) for i in range(5)]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Now inspect results to see which failed
The gather() approach has awkward error handling. If one task raises an exception and return_exceptions=False, the other tasks keep running but you have no clean way to cancel them.
TaskGroup fixes this:
# Python 3.11+
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(worker(1))
task2 = tg.create_task(worker(2))
# Both tasks are done here; exceptions are collected into ExceptionGroup
When any task in the group raises, the others are cancelled. When the context manager exits, you have either all results or an ExceptionGroup containing all the exceptions that were raised. This is genuinely good. It is also Trio’s model, implemented in the standard library seven years after Trio demonstrated it.
Trio’s Argument
Trio was released by Nathaniel J. Smith in 2017 with a clear thesis: structured concurrency is not an optimization for async code, it is the correct model for reasoning about concurrent programs. Nathaniel’s “Notes on structured concurrency, or: Go statement considered harmful” makes the case that spawning a task that outlives its creating scope is the async equivalent of the goto statement: it creates control flow that is difficult to reason about statically.
Trio’s answer is nurseries. Every task is created inside a nursery, and the nursery does not exit until all tasks inside it have finished. The scope of a task’s lifetime is always visible in the code structure.
import trio
async def main():
async with trio.open_nursery() as nursery:
nursery.start_soon(worker, 1)
nursery.start_soon(worker, 2)
# Here, both workers are guaranteed done
Trio also treats cancellation as a first-class primitive rather than an error-propagation mechanism. Cancel scopes are explicit objects with explicit deadlines, and their behavior is predictable across nesting levels. There is no equivalent of asyncio’s shield() complexity because the model does not require it.
The contrast is instructive. Trio was designed for one thing: safe, predictable concurrent I/O. It has no callback layer, no explicit Future objects in the public API, no loop.run_until_complete(). The API is smaller and the contracts are stronger.
What a Reimplementation Actually Needs to Address
The baro.dev post on reinventing asyncio works through what it would take to build asyncio from scratch today, informed by everything learned since 2014. The core observations hold up to scrutiny.
Performance is not the primary issue. uvloop wraps libuv in a Cython extension and replaces asyncio’s default event loop, delivering roughly 2-4x throughput improvement on I/O-bound benchmarks. If raw performance were the main problem, uvloop largely solves it. The issues are in the programming model.
A ground-up design would need to commit to structured concurrency from the start. That means nurseries or TaskGroup-equivalents as the only way to create tasks, cancel scopes as the primary mechanism for timeouts and cancellation, and exception propagation that is coherent rather than historical.
It would also need to take a position on synchronization primitives. Asyncio has Lock, Event, Semaphore, Condition, and Queue, all of which are reasonable, but their interaction with cancellation is not always obvious. Trio’s primitives are designed with explicit cancellation semantics at every point.
The protocol layer is another area where a reimplementation could simplify. Asyncio’s transport/protocol split made sense when callbacks were the primary abstraction. With coroutines, a streaming interface built directly on async for and async with is more natural. Libraries like AnyIO already provide this as an abstraction layer over both asyncio and Trio, and its design decisions are informative.
Why Asyncio Stays
None of this means asyncio is going away or that you should stop using it. The standard library position matters enormously. Every framework that targets CPython can depend on asyncio. FastAPI, aiohttp, Starlette, and dozens of other libraries are built on it. The ecosystem is large and reasonably mature.
Python 3.11 and 3.12 brought real improvements: TaskGroup, asyncio.timeout(), ExceptionGroup, and better error messages when you misuse the API. The gap between asyncio and Trio in terms of safety has narrowed. For code that uses TaskGroup consistently and avoids the low-level callback and Future APIs, the experience is acceptable.
AnyIO provides a meaningful middle ground. It exposes a structured concurrency API inspired by Trio and runs on top of either asyncio or Trio as a backend. Code written against AnyIO gets nursery-style task management and predictable cancel scopes without committing to a specific runtime.
import anyio
async def main():
async with anyio.create_task_group() as tg:
tg.start_soon(worker, 1)
tg.start_soon(worker, 2)
anyio.run(main)
The discomfort with asyncio is not irrational and it is not just unfamiliarity. It reflects a real tension between the API’s historical layers and the programming model that structured concurrency research shows is correct. The improvements in recent Python versions are genuine progress; they are also evidence that the original design required retrofitting. Trio demonstrates what the destination looks like. Asyncio is still finding its way there.