Coroutines and the async await Protocol

A native coroutine in CPython is not a new kind of object grafted onto the language — it is a thin specialization of the generator, the same suspendable-frame machinery in a different costume. A function defined with async def compiles to a code object carrying the CO_COROUTINE flag; calling it does not run a single line of its body but instead manufactures a PyCoroObject that wraps a paused interpreter frame. The await expression is essentially yield from restricted to awaitables: it asks an object for an iterator (via __await__) and drives that iterator to exhaustion, propagating the values it yields up the call chain until something — an event loop, ultimately — feeds a value back in with .send(). The single most important consequence: a bare coroutine object does nothing on its own. It is inert until something drives it. This note explains the object model and the protocol; who drives it is the job of the event loop.

Mental Model

The cleanest way to think about a coroutine is as a generator that has been told it may only be consumed by an await, never by a for loop or next(). Everything that makes generators able to suspend mid-execution and resume later — the heap-allocated frame, the saved instruction pointer, the saved value stack — is reused verbatim. The async/await syntax is a contract layer on top: it forbids the synchronous iteration protocol (__iter__/__next__ are deliberately absent on coroutine objects) so that you cannot accidentally iterate something that is meant to be scheduled cooperatively.

await x decomposes into two mechanical steps. First, x.__await__() is called and must return an iterator (per the data model). Second, that iterator is driven exactly the way yield from drives a sub-generator: each value the inner iterator yields bubbles all the way out to whoever is calling .send() on the outermost coroutine, and each value sent in is forwarded down to where the inner iterator paused. When the inner iterator finally raises StopIteration, its .value becomes the result of the await expression.

flowchart TD
    subgraph drive["The driver — usually a Task in the event loop"]
        D[".send(None) / .send(value)"]
    end
    D -->|resumes| CO["coroutine object (PyCoroObject)<br/>wraps a suspended frame"]
    CO -->|"await x"| GA["GET_AWAITABLE: x.__await__() -> iterator"]
    GA --> SEND["SEND: iterator.send(value)"]
    SEND -->|"iterator yields a value"| OUT["value escapes the whole chain<br/>-> back to the driver"]
    SEND -->|"iterator raises StopIteration"| RES["its .value becomes the<br/>result of the await"]
    OUT -.->|"driver later calls .send() again"| D
    style CO fill:#2d3748,color:#fff
    style drive fill:#1a365d,color:#fff

Diagram: how one await is executed. GET_AWAITABLE turns the awaitable into an iterator; SEND steps it. A yielded value escapes all the way to the external driver (a Task); the driver decides when — if ever — to resume by calling .send() again. Insight: the coroutine is purely passive. The arrows that re-enter it all originate outside it, which is why a coroutine you never await and never schedule simply never executes.

Coroutine object vs coroutine function

These are routinely conflated and the distinction is mechanical, not pedantic. A coroutine function is the thing you define with async def — it is an ordinary PyFunctionObject whose __code__ carries the CO_COROUTINE flag. Calling it does not execute the body; it returns a coroutine object (PyCoroObject), a fresh suspended frame. The asyncio docs make this concrete:

>>> async def main():
...     print("hello")
...
>>> main          # the coroutine *function*
<function main at 0x...>
>>> main()        # calling it returns a coroutine *object* — nothing printed
<coroutine object main at 0x1053bb7c8>

Note that "hello" was never printed: calling main() ran zero lines of the body. The body runs only once the coroutine object is driven. Forgetting this is the classic RuntimeWarning: coroutine 'main' was never awaited — emitted when a never-driven coroutine object is garbage-collected (per PEP 492, which added the warning precisely to catch forgotten await keywords).

The coroutine object exposes its own state through a family of cr_-prefixed introspection attributes (verified against CPython 3.14.5): cr_frame is the suspended interpreter frame, cr_code is the code object, cr_running is True only while the body is actually executing, cr_suspended is True while it is parked at an await, cr_await is the object the coroutine is currently awaiting (or None), and cr_origin is the creation-site traceback used by the “never awaited” machinery. These mirror the generator’s gi_* attributes one-for-one — cr_frame/cr_code/cr_runninggi_frame/gi_code/gi_running — which is the introspection-level evidence of the shared implementation. Debuggers and the asyncio “task stack” tooling read cr_await to reconstruct who-is-waiting-on-whom across a suspended call chain.

Built on the generator machinery — the shared C structures

The clinching evidence that coroutines are generators with a contract lives in Objects/genobject.c (v3.14.5). The construction, deallocation, and stepping code is literally shared. gen_new_with_qualname builds both generator and coroutine objects by copying the _PyInterpreterFrame and setting frame->owner = FRAME_OWNED_BY_GENERATOR — the same suspended-frame ownership for both. The resumption primitive, gen_send_ex2, is shared too; it ends up calling _PyEval_EvalFrame(tstate, frame, exc) to run the frame forward until the next suspension. Deallocation is gen_dealloc for both, with one coroutine-specific extra: Py_CLEAR(((PyCoroObject *)gen)->cr_origin_or_finalizer).

The frame-suspension mechanism itself — how the value stack and instruction pointer are saved on a heap-allocated frame so it can be picked up later — is the generator’s machinery. This note does not re-derive it; see Generators and the yield Mechanism for the suspend/resume contract and Generator and Coroutine Frame Internals for the actual frame layout that both share. What is coroutine-specific is the type’s async-protocol slot table.

A PyTypeObject exposes the await protocol through its tp_as_async slot, a PyAsyncMethods table. The difference between a generator and a coroutine is essentially one row in that table:

/* generator */
static PyAsyncMethods gen_as_async = {
    0,                  /* am_await */
    0,                  /* am_aiter */
    0,                  /* am_anext */
    PyGen_am_send,      /* am_send  */
};
 
/* coroutine */
static PyAsyncMethods coro_as_async = {
    coro_await,         /* am_await */
    0,                  /* am_aiter */
    0,                  /* am_anext */
    PyGen_am_send,      /* am_send  */
};

Line by line: am_send is identical (PyGen_am_send) because stepping a coroutine is stepping a generator. am_aiter/am_anext are null on both — neither a plain coroutine nor a plain generator is an async iterator (those slots are populated on async generator objects, a third member of the family flagged CO_ASYNC_GENERATOR). The one meaningful difference is am_await: generators leave it null (a generator is not awaitable), while a coroutine sets it to coro_await. That is the C-level realization of “a coroutine is awaitable; a generator is not.”

coro_await returns a small wrapper object:

typedef struct {
    PyObject_HEAD
    PyCoroObject *cw_coroutine;
} PyCoroWrapper;

This PyCoroWrapper is the iterator that await some_coroutine drives — it delegates send(), throw(), and close() straight back to the underlying coroutine. So coro.__await__() hands back an iterator over the coroutine itself, satisfying the “__await__ must return an iterator” rule. The dispatch on flags happens at object-construction time in _Py_MakeCoro, which inspects the code flags to decide which family member to build:

int coro_flags = ((PyCodeObject *)func->func_code)->co_flags &
    (CO_GENERATOR | CO_COROUTINE | CO_ASYNC_GENERATOR);

For CO_COROUTINE specifically it also runs compute_cr_origin() when origin-tracking is enabled, recording where the coroutine was created so the “never awaited” warning can point at the right line.

The __await__ protocol and the awaitables

An object is awaitable if it can appear on the right of await. Per the data model, there are three ways to be awaitable:

  1. A native coroutine object (async def result) — awaitable via the coro_await slot above.
  2. A generator-based coroutine — see below.
  3. Any object whose __await__ returns an iterator — the general protocol. asyncio’s Future is the canonical example, and it implements __await__ as a hand-written generator (covered in The asyncio Event Loop, since the Future is the loop’s suspension primitive).

Generator-based coroutines and the CO_ITERABLE_COROUTINE bridge

Before PEP 492 gave the language async/await (Python 3.5), “coroutines” were ordinary generators driven through yield from — the model from PEP 342 and PEP 380. asyncio originally ran on these. When native coroutines arrived, that entire body of generator-based code still needed to interoperate with await, so the @types.coroutine decorator exists as a bridge: applied to a generator function, it sets the CO_ITERABLE_COROUTINE flag on the code object. That flag tells GET_AWAITABLE’s get_awaitable helper to accept the generator as an awaitable even though it is not a native coroutine — recall the docs phrasing, get_awaitable(o) “returns o if o is a coroutine object or a generator object with the CO_ITERABLE_COROUTINE flag.” Verified on 3.14.5: decorating a yield-using function with @types.coroutine sets co_flags & 0x0100 (CO_ITERABLE_COROUTINE). The asymmetry PEP 492 deliberately preserves: a generator-based coroutine can yield from a native coroutine, but a plain (undecorated) generator cannot — and native coroutines cannot be iterated synchronously at all. This is why low-level async primitives (including asyncio’s own Future.__await__) are written as generators that yield: a generator is the most direct way to author “an iterator that suspends,” and @types.coroutine (or being the __await__ of an awaitable) makes it await-compatible. The decorator is legacy and discouraged for new code, but it remains load-bearing under the hood.

object.__await__() “must return an iterator” (data model) — that is the whole protocol. The iterator’s .send()/.throw()/.close() are then driven by await. The coroutine object itself, viewed as the thing being driven, exposes the standard generator-flavored methods (per the coroutine-objects data-model section):

  • coroutine.send(value) — starts or resumes execution. Sending None starts a fresh coroutine; on a suspended one, value becomes the result of the await expression that paused it. When the coroutine returns, send raises StopIteration with the return value in .value.
  • coroutine.throw(exc) — raises exc at the point where the coroutine is paused (used for cancellation).
  • coroutine.close() — raises GeneratorExit inside the coroutine to unwind it.

This is exactly the generator interface, which is the point.

The bytecode: GET_AWAITABLE, SEND, RETURN_GENERATOR

The compiler lowers await to two opcodes, both documented in the dis module. GET_AWAITABLE does STACK[-1] = get_awaitable(STACK[-1]), where (quoting the docs) get_awaitable(o) “returns o if o is a coroutine object or a generator object with the CO_ITERABLE_COROUTINE flag, or resolves o.__await__.” So it is the runtime realization of the three-way awaitable rule above: native coroutines and @types.coroutine generators pass straight through; everything else is asked for __await__. Its where oparg distinguishes a plain await from the implicit awaits inside async with (1 = after __aenter__, 2 = after __aexit__), so error messages can name the construct.

SEND is the stepping opcode: “Equivalent to STACK[-1] = STACK[-2].send(STACK[-1]). Used in yield from and await statements.” The shared mention of yield from is the smoking gun — await and yield from compile to the same drive loop. Crucially, “if the call raises StopIteration, pop the top value from the stack, push the exception’s value attribute, and increment the bytecode counter by delta” — i.e. when the awaited iterator finishes, its return value becomes the result on the stack and control jumps past the await. Supporting opcodes round it out: RETURN_GENERATOR is “the first opcode in the code object” for a coroutine/generator/async-generator — it creates the suspendable object from the current frame, clears the frame, and returns the new object (this is why calling the function runs no body). END_SEND and CLEANUP_THROW (both added 3.12) clean up the stack on normal and exceptional exit of the send loop.

A disassembly makes the parallel visible. Running dis.dis on async def f(x): return await x under CPython 3.14.5 produces (trimmed):

RETURN_GENERATOR
POP_TOP
RESUME             0
LOAD_FAST_BORROW   0 (x)
GET_AWAITABLE      0
LOAD_CONST         0 (None)
SEND               3 (to L5)   <-- step the awaitable
YIELD_VALUE        1           <-- suspend; the yielded value escapes the coroutine
RESUME             3
JUMP_BACKWARD_NO_INTERRUPT (back to SEND)
END_SEND
RETURN_VALUE
CLEANUP_THROW

Now compare the disassembly of def g(x): yield from x on the same interpreter:

RETURN_GENERATOR
POP_TOP
RESUME             0
LOAD_FAST          0 (x)
GET_YIELD_FROM_ITER          <-- the ONLY structural difference
LOAD_CONST         0 (None)
SEND               3 (to L5)
YIELD_VALUE        1
RESUME             2
JUMP_BACKWARD_NO_INTERRUPT (back to SEND)
END_SEND
POP_TOP
LOAD_CONST         0 (None)
RETURN_VALUE
CLEANUP_THROW

The two are the same machine save for one opcode: await preps its iterator with GET_AWAITABLE (type-checked: awaitables only), yield from preps with GET_YIELD_FROM_ITER (any iterable). After that the SENDYIELD_VALUERESUMEJUMP_BACKWARDEND_SEND drive loop, and even the CLEANUP_THROW exception path, are identical. This is the strongest possible confirmation that “await is yield from for awaitables” is not an analogy but a literal statement about the emitted bytecode (verified against CPython 3.14.5).

async for and async with

await is not the only construct the protocol covers. async for drives asynchronous iterators and async with drives asynchronous context managers — both are await-aware versions of their synchronous cousins (The Iterator Protocol, The Context Manager Protocol).

An asynchronous iterator (per PEP 492 and the data model) defines __aiter__, which returns the async iterator directly (not an awaitable — that early-3.5 design was changed), and __anext__, which returns an awaitable that, when awaited, produces the next item or raises StopAsyncIteration to end the loop. The desugaring PEP 492 gives is:

# async for TARGET in ait:
#     BODY
running = True
while running:
    try:
        TARGET = await type(ait).__anext__(ait)
    except StopAsyncIteration:
        running = False
    else:
        BODY

The bytecode mirrors this: GET_AITER calls __aiter__, GET_ANEXT does get_awaitable(STACK[-1].__anext__()) (note the awaitable wrapping — __anext__’s result is itself awaited), and END_ASYNC_FOR catches the StopAsyncIteration that signals loop termination.

An asynchronous context manager defines __aenter__ and __aexit__, each returning an awaitable, so that async with mgr as v: becomes roughly v = await mgr.__aenter__()await mgr.__aexit__(...). The BEFORE_ASYNC_WITH opcode loads those methods, and the GET_AWAITABLE 1/GET_AWAITABLE 2 opargs you saw earlier mark the implicit awaits so a TypeError (“object does not support the asynchronous context manager protocol”) names the right method.

Why a bare coroutine does nothing

This is the recurring beginner trap, and it follows directly from the mechanics above. Calling an async def function executes RETURN_GENERATOR and immediately returns a suspended PyCoroObject — the body has not run. The body runs only when something calls .send(None) on the coroutine (or drives its __await__). Inside an already-running coroutine, await other() does that driving inline. At the top level there is no enclosing coroutine, so you must hand the coroutine to a driver: asyncio.run(main()) or asyncio.create_task(coro). Do neither and the coroutine object is eventually garbage-collected unrun, triggering RuntimeWarning: coroutine '…' was never awaited.

The deeper point: async/await is cooperative, not magical. It describes where a computation may suspend (await points) and what it suspends on (awaitables), but it contributes no scheduler, no threads, and no parallelism. Something external must repeatedly resume the coroutines and decide which one to resume next. In practice that something is the event loop, driving each coroutine through a Task. A coroutine without a driver is a recipe that no one is cooking.

Common misunderstandings

  • await makes things run in parallel.” No. await suspends the current coroutine and yields control to the driver; concurrency comes from the driver interleaving many coroutines, and never from parallel CPU execution under the GIL. To run two coroutines concurrently you must schedule both as Tasks first, then await them — awaiting them one after another is sequential.
  • “A coroutine is a separate thread.” No — it is a heap object holding a suspended frame, living in the same thread as its driver. See Python Threading Model for the contrast between cooperative coroutines and preemptive OS threads.
  • “I can iterate a coroutine like a generator.” No. Coroutine objects deliberately lack __iter__/__next__ (PEP 492); for x in coro: is a TypeError. This is the contract layer preventing accidental synchronous consumption.
  • yield from and await are different mechanisms.” Mechanically they are the same SEND drive loop. await is the type-checked, awaitable-only specialization; yield from is the untyped generator-delegation form. Generator-based coroutines (@types.coroutine) exist precisely to bridge the two.

See Also