How does asyncio.run() finalize async generators that are left suspended at shutdown?
answer
- cleanup that needs to await cannot use __del__
- the loop keeps a set of them
- a hook fires on first iteration
- closing throws GeneratorExit at the yield
- aclosing makes it deterministic instead
basics
~10 sAfter cancelling the remaining tasks, asyncio.run() awaits loop.shutdown_asyncgens(), which calls aclose() on every async generator still suspended at a yield inside that loop, so their finally blocks run before the loop is closed.
solid answer
~40 sAn async generator suspended at a `yield` cannot be cleaned up by the garbage collector, because closing it may need to `await`. CPython solves this with `sys.set_asyncgen_hooks()`: the running loop installs hooks so that the first iteration of an async generator registers it with the loop, and a generator collected while suspended has its `aclose()` scheduled on the loop. `loop.shutdown_asyncgens()` then awaits `aclose()` on everything still registered, which throws `GeneratorExit` in at the suspended `yield` and runs the `finally`. `asyncio.run()` and `asyncio.Runner.close()` do this for you; a hand-driven `run_until_complete()` followed by `loop.close()` does not, and the cleanup is silently lost. For deterministic cleanup, close at the point of use with `contextlib.aclosing()` or an explicit `await agen.aclose()` — shutdown finalization is a safety net, not a plan.
code
python · 16 linesimport asyncio
async def batches():
try:
for i in range(1000):
yield i
finally:
print("generator cleanup: flush the last batch")
async def main():
async for i in batches():
if i == 2:
break
print("main returned; generator still suspended")
asyncio.run(main())go deeper
Know that an async generator you stop iterating early is left suspended, and that its finally block runs later rather than at the break. Recognise that the entry point handles this for you.
Explain why cleanup cannot happen in del (closing may await), name the loop's finalization step, and show contextlib.aclosing as the way to close at the point of use instead.
Argue about where the flush should live. Show the silent-data-loss failure when a loop is driven by hand, and explain why cleanup deferred to shutdown may find its dependencies already destroyed.
Set the house rule: entry points go through the standard runner, buffered work is flushed by an owner with a live context rather than by a shutdown hook, and completeness is verified by reconciliation rather than by the absence of errors.
## Why async generators need special handling A synchronous generator abandoned mid-iteration is cleaned up by the garbage collector: CPython throws `GeneratorExit` in at the suspended `yield`, the `finally` runs, done. An **async** generator cannot be handled that way, because its cleanup may contain `await` — closing a stream, releasing a connection, flushing a buffer. There is no way to await anything from a `__del__`; awaiting requires a running event loop and a task to run in. CPython's answer is a pair of hooks. `sys.set_asyncgen_hooks(firstiter=..., finalizer=...)` installs, per thread, two callbacks: `firstiter` fires the very first time an async generator is iterated, and `finalizer` fires when a still-suspended generator is about to be collected. The asyncio event loop installs its own pair when it starts running. Its `firstiter` records the generator in a weak set owned by the loop; its `finalizer` schedules `agen.aclose()` as a task, so the cleanup gets a live loop to run on. The hooks are per-thread, and registration happens on **first iteration**, not at creation. An async generator object created but never iterated is not registered anywhere and has nothing to clean up; one first iterated inside a loop belongs to that loop for finalization purposes. ## What `shutdown_asyncgens()` does `loop.shutdown_asyncgens()` is a coroutine. It takes everything still in that weak set and awaits `aclose()` on each one, gathering results so a failure in one does not abort the rest; a generator whose cleanup raises is reported through the loop's exception handler. `aclose()` throws `GeneratorExit` in at the suspended `yield`, which unwinds the generator body and runs any `finally`. In `asyncio.Runner.close()` this is step two of four: cancel and await the tasks, **finalize the async generators**, join the default executor, close the loop. The ordering is deliberate — generators are finalized after your tasks are gone but while the loop is still alive and able to run awaits. ## The failure it prevents, and when it does not Consider a webhook receiver that decodes inbound events and hands them out in batches: ```python async def batches(stream): buf = [] try: async for event in stream: buf.append(event) if len(buf) == 500: yield buf buf = [] finally: if buf: await archive(buf) # flush the partial batch ``` A consumer that `break`s out on the first malformed payload leaves this generator suspended at the `yield`. Under `asyncio.run()` the flush still happens, at shutdown. Under a hand-rolled `loop.run_until_complete(main()); loop.close()` it never happens: no cancellation is involved, no exception is raised, nothing is logged. The archive is simply short by up to 499 events — a silent truncation that nobody notices until someone reconciles counts weeks later. That is the whole argument for using `asyncio.run()` or `asyncio.Runner` rather than driving a loop yourself. ## Why shutdown finalization is still a weak plan Even when it runs, finalization at shutdown happens *after every task has been cancelled*. Anything the cleanup depends on may already be gone: the connection was owned by a task that just died, the session's `__aexit__` already ran, the destination is closed. The cleanup then raises inside `shutdown_asyncgens()`, gets logged through the exception handler, and you are back to losing the flush — with one more log line. The deterministic fix is to close where you use it: ```python from contextlib import aclosing async with aclosing(batches(stream)) as stream_of_batches: async for batch in stream_of_batches: if bad(batch): break # aclose() runs here, not at shutdown ``` `contextlib.aclosing()` (3.10+) awaits `aclose()` on the way out of the block, so the `finally` runs at a moment when everything it needs is still alive. Treat `shutdown_asyncgens()` as the backstop that catches what you forgot. ## The neighbouring step The step immediately after generator finalization is `loop.shutdown_default_executor()`, which joins the thread pool behind `asyncio.to_thread()`. On 3.14 `asyncio.run()` gives that join a five-minute budget; if the worker threads have not finished by then, a `RuntimeWarning` is emitted and shutdown proceeds without waiting. Both steps share a theme: the loop is finalizing resources that outlived your tasks, and both are skipped entirely if you close a loop by hand. ## What to say in an interview Name the mechanism (`sys.set_asyncgen_hooks()` and the loop's weak set), name the step (`loop.shutdown_asyncgens()`), and say what breaks without it — a suspended generator's `finally` never running, which loses exactly the buffered data that a `finally` exists to flush. Then say you would not rely on it, and reach for `contextlib.aclosing()`. ## A quick way to see it Run a program that breaks out of an `async for` over a generator whose `finally` prints. The print appears *after* the line that follows the loop in `main()` — proof that the cleanup was deferred to the runner's finalization step rather than performed at the `break`. Wrap the same generator in `contextlib.aclosing()` and the two lines swap order. That five-line experiment is worth more than any amount of reasoning about when cleanup "should" happen.
- What breaks if you drive the loop yourself with run_until_complete() and then close it?You skip both finalization steps. Suspended async generators never run their `finally`, so buffered data is silently dropped, and the default executor's threads are never joined. Use `asyncio.run()` or an `asyncio.Runner` context manager, or replicate the order by hand: cancel and gather the tasks, await `shutdown_asyncgens()`, await `shutdown_default_executor()`, then close.
- Why is finalization at shutdown still an unreliable place to flush data?It runs after every task has been cancelled, so resources the cleanup needs may already be closed — the connection belonged to a task that just died. The `finally` then raises inside `shutdown_asyncgens()`, is logged through the loop's exception handler, and the flush is lost anyway. Close deterministically with `contextlib.aclosing()` while the surroundings are alive.
- How would you detect that a generator's cleanup is failing during shutdown?Install your own handler with `loop.set_exception_handler()` so shutdown-time errors reach your logging pipeline with context instead of the default asyncio logger, and assert on it in tests: run the program under a controlled stop, then check both the log stream and the completeness of whatever the generator was supposed to flush.
saying these in an interview costs you the question
- Thinks the garbage collector runs an async generator's finally
- Assumes closing the loop finalizes suspended generators
- Believes breaking out of async for closes the generator immediately
- Drives a loop by hand and closes it without finalizing
- Says shutdown_asyncgens also joins the default executor threads
- Treats shutdown finalization as a reliable place to flush data