Skip to content

36. Coroutines and Async

async def, await, SEND opcode, coroutine wakeup, and how asyncio integrates with CPython's coroutine machinery.

Coroutines are resumable computations used for asynchronous programming. They let Python code suspend at an await point, return control to an event loop, and later resume when the awaited operation has a result.

A coroutine is similar to a generator because both preserve execution state across suspension. The difference is protocol and purpose.

A generator yields values to an iterator consumer.

A coroutine awaits other asynchronous operations and eventually returns one final result.

async def fetch():
    data = await read()
    return data

Calling fetch() does not run the body to completion. It creates a coroutine object.

coro = fetch()

The body runs when the coroutine is awaited or scheduled by an event loop.

36.1 Coroutine Function vs Coroutine Object

An async def statement creates a coroutine function.

Calling the coroutine function creates a coroutine object.

async def work():
    return 42

coro = work()

Conceptually:

work
    coroutine function object

work()
    coroutine object
        suspended execution state
        code object
        frame or frame-like state

This differs from an ordinary function:

def work():
    return 42

work()

An ordinary function call runs immediately and returns 42.

An async function call returns a coroutine object that must later be driven.

36.2 await

await suspends the current coroutine until another awaitable completes.

async def outer():
    value = await inner()
    return value + 1

The expression:

await inner()

does several things conceptually:

call inner()
obtain awaitable object
suspend current coroutine
let event loop drive awaitable
resume current coroutine with result

If the awaited operation raises, the exception is injected back into the awaiting coroutine.

async def outer():
    try:
        value = await inner()
    except ValueError:
        value = 0
    return value

36.3 Awaitables

await works on awaitable objects.

Common awaitables include:

coroutine objects
asyncio Task objects
asyncio Future objects
objects implementing __await__

A custom object can be awaitable by defining __await__.

class Immediate:
    def __await__(self):
        yield
        return 42

In practice, most application code awaits coroutines, tasks, and futures created by an async framework.

36.4 Coroutine State

A coroutine can be in states similar to generator states:

created
running
suspended
closed

Use inspect:

import inspect

async def work():
    await other()

coro = work()
print(inspect.getcoroutinestate(coro))

Common states include:

CORO_CREATED
CORO_RUNNING
CORO_SUSPENDED
CORO_CLOSED

A coroutine cannot be awaited by multiple consumers at the same time. It represents one execution.

36.5 Coroutine Frames

A suspended coroutine keeps its execution state.

That state includes:

code object
instruction position
local variables
value stack
exception state
currently awaited object
running state
closed state

Example:

async def process():
    data = await read()
    return transform(data)

While suspended at await read(), the coroutine must remember:

current frame
local variables
await target
next instruction after await

When read() completes, the coroutine resumes and assigns the returned value to data.

36.6 Coroutines Use the Same Frame Model

Coroutines do not use a separate interpreter. They are executed by the same CPython evaluation loop.

The loop runs until the coroutine:

awaits and suspends
returns
raises
is cancelled

Conceptually:

coroutine object
    frame state
        bytecode
        stack
        locals

event loop
    resumes coroutine
        evaluation loop runs frame
        stops at await or completion

The event loop does scheduling. CPython provides the resumable execution machinery.

36.7 Event Loop

An event loop coordinates asynchronous tasks.

In asyncio, the event loop manages:

ready tasks
sleep timers
socket readiness
future completion
callbacks
cancellation
exception reporting

Example:

import asyncio

async def main():
    await asyncio.sleep(1)
    return 42

result = asyncio.run(main())
print(result)

asyncio.run creates and drives an event loop until main() completes.

CPython itself supplies coroutine objects and await semantics. asyncio supplies one standard event-loop implementation.

36.8 Tasks

A coroutine object alone is passive. A task schedules it for execution.

import asyncio

async def work():
    await asyncio.sleep(1)
    return 42

async def main():
    task = asyncio.create_task(work())
    result = await task
    return result

Conceptually:

coroutine object
    passive resumable computation

task
    event-loop managed wrapper
    resumes coroutine
    stores result or exception
    supports cancellation

A task lets a coroutine run concurrently with other tasks.

36.9 Cooperative Concurrency

Async Python uses cooperative concurrency.

A coroutine runs until it reaches an await point.

async def work():
    step1()
    await wait()
    step2()

During step1(), no other task runs on that event loop unless step1() itself awaits or returns control.

At await wait(), the coroutine suspends, and the event loop can run another task.

This means CPU-heavy code inside a coroutine can block the event loop:

async def bad():
    while True:
        compute()

Without await, this coroutine does not yield control.

36.10 Async Is Not Parallelism

Async concurrency does not automatically mean CPU parallelism.

One event loop usually runs in one OS thread. It switches between tasks only at suspension points.

Async works well for:

network I/O
many open connections
timeouts
sleeping
waiting for subprocesses
streaming protocols
cooperative task orchestration

It does not make CPU-bound Python bytecode run in parallel.

For CPU-bound work, use:

native extensions
multiprocessing
process pools
thread pools for blocking calls
free-threaded builds where appropriate
external workers

36.11 Awaiting a Coroutine

Given:

async def inner():
    return 10

async def outer():
    x = await inner()
    return x + 1

The flow is:

outer starts
outer calls inner(), producing inner coroutine
outer awaits inner coroutine
event loop drives inner
inner returns 10
outer resumes with x = 10
outer returns 11

The return value crosses the await boundary back into the suspended frame.

36.12 Awaiting a Future

A future represents a result that may not exist yet.

future = loop.create_future()

A coroutine can await it:

value = await future

If the future is not complete, the coroutine suspends.

Later:

future.set_result(42)

The event loop marks waiting tasks ready. The awaiting coroutine resumes with 42.

If the future has an exception:

future.set_exception(ValueError("bad"))

the awaiting coroutine resumes by raising that exception at the await point.

36.13 Awaiting a Task

A task is also awaitable.

task = asyncio.create_task(work())
result = await task

If the task finishes normally, await task returns its result.

If the task raises, await task raises the same exception.

If the task is cancelled, await task raises CancelledError.

36.14 Cancellation

Cancellation asks a task to stop.

task.cancel()

In asyncio, cancellation is delivered by injecting CancelledError into the coroutine at an await point.

async def work():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        cleanup()
        raise

A coroutine may catch cancellation for cleanup, but it should usually reraise it unless it intentionally suppresses cancellation.

Cancellation is cooperative. A coroutine that never awaits may not observe cancellation promptly.

36.15 Cleanup With try/finally

Use try/finally for async cleanup.

async def work():
    resource = await acquire()
    try:
        await use(resource)
    finally:
        await release(resource)

The finally block runs on:

normal completion
exception
cancellation

If cleanup itself awaits, the coroutine may suspend during cleanup.

This makes cancellation and cleanup subtle. The coroutine can be in a cancelled state while still executing finalization code.

36.16 Async Context Managers

Async context managers use async with.

async with lock:
    await work()

The object must provide:

__aenter__
__aexit__

Conceptually:

mgr = lock
value = await mgr.__aenter__()
try:
    await work()
except BaseException as exc:
    suppress = await mgr.__aexit__(type(exc), exc, exc.__traceback__)
    if not suppress:
        raise
else:
    await mgr.__aexit__(None, None, None)

Async context managers allow resource acquisition and release to suspend.

36.17 Async Iterators

Async iteration uses async for.

async for item in stream:
    process(item)

The object must provide:

__aiter__
__anext__

Conceptually:

ait = stream.__aiter__()

while True:
    try:
        item = await ait.__anext__()
    except StopAsyncIteration:
        break
    process(item)

Async iteration is useful for streams where each next item may require I/O.

36.18 Async Generators

An async generator is defined with async def and yield.

async def lines(reader):
    async for line in reader:
        yield line.strip()

It produces an async iterator.

Use:

async for line in lines(reader):
    print(line)

Async generators can both await and yield.

async def gen():
    await asyncio.sleep(1)
    yield 1

They combine coroutine suspension with generator-style value production.

36.19 anext

anext retrieves the next item from an async iterator.

item = await anext(ait)

This awaits ait.__anext__().

If the async iterator is exhausted, it raises StopAsyncIteration.

A default can be supplied:

item = await anext(ait, default)

36.20 StopAsyncIteration

Async iterators signal completion with StopAsyncIteration.

This is distinct from StopIteration.

normal iterator ends with StopIteration
async iterator ends with StopAsyncIteration

async for catches StopAsyncIteration to exit the loop.

Raising StopIteration from async iteration is incorrect.

36.21 Coroutine Bytecode

An async function compiles to a code object marked as coroutine code.

async def f():
    return 1

Calling it creates a coroutine object.

An async function with await:

async def f():
    value = await g()
    return value

conceptually includes bytecode for:

call g
get awaitable iterator
send into awaitable
suspend if not done
resume with result
store value
return value

The exact bytecode changes by Python version. The model remains: await is implemented as resumable frame execution.

36.22 SEND and Await

Modern CPython bytecode uses send-like machinery to drive awaitables.

Conceptually:

await awaitable:
    get awaitable iterator
    send None or value
    if it yields:
        suspend current coroutine
    if it returns:
        resume with result
    if it raises:
        propagate exception

This is related to generator delegation. Coroutines build on the same idea of sending values into suspended computations.

36.23 Coroutine Return

A coroutine returns one final value.

async def f():
    return 42

When awaited:

value = await f()

the awaiting coroutine receives 42.

Internally, completion is represented through the awaitable protocol. At user level, await turns coroutine completion into an ordinary expression result.

36.24 Coroutine Exceptions

If a coroutine raises, awaiting it raises.

async def f():
    raise ValueError("bad")

async def main():
    await f()

The exception propagates to main at the await point.

You can catch it:

async def main():
    try:
        await f()
    except ValueError:
        return 0

The traceback includes coroutine frames involved in the async call chain.

36.25 Coroutine Warnings

Creating a coroutine and never awaiting it is usually a bug.

async def f():
    return 1

f()

This creates a coroutine object and discards it.

Python may warn:

RuntimeWarning: coroutine was never awaited

The body never ran.

Correct usage:

await f()

or:

asyncio.create_task(f())

36.26 asyncio.run

asyncio.run is the standard entry point for running an async main function.

import asyncio

async def main():
    await asyncio.sleep(1)
    return 42

print(asyncio.run(main()))

It creates an event loop, runs the coroutine to completion, handles final async generator cleanup, and closes the loop.

You normally call it once at the top level of a program.

36.27 Blocking Calls Inside Async Code

Blocking calls stop the event loop.

Bad:

import time

async def main():
    time.sleep(5)

During time.sleep, the event loop cannot run other tasks.

Better:

import asyncio

async def main():
    await asyncio.sleep(5)

For blocking functions that cannot be made async, use an executor or thread helper:

result = await asyncio.to_thread(blocking_function, arg)

This keeps the event loop responsive.

36.28 Task Groups

Structured concurrency groups related tasks.

import asyncio

async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch_one())
        tg.create_task(fetch_two())

A task group waits for its child tasks. If one task fails, the group coordinates cancellation and raises grouped exceptions as needed.

This is safer than creating tasks without tracking them.

36.29 Exception Groups in Async Code

Concurrent tasks can fail together.

Task groups can report multiple failures using exception groups.

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(a())
        tg.create_task(b())
except* ValueError as group:
    handle_value_errors(group)

except* can split grouped exceptions by type.

This is important for async systems because independent tasks may fail during the same structured operation.

36.30 Async Generators and Cleanup

Async generators need async cleanup.

async def gen():
    try:
        yield 1
    finally:
        await cleanup()

If the async generator is not exhausted, it should be closed with aclose().

agen = gen()
item = await anext(agen)
await agen.aclose()

Event loops also include machinery to finalize pending async generators during shutdown.

36.31 Async Generator Methods

Async generators support methods similar to generators, but awaitable:

MethodMeaning
__anext__()Get next item asynchronously
asend(value)Send value into async generator
athrow(exc)Throw exception into async generator
aclose()Close async generator

These methods return awaitables.

Example:

value = await agen.__anext__()
await agen.aclose()

36.32 Coroutines and Reference Lifetime

A suspended coroutine keeps local variables alive.

async def work():
    data = bytearray(100_000_000)
    await asyncio.sleep(10)
    return len(data)

While sleeping, data remains alive because the coroutine may resume and use it.

Retention chain:

task
    coroutine
        suspended frame
            local data

Clear large locals before long awaits when possible:

async def work():
    data = bytearray(100_000_000)
    result = process(data)
    data = None
    await asyncio.sleep(10)
    return result

36.33 Task Lifetime

A task keeps its coroutine alive.

task = asyncio.create_task(work())

Until the task completes or is cancelled and finalized, it references the coroutine and its frame state.

If you create tasks and lose track of them, exceptions may be logged later and resources may remain active longer than expected.

Structured patterns such as task groups help control lifetime.

36.34 Async Stack Traces

Async stack traces include suspended coroutine chains.

When an exception crosses await boundaries, the traceback shows where it was raised and where it was awaited.

Example:

async def a():
    await b()

async def b():
    await c()

async def c():
    1 / 0

The traceback can show the async chain from a to b to c.

This is possible because coroutine frames preserve code object and instruction position information.

36.35 contextvars

Async code often uses contextvars for context-local state.

from contextvars import ContextVar

request_id = ContextVar("request_id")

async def handle():
    print(request_id.get())

Unlike thread-local storage, context variables are designed to work correctly across async task switches.

Each task can carry its own context.

This is important for:

request IDs
tracing
logging context
database transaction context
auth state
locale settings

36.36 Async and the GIL

Async code does not remove the GIL.

In traditional CPython:

one thread executes Python bytecode at a time per interpreter
async tasks switch cooperatively at await points

Async code can handle many I/O-bound tasks efficiently because most time is spent waiting for external events.

It does not make CPU-heavy Python loops execute in parallel.

36.37 Async vs Threads

Async and threads solve different problems.

FeatureAsync tasksThreads
SchedulingCooperativePreemptive by OS and interpreter checks
Switch pointsawaitThread scheduling and GIL handoff
Best forMany I/O tasksBlocking APIs, integration with sync code
Shared memorySame thread usuallyShared across threads
Race risksLower but still possibleHigher
CPU parallelism in traditional CPythonNoLimited by GIL for Python bytecode

Async code is explicit about suspension. Threads can switch at less obvious points.

36.38 Async vs Generators

Coroutines and generators share resumable execution, but the protocols differ.

FeatureGeneratorCoroutine
Definitiondef with yieldasync def
ProducesMultiple yielded valuesOne final result
Consumerfor, next, sendawait, event loop
CompletionStopIterationAwait result or exception
Main useLazy iterationAsync I/O coordination
Can awaitNoYes
Can yield plain valuesYesNo, except async generators

A coroutine is not an iterator. A generator is not directly awaitable unless adapted.

36.39 A Minimal Coroutine Scheduler

A toy scheduler can show the core idea.

from collections import deque

class Sleep:
    def __await__(self):
        yield self
        return None

async def task(name):
    print(name, "start")
    await Sleep()
    print(name, "end")

def run(coros):
    ready = deque(c.__await__() for c in coros)

    while ready:
        aw = ready.popleft()
        try:
            next(aw)
        except StopIteration:
            continue
        ready.append(aw)

run([task("a"), task("b")])

This is not asyncio. It ignores real I/O, timers, futures, cancellation, exceptions, and task state. But it shows the key mechanism:

coroutine runs
await yields control
scheduler later resumes it

36.40 Common Misunderstandings

MisunderstandingCorrect model
Calling an async function runs itIt creates a coroutine object
await starts a new threadIt suspends current coroutine until awaitable completes
Async makes CPU code parallelIt mainly helps cooperative I/O concurrency
A coroutine can be awaited repeatedlyA coroutine object represents one execution
asyncio.create_task is the same as awaitIt schedules concurrently; await waits for completion
Blocking calls are fine inside async codeThey block the event loop
Cancellation kills code immediatelyIt injects an exception cooperatively
Async uses a different interpreterIt uses CPython frames and evaluation loop

36.41 Reading Strategy

Start with:

async def f():
    return 1

Then:

async def g():
    value = await f()
    return value + 1

Inspect:

import dis
import inspect

print(inspect.iscoroutinefunction(f))
coro = f()
print(inspect.getcoroutinestate(coro))
dis.dis(g)

Then study:

await
async with
async for
async generators
tasks
cancellation
TaskGroup
contextvars
blocking calls inside async code

For each case, track:

when the coroutine object is created
when the body starts
where it suspends
what object it awaits
how it resumes
what happens on exception or cancellation
when its frame is cleared

36.42 Chapter Summary

Coroutines are resumable computations used for asynchronous programming. An async def call creates a coroutine object. The coroutine body runs only when awaited or scheduled. At each await, the coroutine can suspend, preserving its frame state, and later resume with a value or exception.

The core model is:

async function call
create coroutine object
event loop or await resumes coroutine
run until await, return, raise, or cancellation
await suspends and yields control
resume later with result or exception

Async execution is built on the same CPython foundations as ordinary execution: code objects, frames, bytecode, exceptions, and object protocols. The event loop supplies scheduling; CPython supplies resumable execution.