How CPython Implements async generators in C
The PyAsyncGenObject struct, ag_code, firstiter/finalizer hooks, and the aclose machinery traced through CPython 3.15's Objects/genobject.c.
Python’s async def functions containing yield become async generators: objects
that implement __anext__, asend, athrow and aclose. Unlike a regular
generator, an async generator is also an awaitable: calling anext(ag) does not
run code immediately, it returns an async_generator_asend awaitable that is
driven by the event loop. In CPython 3.15 all of this lives in
Objects/genobject.c (there is no separate asyncgenobject.c), sharing the same
interpreter-frame machinery as generators and coroutines.
The object struct
PyAsyncGenObject is defined through the shared _PyGenObject_HEAD macro, so its
layout is identical to a generator’s except for the ag_ prefix:
// Include/internal/pycore_interpframe_structs.h
struct _PyAsyncGenObject {
PyObject_HEAD
PyObject *ag_weakreflist;
PyObject *ag_name;
PyObject *ag_qualname;
_PyErr_StackItem ag_exc_state;
PyObject *ag_origin_or_finalizer;
char ag_hooks_inited;
char ag_closed;
char ag_running_async;
int8_t ag_frame_state;
_PyInterpreterFrame ag_iframe;
};The two async-specific fields are ag_closed (set once the generator has
received GeneratorExit) and ag_origin_or_finalizer, which doubles as storage
for the thread’s async-generator finalizer hook. The frame itself is the same
_PyInterpreterFrame used by every other callable, so an async generator reuses
the normal eval loop and yield/YIELD_VALUE machinery. PyAsyncGen_New
allocates through gen_new_with_qualname and then initializes the async fields:
// Objects/genobject.c
PyObject *PyAsyncGen_New(PyFrameObject *f, PyObject *name, PyObject *qualname) {
PyAsyncGenObject *ag;
ag = (PyAsyncGenObject *)gen_new_with_qualname(&PyAsyncGen_Type, f,
name, qualname);
...
ag->ag_origin_or_finalizer = NULL;
ag->ag_closed = 0;
ag->ag_hooks_inited = 0;
ag->ag_running_async = 0;
return (PyObject*)ag;
}ag_code
Like gi_code/cr_code, ag_code is a read-only attribute backed by the frame’s
executable. ag_getcode just forwards to _gen_getcode, which returns a new
reference to _PyGen_GetCode(gen) (i.e. _PyFrame_GetCode(&gen->ag_iframe)):
// Objects/genobject.c
static PyObject *
ag_getcode(PyObject *gen, void *Py_UNUSED(ignored)) {
return _gen_getcode((PyGenObject*)gen, "ag_code");
}
static PyObject *
_gen_getcode(PyGenObject *gen, const char *const name) {
if (PySys_Audit("object.__getattr__", "Os", gen, name) < 0) {
return NULL;
}
return Py_NewRef(_PyGen_GetCode(gen));
}It is registered in async_gen_getsetlist as {"ag_code", ag_getcode, NULL, NULL}.
Because the code object is reachable through the (possibly cleared) frame, ag_code
remains available even after the generator is closed.
Async generator hooks
CPython exposes two per-thread hooks, sys.set_asyncgen_firstiter and
sys.set_asyncgen_finalizer, stored on the thread state as
tstate->async_gen_firstiter and tstate->async_gen_finalizer (see
Python/ceval.c). The firstiter hook is invoked the first time an async
generator is iterated; the finalizer is stored on the object for later use.
Hook initialization is lazy and guarded by ag_hooks_inited, so it happens on
the first call to anext/asend/aclose/athrow:
// Objects/genobject.c
static int async_gen_init_hooks(PyAsyncGenObject *o) {
PyThreadState *tstate;
PyObject *finalizer;
PyObject *firstiter;
if (o->ag_hooks_inited) return 0;
o->ag_hooks_inited = 1;
tstate = _PyThreadState_GET();
finalizer = tstate->async_gen_finalizer;
if (finalizer) {
o->ag_origin_or_finalizer = Py_NewRef(finalizer);
}
firstiter = tstate->async_gen_firstiter;
if (firstiter) {
PyObject *res;
Py_INCREF(firstiter);
res = PyObject_CallOneArg(firstiter, (PyObject *)o);
Py_DECREF(firstiter);
if (res == NULL) return 1; /* propagate exception */
Py_DECREF(res);
}
return 0;
}Note the ordering: the finalizer is stored (ag_origin_or_finalizer), while the
firstiter callback is called immediately and its result discarded. If the firstiter
callback raises, the error propagates out of anext() etc.
anext/asend/athrow are awaitables
None of these run the generator directly. async_gen_anext returns an
async_generator_asend object (with sendval == NULL), and async_gen_asend
wraps the supplied argument. These awaitables (PyAsyncGenASend) carry state
(AWAITABLE_STATE_INIT / _ITER / _CLOSED) plus the generator and the value to
send. Driving them via .send()/__next__() calls gen_send, then
async_gen_unwrap_value converts a wrapped yield value into a StopIteration:
// Objects/genobject.c
static PyObject *async_gen_unwrap_value(PyAsyncGenObject *gen, PyObject *result) {
if (result == NULL) {
if (!PyErr_Occurred()) PyErr_SetNone(PyExc_StopAsyncIteration);
if (PyErr_ExceptionMatches(PyExc_StopAsyncIteration) ||
PyErr_ExceptionMatches(PyExc_GeneratorExit)) {
gen->ag_closed = 1;
}
gen->ag_running_async = 0;
return NULL;
}
if (_PyAsyncGenWrappedValue_CheckExact(result)) {
_PyGen_SetStopIterationValue(((_PyAsyncGenWrappedValue*)result)->agw_val);
Py_DECREF(result);
gen->ag_running_async = 0;
return NULL; /* signals "yielded a value" via StopIteration */
}
return result;
}ag_running_async guards re-entrancy: while a generator is running, a second
__anext__() raises “asynchronous generator is already running”.
aclose
async_gen_aclose is implemented as athrow(GeneratorExit) in “aclose mode”
(agt_typ == NULL). It creates an async_generator_athrow awaitable and, on
first send, marks the generator closed and throws GeneratorExit with
close_on_genexit = 0 (so a suspended yield from is not auto-closed
immediately, allowing the async generator to clean up via awaits):
// Objects/genobject.c (async_gen_athrow_send, aclose mode)
if (o->agt_typ == NULL) {
/* aclose() mode */
o->agt_gen->ag_closed = 1;
retval = _gen_throw((PyGenObject *)gen, 0,
PyExc_GeneratorExit, NULL, NULL);
if (retval && _PyAsyncGenWrappedValue_CheckExact(retval)) {
Py_DECREF(retval);
goto yield_close; /* generator yielded during close -> RuntimeError */
}
}If the generator yields a value while being closed, CPython sets
ASYNC_GEN_IGNORED_EXIT_MSG (“async generator ignored GeneratorExit”) and raises
RuntimeError. Otherwise, on completion, StopAsyncIteration/GeneratorExit
are swallowed and converted to StopIteration, signalling that the aclose()
awaitable is done:
// Objects/genobject.c
if (PyErr_ExceptionMatches(PyExc_StopAsyncIteration) ||
PyErr_ExceptionMatches(PyExc_GeneratorExit)) {
if (o->agt_typ == NULL) {
PyErr_Clear();
PyErr_SetNone(PyExc_StopIteration);
}
}The ag_closed flag is also consulted by subsequent asend/athrow calls,
which then immediately raise StopAsyncIteration without running the body again.
Finalization
gen_finalize (registered as tp_finalize) handles unawaited async generators:
if the frame is not finished and ag_closed is false, it invokes the stored
finalizer (ag_origin_or_finalizer) so that unclosed async generators are
reported (and typically closed) rather than silently leaked.
Summary
PyAsyncGenObjectshares_PyGenObject_HEAD(ag)with generators; async-specific state isag_closed,ag_running_async,ag_hooks_initedandag_origin_or_finalizer.ag_codeis a read-only getset returning the frame’s code object.- Hooks are initialized lazily on first iteration: the finalizer is stored, firstiter is called immediately.
anext/asend/athrowreturn awaitables (PyAsyncGenASend/PyAsyncGenAThrow) that drive the generator viagen_send/gen_throw.acloseisathrow(GeneratorExit)in a special mode that marks the generator closed, forbids yielding, and converts terminal exceptions toStopIteration.
Further reading
Objects/genobject.c–PyAsyncGen_Type,async_gen_*, awaitable types.Include/internal/pycore_interpframe_structs.h–_PyAsyncGenObject.Include/cpython/pystate.h–async_gen_firstiter/async_gen_finalizer.Python/ceval.c–_PyEval_SetAsyncGenFirstiter/_PyEval_SetAsyncGenFinalizer.