Backend / Async & concurrency / 05_async_python_coroutines.md

Coroutines and async def

Updated 5 interview angles 7 min read source
On this page12
  1. The object
  2. Driving one by hand
  3. What await compiles to
  4. Why one blocking call freezes everything
  5. The errors, and what they mean
  6. Telling coroutine functions apart
  7. Async generators and async context managers
  8. Running one from synchronous code
  9. Where it breaks in production
  10. The trade not taken
  11. Related
  12. Interview angle

Coroutines and async def

Article.

async def does not run anything. Calling it returns a coroutine object: a suspended function, closely related to a generator, that runs only when something drives it. An event loop is just the thing doing the driving.

What is a coroutine object, and what does await actually do? Answer that and asyncio stops being magic: “never awaited” warnings, cancellation, and why one blocking call freezes everything follow directly. Outputs below are from Python 3.14.5.

Running coroutines concurrently with an event loop, tasks and TaskGroup are in Event loop internals and TaskGroup and structured concurrency.

The object

python
async def job():
    return 42

c = job()                       # nothing ran
type(c)                         # <class 'coroutine'>
inspect.getcoroutinestate(c)    # 'CORO_CREATED'

A coroutine object owns a suspended frame, exactly as a generator does (Generators): cr_frame is that frame, cr_await the object it is currently waiting on, and its state moves through CORO_CREATED, CORO_RUNNING, CORO_SUSPENDED and CORO_CLOSED. One is small: 100,000 of them took 19 MB here, which is why a server can hold tens of thousands of in-flight requests.

Driving one by hand

There is no magic in the loop. A coroutine is driven with send(), like a generator, and the values it yields come from the innermost awaitable:

python
@types.coroutine
def tick(value):
    got = yield value         # a bare yield: this is where control leaves
    return got

async def job():
    a = await tick("first yield")
    b = await tick("second yield")
    return f"done with {a}, {b}"

c = job()
c.send(None)       # 'first yield'   runs until the innermost yield
c.send("A")        # 'second yield'  "A" becomes the result of the first await
c.send("B")        # StopIteration: StopIteration.value == 'done with A, B'

Read that as the event loop sees it:

  • send(None) resumes the coroutine and runs it until something deep inside yields. What comes out is a message for the loop; in asyncio it is a Future meaning “wake me when this is done”, or None from asyncio.sleep(0), meaning “let others run”.
  • The value passed to the next send() becomes the result of the innermost await. The loop sends None and the future’s result is read from the future.
  • Returning raises StopIteration carrying the return value. That is how await coro gets a result and how a Task completes.
  • throw(exc) raises an exception at the paused await; that is how cancellation is delivered (CancelledError) and why finally blocks run. close() throws GeneratorExit the same way.

What await compiles to

text
CALL         asyncio.sleep(0)    build the awaitable
GET_AWAITABLE                    call its __await__()
SEND          ─┐                 pass the loop's value in
YIELD_VALUE    │ loop until the  hand its yield to the loop
RESUME         │ inner one       resume here
JUMP_BACKWARD ─┘ returns
END_SEND                         result of the await

That is yield from under another name: the SEND/YIELD_VALUE loop passes values and exceptions through a chain of coroutines until the innermost one yields to the loop. An awaitable is anything GET_AWAITABLE accepts: a coroutine, an object with __await__ returning an iterator (a Future, a Task), or a generator decorated with @types.coroutine, which is how asyncio.sleep(0) yields None without an __await__ method.

Why one blocking call freezes everything

The loop can only switch tasks when a coroutine yields, and a coroutine only yields at an await that reaches a real yield. Between awaits it is ordinary synchronous code on the loop’s thread. time.sleep(1), a synchronous database driver or parsing a 50 MB JSON body inside a coroutine holds the thread, and every other task waits (run_in_executor and asyncio.to_thread).

An await on something that completes immediately does not yield either: a coroutine awaiting coroutines that never reach a real I/O wait never lets the loop run.

The errors, and what they mean

Error Message, and what happened
never awaited coroutine 'f' was never awaited: nobody awaited f()
reused cannot reuse already awaited coroutine
not awaitable 'X' object can't be awaited (3.14 wording)
task failed Task exception was never retrieved: nobody read it

The first one is the classic bug: save(user) instead of await save(user) runs nothing, and the warning appears only when the unused coroutine object is garbage-collected, so it can show up far from the line.

Run tests with -W error::RuntimeWarning, and asyncio’s debug mode (PYTHONASYNCIODEBUG=1) adds where the coroutine was created.

Telling coroutine functions apart

Frameworks decide how to call a handler by asking whether it is a coroutine function: FastAPI runs async def endpoints on the loop and plain def ones in a thread pool.

Use inspect.iscoroutinefunction; asyncio.iscoroutinefunction is deprecated in 3.14 and slated for removal in 3.16. A decorator that wraps an async function must itself be async def, or mark its wrapper with inspect.markcoroutinefunction (3.12), or the framework will treat the endpoint as synchronous (Decorators).

Async generators and async context managers

An async def containing yield is an async generator, consumed with async for; __aenter__/__aexit__ make an async context manager, used with async with.

An async generator abandoned mid-iteration is finalized by the loop later, not at once, so its finally may run after you expect, or at shutdown (asyncio.run calls loop.shutdown_asyncgens() for this). contextlib.aclosing() (3.10) closes it at a known point (Context managers: the with protocol and contextlib).

Running one from synchronous code

asyncio.run(main()) creates a loop, runs the coroutine to completion and closes the loop: right for a program’s entry point, wrong for calling async code repeatedly from sync code, because each call builds and tears down a loop.

python
with asyncio.Runner() as runner:          # one loop for many calls
    user = runner.run(fetch_user(42))
    orders = runner.run(fetch_orders(42))

From another thread, run a loop in a background thread and submit with asyncio.run_coroutine_threadsafe(). (asyncio.to_thread goes the other way: sync code called from async code.)

Where it breaks in production

The missing await

cache.set(key, value) without await returns a coroutine and does nothing; the cache silently never fills. Type checkers flag an unused coroutine, and -W error::RuntimeWarning turns the warning into a test failure.

Sync work hiding in async code

An endpoint that is async def all the way down can still block the loop: CPU work between awaits, a sync SDK, a requests call a library makes internally. Event-loop lag rising with traffic, with idle CPU waits, is the signature; PYTHONASYNCIODEBUG=1 logs callbacks that take longer than 100 ms.

A decorator that makes async look sync

A logging or retry decorator written as a plain def wrapper around an async def returns a coroutine from a function that looks synchronous. The framework then runs it in a thread pool, or never awaits it.

The trade not taken

Python chose stackless coroutines with an explicit await: every switch point is visible, at the cost of “function colour”, since async code can only be awaited from async code.

gevent takes the other path: stackful green threads plus monkey-patched I/O, so ordinary synchronous code becomes concurrent without async/await, but switch points are invisible, and any C extension doing blocking I/O stalls every green thread silently.

Before 3.5, asyncio’s own coroutines were generators decorated with @asyncio.coroutine and yield from; that decorator was removed in 3.11, and await is the same mechanism with its own syntax.

Interview angle 5

  • “What does calling an async def function do?” — nothing runs: it returns a coroutine object, a suspended frame like a generator’s. Something must drive it, normally the event loop via a Task.
    • Follow-up: “Drive one without an event loop.” — coro.send(None) runs it to its first real yield and returns what the innermost awaitable yielded; the final send() raises StopIteration with the return value in .value.
  • “What does await do?” — it delegates to an awaitable, like yield from: values and exceptions pass through the chain until the innermost awaitable yields to the event loop, and the awaitable’s return value becomes the result of the await.
    • Follow-up: “How is a task cancelled, mechanically?” — the loop calls throw(CancelledError()) on the coroutine, which raises at the paused await, so try/finally and async with clean up.
  • “Why does time.sleep() in a coroutine freeze the server?” — the loop can only switch when a coroutine yields, and time.sleep never yields; the thread is held and every other task waits.
  • “What does ‘coroutine was never awaited’ mean?” — a coroutine was created and garbage-collected without being awaited: a missing await, so the code never ran. The warning appears at collection, not at the line.
  • “Async without async, like gevent: why didn’t Python do that?” — explicit await makes every switch point visible and keeps C extensions from blocking silently, at the price of function colour.