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
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:
@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 aFuturemeaning “wake me when this is done”, orNonefromasyncio.sleep(0), meaning “let others run”.- The value passed to the next
send()becomes the result of the innermostawait. The loop sendsNoneand the future’s result is read from the future. - Returning raises
StopIterationcarrying the return value. That is howawait corogets a result and how aTaskcompletes. throw(exc)raises an exception at the pausedawait; that is how cancellation is delivered (CancelledError) and whyfinallyblocks run.close()throwsGeneratorExitthe same way.
What await compiles to
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 awaitThat 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.
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.
Related
Interview angle 5
- “What does calling an
async deffunction do?” — nothing runs: it returns a coroutine object, a suspended frame like a generator’s. Something must drive it, normally the event loop via aTask.- 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 finalsend()raisesStopIterationwith the return value in.value.
- Follow-up: “Drive one without an event loop.” —
- “What does
awaitdo?” — it delegates to an awaitable, likeyield 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 theawait.- Follow-up: “How is a task cancelled, mechanically?” — the loop calls
throw(CancelledError())on the coroutine, which raises at the pausedawait, sotry/finallyandasync withclean up.
- Follow-up: “How is a task cancelled, mechanically?” — the loop calls
- “Why does
time.sleep()in a coroutine freeze the server?” — the loop can only switch when a coroutine yields, andtime.sleepnever 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?” — explicitawaitmakes every switch point visible and keeps C extensions from blocking silently, at the price of function colour.