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 dataCalling 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 stateThis 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 + 1The expression:
await inner()does several things conceptually:
call inner()
obtain awaitable object
suspend current coroutine
let event loop drive awaitable
resume current coroutine with resultIf 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 value36.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 42In 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
closedUse inspect:
import inspect
async def work():
await other()
coro = work()
print(inspect.getcoroutinestate(coro))Common states include:
CORO_CREATED
CORO_RUNNING
CORO_SUSPENDED
CORO_CLOSEDA 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 stateExample:
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 awaitWhen 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 cancelledConceptually:
coroutine object
frame state
bytecode
stack
locals
event loop
resumes coroutine
evaluation loop runs frame
stops at await or completionThe 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 reportingExample:
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 resultConceptually:
coroutine object
passive resumable computation
task
event-loop managed wrapper
resumes coroutine
stores result or exception
supports cancellationA 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 orchestrationIt 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 workers36.11 Awaiting a Coroutine
Given:
async def inner():
return 10
async def outer():
x = await inner()
return x + 1The 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 11The 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 futureIf 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 taskIf 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()
raiseA 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
cancellationIf 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 1They 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 StopAsyncIterationasync 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 1Calling it creates a coroutine object.
An async function with await:
async def f():
value = await g()
return valueconceptually includes bytecode for:
call g
get awaitable iterator
send into awaitable
suspend if not done
resume with result
store value
return valueThe 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 exceptionThis 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 42When 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 0The 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 awaitedThe 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:
| Method | Meaning |
|---|---|
__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 dataClear 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 result36.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 / 0The 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 settings36.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 pointsAsync 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.
| Feature | Async tasks | Threads |
|---|---|---|
| Scheduling | Cooperative | Preemptive by OS and interpreter checks |
| Switch points | await | Thread scheduling and GIL handoff |
| Best for | Many I/O tasks | Blocking APIs, integration with sync code |
| Shared memory | Same thread usually | Shared across threads |
| Race risks | Lower but still possible | Higher |
| CPU parallelism in traditional CPython | No | Limited 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.
| Feature | Generator | Coroutine |
|---|---|---|
| Definition | def with yield | async def |
| Produces | Multiple yielded values | One final result |
| Consumer | for, next, send | await, event loop |
| Completion | StopIteration | Await result or exception |
| Main use | Lazy iteration | Async I/O coordination |
| Can await | No | Yes |
| Can yield plain values | Yes | No, 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 it36.40 Common Misunderstandings
| Misunderstanding | Correct model |
|---|---|
| Calling an async function runs it | It creates a coroutine object |
await starts a new thread | It suspends current coroutine until awaitable completes |
| Async makes CPU code parallel | It mainly helps cooperative I/O concurrency |
| A coroutine can be awaited repeatedly | A coroutine object represents one execution |
asyncio.create_task is the same as await | It schedules concurrently; await waits for completion |
| Blocking calls are fine inside async code | They block the event loop |
| Cancellation kills code immediately | It injects an exception cooperatively |
| Async uses a different interpreter | It uses CPython frames and evaluation loop |
36.41 Reading Strategy
Start with:
async def f():
return 1Then:
async def g():
value = await f()
return value + 1Inspect:
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 codeFor 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 cleared36.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 exceptionAsync 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.