Async/Await is a Plague: Part 2 Infection
  • Python
  • Concurrency

Async/Await is a Plague: Part 2 Infection

Preface

In Part 1 we went looking for the roots. We saw the concurrency crisis that made threads feel too heavy, watched the industry move scheduling up the stack into an event loop, and then rebuilt that loop ourselves out of nothing but Python generators. By the end we had a fully non-blocking server written as plain, top-to-bottom code, and we proved that this little engine is async/await: the coroutine is our generator, await is our yield from, the runtime's loop is our Loop with epoll bolted on. We left off admiring how beautiful it all looked, and I promised you that beauty was the trick.

This is where I make good on that promise.


Async/Await is a leaky abstraction

The thesis of this part is simple to state and uncomfortable to accept: async/await is a leaky abstraction. The whole pitch of the pattern is that you get to write sequential-looking code while a runtime quietly handles the messy reality of suspension and resumption underneath. But that promise only half-holds. The abstraction leaks, and it leaks in a very specific, very damaging way: whether a function suspends is not an implementation detail it can hide. The moment a function deep in your call stack needs to await, that fact has to be spelled out at every single layer above it, all the way up. The async keyword is the leak made visible, a label the abstraction forces you to paint on every function that so much as touches an awaiting one.

Leaky abstraction. Joel Spolsky's Law of Leaky Abstractions says that every non-trivial abstraction is, to some degree, leaky: it eventually forces you to understand and account for the details it was supposed to hide. async/await is a textbook case. It promises to hide the event loop, yet the loop's central demand, tell me exactly which functions can suspend, leaks straight back out into your type signatures.

That leak is the source of everything that follows. In this part we are going to:

  • Locate the flaw in the abstraction itself. Before blaming the symptoms, we'll pin down why the abstraction leaks at all. Because the event loop schedules cooperatively, it can only switch tasks at the points a function explicitly hands control back, so every function is made responsible for signalling those points itself: await is that signal, the place where the function tells the loop you may suspend me here and go run something else. Suddenly ordinary code has to know about the scheduler and mark where it is willing to yield, and its willingness to suspend can no longer live quietly inside the body, it surfaces in the signature as async. That is the original sin: scheduling is the event loop's job, yet the abstraction pushes it onto the functions, forcing them to care about a concern that was never their business, and every other problem in this part descends from it.

  • Trace how the infection spreads. We'll watch a single await introduced in one low-level utility force a cascade of async annotations all the way up the call stack. And to be precise: the leaf that actually does the I/O earns its async honestly, no complaint there. The scandal is everything above the leaf, the pure-computation functions that just sum a list or transform a value, dragged into async purely because something far beneath them learned to suspend. The lesson is that suspension is transitive, but I/O-ness is not: a function that merely computes over data fetched elsewhere has no inherent relationship to the network, yet async forces it to advertise one anyway.

  • Follow the consequences into the rest of the language. Once functions are split into two colors, the damage doesn't stop at signatures; it seeps into the surrounding language until nearly every construct has to pick a side. The language is forced to grow a parallel vocabulary just to keep up: async for, async with, and friends, duplicate constructs that already existed in the synchronous world, simply re-minted in the async color. And we'll look at the genuine strangeness that appears when the two models collide, such as putting a yield inside an async def and discovering that an async generator is a different, more confusing beast than either of the two features that seem to compose it.

By the end, the innocent loop from Part 1 won't look so innocent anymore. Let's diagnose the plague.

await is where the infection begins

A function is the smallest unit we have for saying "here is a job, here is how to do it." You hand it inputs, it produces outputs and performs whatever effect it was written for, and that is the whole of its contract with the rest of the program. parse_headers parses headers. sum_prices sums prices. Nothing in that contract says a word about when the program as a whole should pause to let some other job run, because that is a question about the program, not about the job. Deciding when to pause is no more the function's concern than deciding which CPU core it runs on: both are things the system arranges around it, not things the function should have to spell out itself.

Cooperative multitasking, however, asks exactly that question, and it has to be answered somewhere. In a cooperatively scheduled system the runtime never seizes control on its own; it can only advance when the code currently running voluntarily hands control back. So a yield point, a moment where "switch to another task" is permitted to happen, has to exist. But look closely at what cooperative scheduling actually demands: only that such a point exist somewhere in the running code. It says nothing about where that point should live, and nothing about which function has to raise it. That part is left open. async/await then closes it with a decision entirely its own: it puts the yield point inside the function, spelled await, the function itself turning to the scheduler and volunteering a place to pause. The cooperative model handed us an open question; async/await answered it by reaching into the function and planting the signal there.

Let me be careful about what I am not saying. This is not an attack on cooperative concurrency. Cooperative scheduling is a perfectly reasonable design, and it is exactly the one we built by hand in Part 1: tasks run until they reach a point where they willingly hand control back, and only then does the scheduler switch to another. Nor is the problem that suspension points are explicit; a cooperative runtime cannot escape needing them. The problem is that placing them inside the function was never something the cooperative model forced on us. It is a choice async/await made and then imposed everywhere: the signal lives in the function's own body, in its own signature, at every layer of the stack. That single placement takes a scheduling decision, a property of the program's concurrency rather than of the computation, and welds it onto the source of the computation itself.

That is a category error. Deciding when to yield to the scheduler is the concern of whatever owns the concurrency, the event loop and the I/O boundary, not of a function that merely sums a list. Compare preemptive threads: the OS suspends a thread mid-instruction constantly, and not one line of the function has to mention it. The suspension is just as real; the function is simply not burdened with announcing it. async/await could not manage that same separation, so it conscripted every function into announcing it instead.

None of this makes the signal itself worthless. A cooperative runtime genuinely does need to know where it may suspend, so somewhere a yield point has to exist. The mistake is in the signal's location, not its existence. Later in this part we will pin down where it actually belongs: at the single point that touches the outside world, the I/O leaf, the one place with a real reason to talk to the scheduler. Put the signal there and the whole tower of pure functions above it goes back to minding its own business, none the wiser that it was ever suspended. That separation is the thing async/await throws away, and the rest of this part is the bill for throwing it away.

How the infection spreads up the call stack

Here is a perfectly ordinary stack of functions. A user has a few bank accounts; we want a one-line summary of how they're doing. Only the bottom function touches the outside world:

from decimal import Decimal

def fetch_balance(account_id: int) -> Decimal:
    # the ONLY function here that touches the outside world
    return bank_api.get(f"/accounts/{account_id}/balance")

def total_balance(account_ids: list[int]) -> Decimal:
    return sum(fetch_balance(a) for a in account_ids)      # just adds numbers

def is_solvent(account_ids: list[int]) -> bool:
    return total_balance(account_ids) >= 0                 # just compares to zero

def account_summary(user: User) -> str:
    status = "in the black" if is_solvent(user.account_ids) else "OVERDRAWN"
    return f"{user.name}: {status}"                        # just formats a string

Look at how honest this is. fetch_balance is the one function with any business knowing that a network exists; it is the leaf, and it earns whatever it costs. Everything above it is pure computation. total_balance adds up some Decimals. is_solvent compares a number to zero. account_summary interpolates a string. Not one of them has the faintest relationship to the bank, the socket, or the scheduler, and their signatures say so: they take plain data and return plain data. You could read this stack to a first-week intern and they would understand every line.

Now change exactly one thing. The synchronous bank_api is retired for a non-blocking HTTP client, so the leaf, the one function that genuinely does I/O, becomes a coroutine and awaits the call:

async def fetch_balance(account_id: int) -> Decimal:
    # STILL the only function that touches the outside world
    return await bank_api.get(f"/accounts/{account_id}/balance")

No complaint about that line. fetch_balance really does suspend, really does wait on the network, and marking it async is the truth. This is the leaf paying its honest due.

Then watch the truth turn into a lie as it climbs.

total_balance calls fetch_balance. To get a number out of a coroutine you must await it, and you may only await inside an async function, so total_balance is dragged into async too:

async def total_balance(account_ids: list[int]) -> Decimal:
    return sum([await fetch_balance(a) for a in account_ids])   # STILL just adds numbers

Its body still does one thing: add numbers. It has gained no new relationship to the network. But its signature now announces async, and every caller must now await it. So the same thing happens one floor up. is_solvent calls total_balance; it must await; it becomes async:

async def is_solvent(account_ids: list[int]) -> bool:
    return await total_balance(account_ids) >= 0               # STILL just compares to zero

Read that body again. It compares a number to zero. That is the entire computation. And it is now, by the type system's own testimony, an asynchronous, network-touching coroutine that callers must await. The infection does not stop until it runs out of stack:

async def account_summary(user: User) -> str:
    status = "in the black" if await is_solvent(user.account_ids) else "OVERDRAWN"
    return f"{user.name}: {status}"                            # STILL just formats a string

Look at the diff. One line of real behaviour changed, at the leaf, yet every function above it had to be repainted: four signatures stamped async, three awaits threaded through bodies that only sum, compare, and format. None of those upper functions does any I/O, and none suspends because it waits on anything, only because something beneath it does. Each is now forced to advertise "might suspend" in its signature, a fact about the leaf tattooed onto a function that compares a number to zero.

This is the asymmetry from the top of the part, made concrete: suspension is transitive, but I/O-ness is not. Suspension propagates up the whole chain, because consuming a suspending call means suspending yourself; I/O-ness propagates nowhere, is_solvent is no more a network operation than before. async conflates the two, spending one keyword on both "performs I/O" at the leaf and "calls something that eventually does" everywhere above.

And it spreads outward too. Once is_solvent is async it can no longer be called from ordinary synchronous code, a script, a test, a __repr__, because you cannot await outside an async function. One await at the bottom redraws the boundary of what the rest of your codebase may call directly.

A tax even on summing a list. total_balance originally summed a generator expression, but sum(await fetch_balance(a) for a in account_ids) builds an async generator that sum cannot consume (TypeError: 'async_generator' object is not iterable), forcing the list-comprehension form sum([await fetch_balance(a) for a in account_ids]). Even summing a list is not spared, a first hint that the split reaches past signatures, which a later section takes up.

None of it bought us anything. The behaviour we wanted lives entirely in the leaf; the functions above compute exactly as before. They were simply conscripted, each made to declare a concern that was never theirs, the original sin from the previous section, metastasizing one frame at a time.

"But that's just badly layered code"

There is a fair objection to make here. The stack above is only infected because it interleaves I/O with computation, calling fetch_balance in the middle of a sum. Refactor it the disciplined way, I/O at the edge and pure logic in the core, and the problem seems to dissolve:

async def account_summary(user: User) -> str:
    balances = [await fetch_balance(a) for a in user.account_ids]  # all I/O, up front
    status = "in the black" if is_solvent(balances) else "OVERDRAWN"
    return f"{user.name}: {status}"

def is_solvent(balances: list[Decimal]) -> bool:      # pure and sync again
    return total_balance(balances) >= 0

def total_balance(balances: list[Decimal]) -> Decimal:  # pure and sync again
    return sum(balances)

Fetch every balance first, pass a plain list[Decimal] inward, and total_balance and is_solvent shed their async entirely. This is good design, the functional-core / imperative-shell split, and it genuinely shrinks the infected set. But look closely at what it does and does not fix.

It relocates the boundary; it does not remove it. Something still fetches the balances, that orchestrator is still async, and everything above it is still colored. The infected set got smaller, but the rule that painted it, anything between an await and the top must be async, is untouched.

And it only works when you can fetch everything up front. That assumption collapses the moment the next fetch depends on the last result, which is most real code: pagination, graph traversal, "load the user, then their accounts, then each account's currency rate."

async def net_worth(user_id: int) -> Decimal:
    user = await fetch_user(user_id)                  # decides which accounts exist
    total = Decimal(0)
    for account_id in user.account_ids:
        account = await fetch_account(account_id)     # tells us the currency...
        rate = await fetch_fx_rate(account.currency)  # ...which decides THIS fetch
        total += account.balance * rate
    return total

You cannot hoist these fetches into one list at the edge. user.account_ids is unknown until fetch_user returns; account.currency is unknown until fetch_account returns. The I/O is braided into the control flow, and any helper you factor out of the loop is born async.

But the deepest point is the one the objection concedes by making it. "Keep I/O at the leaves, pass plain data inward, never await inside a reusable helper" is a discipline you now have to impose on your architecture to contain the coloring. "Just layer it better" is not a refutation of the tax. It is a description of paying it.

The consequences on other parts of the language

The split does not stop at def. The reason it keeps spreading is mechanical: many of the language's everyday constructs are just sugar over method calls. A for loop calls __next__. A with block calls __enter__ and __exit__. The moment one of those hidden methods needs to await, the construct that calls it is stuck, because a keyword cannot await. So Python could not let for and with quietly keep working across the boundary; it had to mint an async twin of each, with its own dunder protocol, its own keyword, and the same restriction the color always carries: usable only inside an async def. The two-coloring you saw at the function level reappears in the grammar itself.

Iteration: for becomes async for

A for loop is a thin wrapper over an iterator: it calls __next__ until StopIteration. That protocol is synchronous to the bone, __next__ must return the next value now. But suppose producing the next value is I/O, streaming an account's transactions from a paginated API where each page is a network round-trip. __next__ would have to await, and it cannot.

def transactions(account_id: int) -> Iterator[Txn]:
    page = 0
    while True:
        batch = fetch_page(account_id, page)   # blocking I/O per page
        if not batch:
            return
        yield from batch
        page += 1

for txn in transactions(account_id):
    print(txn)

Make fetch_page async and the protocol changes identity. A yield inside an async def no longer builds an ordinary generator; it builds an async generator, a different object that suspends between elements:

async def transactions(account_id: int) -> AsyncIterator[Txn]:
    page = 0
    while True:
        batch = await fetch_page(account_id, page)   # await per page
        if not batch:
            return
        for txn in batch:        # the sync version's `yield from batch` is a SyntaxError here
            yield txn
        page += 1

Now try to consume it exactly the way you always have:

for txn in transactions(account_id):   # TypeError: 'async_generator' object is not iterable
    print(txn)

It refuses outright. A for loop drives the synchronous iterator protocol, __iter__ and __next__, and an async generator does not implement it; it implements the parallel __aiter__/__anext__ (whose __anext__ returns an awaitable) and signals exhaustion with StopAsyncIteration instead of StopIteration.

You can still iterate it, you just have to drive that protocol by hand, awaiting each step yourself:

it = transactions(account_id).__aiter__()   # grab the async iterator
while True:
    try:
        txn = await it.__anext__()           # await the next element
    except StopAsyncIteration:               # its version of "we're done"
        break
    print(txn)

There is the whole problem in plain sight. Fetching the next element is an await, so the step that an ordinary for performs invisibly, "call __next__, stop on StopIteration", now has a suspension buried inside it, and a for loop expands to synchronous next() calls with nowhere to put that await. So the language adds a second loop construct whose only job is to expand to exactly the awaited __anext__ dance above:

async for txn in transactions(account_id):   # sugar for the while-loop above; only inside an async def
    print(txn)

async for is that manual loop, hidden behind a keyword. And the duplication does not stop at the loop statement: sum(t.amount for t in transactions(...)) no longer works either, because a generator expression over an async source is itself an async generator, which sum cannot consume. You fall back to async for with a manual accumulator, or an async comprehension [t async for t in ...]. Every tool that consumed iterables, sum, max, any, sorted, most of itertools, speaks only the synchronous protocol and is now off-limits for this data.

Resource management: with becomes async with

with is sugar too: it calls __enter__ on the way in and __exit__ on the way out. That exit is the natural home for teardown that must not be skipped, and very often the teardown is I/O: commit or roll back a transaction, return a pooled connection, flush a socket. So __exit__ wants to await, and again it cannot.

def transfer(db: DB, src: int, dst: int, amount: Decimal) -> None:
    with db.transaction() as tx:       # __enter__ opens, __exit__ commits/rolls back
        tx.debit(src, amount)
        tx.credit(dst, amount)

Make the driver non-blocking and begin/commit/rollback become network round-trips, so the setup and teardown have to await. Try to keep the block you already have:

with db.transaction() as tx:   # TypeError: 'Transaction' object does not support
    ...                        #            the context manager protocol

It refuses, for the same reason the for loop did. A with statement drives the synchronous protocol, __enter__ and __exit__, and a context manager that needs to await implements the parallel __aenter__/__aexit__ instead, each returning an awaitable. As with iteration, you can still run it, you just have to drive the protocol by hand and await the two steps yourself:

mgr = db.transaction()
tx = await mgr.__aenter__()               # await the setup
try:
    await tx.debit(src, amount)
    await tx.credit(dst, amount)
finally:
    await mgr.__aexit__(*sys.exc_info())  # await the teardown, passing along any error

That awaited entry and awaited exit are exactly what a plain with has no room for, so the language mints a second block statement whose whole job is to expand to the dance above:

async def transfer(db: DB, src: int, dst: int, amount: Decimal) -> None:
    async with db.transaction() as tx:   # sugar for the try/finally above; __aenter__/__aexit__
        await tx.debit(src, amount)
        await tx.credit(dst, amount)

And the sting is in what needs the await: the commit-or-rollback guarantee, the entire reason you reached for with in the first place, lives in __aexit__, the part that must suspend. So a plain with cannot silently stand in here; it does not merely run slower, it fails outright. The construct is cloned whole, same shape, same intent, new keyword, new dunders, usable only in colored functions.

The coda: async def + yield is a third thing

Look back at transactions. It was an async def that contained a yield. Individually you know both constructs cold: async def makes a coroutine, yield makes a generator. Put them in one function and you get neither, you get an async generator, a third species with its own protocol (__anext__, __aiter__, StopAsyncIteration, plus asend/athrow/aclose) that is interchangeable with neither parent.

The seams show at once. It is driven by async for, never by await and never by plain for. Its cleanup is asynchronous and easy to get wrong: because finalization may itself await, the runtime cannot reliably run it at garbage-collection time the way it finalizes an ordinary generator, so you are expected to aclose() it explicitly (or wrap it in contextlib.aclosing) to be sure the finally runs at all. And, as the next section shows, it cannot even hand a value back the way either of its parents can. Two features you understand perfectly apart combine into one whose rules you must learn fresh.

The return that isn't there

Here is a small, telling oddity. A coroutine can return a value, that is the point of return in an async def. An ordinary generator can too: since PEP 380, return value is legal and rides out on StopIteration, which is exactly what yield from collects. Combine the two, an async def with a yield, and the ability vanishes, in an async generator return value is a syntax error:

async def drain(account_id: int) -> AsyncGenerator[Txn, None]:
    count = 0
    async for txn in load_all(account_id):
        yield txn
        count += 1
    return count                # SyntaxError: 'return' with value in async generator

A bare return still works, but only to stop iteration, never to carry anything out, and there is no yield from for async generators to recover such a value anyway. Both parents can hand back a result; the child, uniquely, cannot, so the routine "stream the items and report a summary at the end" has to be contorted into some workaround.

Step back and count what the split has cost the grammar itself: async def, await, async for, async with, async comprehensions, async generators, a shadow set of dunders (__aiter__, __anext__, __aenter__, __aexit__) and a shadow toolkit (aclose, asend, athrow, contextlib.aclosing). Each one is either a re-minted copy of something the language already had, or a patch for a rule that async broke. A feature sold as "just write sequential-looking code" turns out to demand its own dialect, learned separately and incompatible with the original. That is the final bill of the leak: not two colors of function, but two of nearly everything.

Conclusion

Strip this entire part down to a single sentence and it is this:

async/await is a leaky abstraction because it violates the single responsibility principle.

A function has one job, carry out its computation, take these inputs, produce this result, perform this effect, and the single responsibility principle says that job should be its only reason to exist and its only reason to change. async/await breaks that rule. By planting the suspension signal inside the function, it hands every coroutine a second responsibility with nothing to do with its computation: telling the event loop where it may be paused so other tasks can run. The function stops being purely a unit of work and becomes, in the same breath, a participant in the scheduler. Two unrelated concerns, what the function computes and when the scheduler may suspend it, are welded into one signature.

Everything in this part is that one violation playing out. The original sin was the delegation itself, await making the function raise the scheduler's signal on its own behalf and async advertising that it does. The infection up the call stack is what a misplaced responsibility does once it is loose: because calling something that carries the scheduling concern forces you to carry it too, it climbs through every pure-computation layer that never should have held it. And the parallel language, async for, async with, async generators, the shadow dunders, is the same violation spreading sideways: once suspension is a responsibility of user code, every construct that touches user code must grow a version that shoulders it as well.

Seen this way, the two colors of function were never really about I/O, or performance, or syntax. They are the visible symptom of a design that fused two separate responsibilities into one signature and then let that fusion propagate through the whole language. Keep the two apart, let a function mind its computation while something else decides when to pause it, and every problem in this part dissolves at its source. Which raises the obvious question.

Where the cure to the infection lies?

Up to now this part has been almost all prosecution. We found the original sin, watched the async annotation metastasize up the call stack, and tallied the shadow language the split forced Python to grow. So it is fair to stop and ask: is any of this actually fixable, or is function coloring simply the price of admission? If you want cooperative, non-blocking concurrency, are you obliged to paint async on half your codebase?

The answer, and the reason there is a Part 3, is no. Nothing we have complained about is inherent to concurrency, or even to cooperative scheduling. Every problem in this part traces back to the one avoidable decision we pinned down at the start, putting the suspension signal inside the function, and there are models in wide production use today that decide differently and pay none of the costs. They come in two families.

The first keeps the event loop's entire bargain, cheap user-space suspension, thousands of tasks riding on a handful of OS threads, and simply moves the suspension point off your signatures and down to the I/O leaf where it belongs. In a stackful model, the runtime can park an entire call stack at the moment it blocks, without one function above the leaf saying a word about it. Go's goroutines work this way; Java's virtual threads (Project Loom) work this way. You write ordinary, blocking-looking code, a call is just a call, and the scheduler does the parking, exactly the separation async/await refused to make.

The second family is more radical and lives at the level of language design: treat "this might suspend" as an effect the compiler tracks and checks, rather than a keyword the programmer smears over every caller. Effect systems like Koka's, and the effect handlers now in OCaml 5, let a function's suspendability be inferred and verified without ever splitting the world into two colors of function. The event loop still gets the information it needs; that information just stops leaking into every type signature.

Part 3 is where we cross from prosecution to remedy. Having spent this part establishing that the leak is real and tracing where it comes from, we will turn to these two families and see how each keeps the concurrency we actually want while refusing the one decision that caused all the trouble.