Skip to content

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

What is still holding the process open Five-row matrix of shutdown holders and their release mechanisms. What is still holding the process open holds released by ThreadPoolExecutor non-daemon threads shutdown(), joined at exit ProcessPoolExecutor child processes shutdown(), then terminate/kill async generator a suspended frame + resources aclose() or shutdown_asyncgens() default executor threads behind to_thread() shutdown_default_executor() open transport a socket and its buffers close() then wait_closed() Only the first two block exit; the rest leak resources silently.

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.

Cleanup order after the drain A vertical sequence of the four cleanup steps. Cleanup order after the drain close async generators their finally needs a live loop close clients and pools flush what is buffered shut down the default executor to_thread's hidden pool join explicit executors, bounded then escalate if needed Generators first: their cleanup is async and needs the loop that is about to close.

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.

Finding the thread that will not exit A pipeline of four diagnostic steps for a hanging process. Finding the thread that will not exit process still alive loop idle kill -USR2 faulthandler dump read the deepest frame names the blocking call give that call a timeout the actual fix The executor is rarely the bug; the timeout-less call inside it is.

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 finally cannot await anything; close explicitly with aclose() or contextlib.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 call shutdown_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.