Shutting Down Async Generators and Executors Cleanly¶
The drain completed, the log says "shutdown complete", and the process is still there thirty seconds later, doing nothing, until the orchestrator kills it. Nothing is running on the event loop; the CPU is idle. What is keeping the interpreter alive is almost always one of two things: a non-daemon thread inside a ThreadPoolExecutor that is still blocked on a syscall, or a suspended async generator whose finally block never ran because nobody closed it — leaving a connection, a file, or a lock held.
Both are cleanup problems rather than logic problems, and both have exact fixes. This guide covers what asyncio.shutdown_asyncgens() does and when asyncio.run() does it for you, closing generators explicitly with aclose(), tearing down thread and process pools without waiting forever, finding the thread that will not exit, and ordering the whole cleanup phase so it fits inside the grace period.
Prerequisites¶
- Python 3.11+; standard library only (
asyncio,concurrent.futures,threading,faulthandler). - The graceful shutdown and signal handling overview for the phase this belongs to, and CPU-bound task offloading for how the executors got there.
1. Understand what keeps the process alive¶
Exit does not happen when the loop stops; it happens when the last non-daemon thread returns and the interpreter finalises.
import threading
for t in threading.enumerate():
print(f"{t.name:<28} daemon={t.daemon} alive={t.is_alive()}")
# MainThread daemon=False alive=True
# ThreadPoolExecutor-0_0 daemon=False alive=True <- this blocks exit
# asyncio_1 daemon=True alive=True
Since Python 3.9, ThreadPoolExecutor threads are non-daemon and are joined by an atexit hook, which is exactly why a pool with a blocked worker prevents exit. ProcessPoolExecutor adds a second layer: child processes plus the management thread that feeds them. Async generators are different — they hold no thread, but their suspended frames hold resources until closed. Verify: print the thread table right before the final return in main(); every non-daemon thread listed there must be one you intend to wait for.
2. Close async generators explicitly¶
A suspended async generator's finally runs only when the generator is closed. asyncio.run() does this for you at the end; a hand-managed loop does not.
import asyncio
async def rows(conn):
cursor = await conn.cursor()
try:
while (row := await cursor.fetchone()) is not None:
yield row
finally:
await cursor.close() # runs on aclose(), not on garbage collection
async def consume(conn) -> None:
agen = rows(conn)
try:
async for row in agen:
if row.done:
break # early exit leaves the generator suspended
finally:
await agen.aclose() # explicit: the finally above runs here
The break is the case that catches people: exiting an async for early leaves the generator parked at its yield with the cursor open, and relying on garbage collection is unsafe because finalisation may happen after the loop closes — the source of "Task was destroyed but it is pending" and leaked cursors. contextlib.aclosing() wraps this pattern neatly. Verify: add a print to the finally and confirm it appears immediately after the early break, not at process exit.
3. Call shutdown_asyncgens when you own the loop¶
asyncio.run() calls it; loop.run_until_complete() in a hand-rolled entry point does not.
import asyncio
def main() -> None:
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(app())
finally:
try:
loop.run_until_complete(loop.shutdown_asyncgens()) # close suspended agens
loop.run_until_complete(loop.shutdown_default_executor())
finally:
loop.close()
shutdown_asyncgens() walks every async generator the loop knows about and awaits aclose() on each, so their cleanup runs while the loop is still alive to await it. shutdown_default_executor() (Python 3.9+) does the same for the implicit thread pool behind asyncio.to_thread(), which is otherwise a common source of a lingering thread. Verify: run with a deliberately abandoned generator; without these calls you see a "coroutine ignored GeneratorExit" or an unclosed-resource warning at exit, and with them you do not.
4. Shut executors down without waiting forever¶
shutdown(wait=True) blocks until every worker returns, and a worker blocked on a socket read returns when it decides to.
import asyncio
from concurrent.futures import ThreadPoolExecutor
POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="io")
async def close_pool(pool: ThreadPoolExecutor, budget: float = 5.0) -> bool:
"""Cancel queued work, then wait a bounded time for running work."""
pool.shutdown(wait=False, cancel_futures=True) # drop what has not started
loop = asyncio.get_running_loop()
try:
async with asyncio.timeout(budget):
await loop.run_in_executor(None, pool.shutdown, True) # join off-loop
return True
except TimeoutError:
return False # a worker is still blocked
cancel_futures=True (Python 3.9+) discards queued-but-unstarted work immediately, which is usually most of the backlog. Doing the blocking join in another executor keeps the event loop responsive so your timeout can actually fire — a direct pool.shutdown(wait=True) on the loop thread would block the loop and the timeout with it. When the budget expires, the honest options are to exit anyway (accepting that atexit will block, so the work must be idempotent) or to give the underlying calls their own timeouts so they cannot block indefinitely in the first place. Verify: submit a job that sleeps 60 s, shut down with a 5 s budget, and confirm the function returns False after 5 s rather than hanging.
5. Tear down process pools and their children¶
ProcessPoolExecutor failures are noisier because the resources are OS processes.
import asyncio
from concurrent.futures import ProcessPoolExecutor
async def close_process_pool(pool: ProcessPoolExecutor, budget: float = 10.0) -> None:
pool.shutdown(wait=False, cancel_futures=True)
loop = asyncio.get_running_loop()
try:
async with asyncio.timeout(budget):
await loop.run_in_executor(None, pool.shutdown, True)
except TimeoutError:
for proc in list(getattr(pool, "_processes", {}).values()):
proc.terminate() # SIGTERM to the child
await asyncio.sleep(1.0)
for proc in list(getattr(pool, "_processes", {}).values()):
if proc.is_alive():
proc.kill() # SIGKILL: no cleanup, but bounded
Children of a process pool inherit nothing about your shutdown, so a worker in a long C-level computation cannot be interrupted cooperatively — escalation is the only tool. Note that a SIGTERM sent to a container's process group hits the children too, which usually helps, but is not something to rely on. Verify: run a pool task that spins for 60 s, shut down, and confirm no orphaned children remain (ps --ppid <pid> is empty after the sequence).
6. Find the thread that will not exit¶
When a process still hangs, one command tells you where.
import faulthandler
import signal
faulthandler.register(signal.SIGUSR2, all_threads=True) # install at startup
kill -USR2 <pid> # dumps a traceback for every thread, including blocked ones
# Thread 0x7f...(io_3):
# File "/srv/app/legacy.py", line 88 in fetch_report
# File "/usr/lib/python3.12/socket.py", line 705 in readinto <- blocked here
The deepest frame names the blocking call, and the fix is almost always to give that call a timeout rather than to fight the executor. faulthandler works on threads that are blocked in C code, which is exactly where a py-spy-free environment leaves you guessing. Verify: trigger the dump during a hang and confirm you can name the file and line holding the process open.
Verification¶
- Exit is prompt. After the drain, the process exits within the cleanup budget — not at the grace period boundary.
- No unclosed-resource warnings. Shutdown produces no "Task was destroyed but it is pending", no unclosed transport or cursor warnings.
- No orphans. After exit, no child processes and no leftover file descriptors attributable to the service remain.
Pitfalls and edge cases¶
- Relying on garbage collection for generators. Finalisation may run after the loop closes, so the
finallycannot await anything; close explicitly withaclose()orcontextlib.aclosing(). pool.shutdown(wait=True)on the loop thread. It blocks the loop, so your timeout never fires and the whole service freezes during shutdown.- Forgetting the default executor.
asyncio.to_thread()uses an implicit pool; a hand-rolled loop must callshutdown_default_executor(). - Daemon threads as a fix. Marking pool threads daemon lets the interpreter exit mid-write; it converts a hang into corruption.
- Cancelling the future does not stop the thread.
future.cancel()only works before the work starts; a running thread keeps going regardless. - Blocking calls with no timeout. The root cause of most hangs: give every socket, file, and SDK call in a worker its own timeout.
- Process pools and locks. A child killed mid-write can leave a shared lock or a partially written file; make worker output atomic (write to a temp file, then rename).
Frequently Asked Questions¶
Why does my asyncio program hang at exit even after the loop stops?
A non-daemon thread is still running. ThreadPoolExecutor threads are non-daemon and joined by an atexit hook, so a worker blocked on a socket read or a long computation holds the interpreter open. Print threading.enumerate() before the final return, and send SIGUSR2 with faulthandler registered to see exactly which frame is blocking.
What does asyncio.shutdown_asyncgens() do?
It closes every async generator the loop is tracking by awaiting aclose() on each, so their finally blocks run while the loop is still alive. asyncio.run() calls it automatically at the end; a hand-managed loop must call it explicitly before loop.close(), otherwise suspended generators are finalised later — or never — and the resources they hold leak.
How do I shut down a ThreadPoolExecutor with a timeout?
Call shutdown(wait=False, cancel_futures=True) to discard queued work, then perform the blocking join in another executor via run_in_executor so it can be wrapped in asyncio.timeout. A direct shutdown(wait=True) on the loop thread blocks the loop itself, which prevents the timeout from firing at all.
Do I need aclose() if I always iterate the generator to completion?
Not strictly — a generator that runs to exhaustion executes its finally block as part of finishing. The problem is early exit: any break, exception, or cancellation inside the async for leaves the generator suspended with its cleanup pending. Since those paths are easy to add later, wrapping iteration in contextlib.aclosing() by default is cheaper than auditing every loop.
Related¶
- Graceful Shutdown & Signal Handling — the parent overview: sequence, budget, and where this phase fits.
- Draining in-flight requests before shutdown — the phase before this one.
- CPU-Bound Task Offloading — sizing and using the executors this teardown closes.