Skip to content

Graceful Shutdown & Signal Handling

Every deployment is a shutdown. Kubernetes sends SIGTERM, waits out a grace period, then sends SIGKILL; systemd does the same with TimeoutStopSec; a developer presses Ctrl-C. What happens in that window decides whether a rollout is invisible to users or shows up as a spike of 502s, half-written database rows, and messages acknowledged but never processed. In an async service the window is particularly easy to get wrong, because "in flight" means dozens of tasks in arbitrary states, and the naive response — cancel everything immediately — is barely better than the SIGKILL you were trying to avoid.

A correct shutdown is a sequence, and the order is not negotiable: stop accepting new work, let existing work finish under a deadline, cancel whatever remains, release external resources, and only then exit. This section covers installing signal handlers that run on the loop, structuring the drain so it cannot hang, sizing the deadline against your orchestrator's grace period, and closing the resources — connection pools, executors, async generators — that keep a process alive after the interesting work is done. The parent overview, Resilience, Cancellation & Error Handling, covers cancellation semantics; this section applies them to the one event every process experiences.

Scope of this section:

  • Installing SIGTERM/SIGINT handlers with loop.add_signal_handler() and why signal.signal() is wrong here.
  • The shutdown sequence: stop intake, drain, cancel, close, exit — and what breaks when it is reordered.
  • Budgeting the drain against the orchestrator's grace period, with a hard cap.
  • Closing pools, executors and async generators so the process actually exits.
  • Readiness versus liveness during shutdown, and the load-balancer race that causes deploy-time 502s.

Architectural principles

  • Stop intake first. Draining while still accepting requests never terminates. Close listeners, stop consuming from the broker, and fail readiness probes before awaiting anything.
  • The drain must be bounded. Every wait during shutdown needs a deadline. An unbounded await queue.join() turns one stuck item into a SIGKILL and a corrupted deploy.
  • Cancellation is the fallback, not the plan. Tasks that finish on their own leave consistent state; cancelled tasks leave whatever their finally blocks manage. Give work a chance to finish, then cancel with intent.
  • The grace period is a hard budget. Your total shutdown must fit inside it with margin. If the orchestrator allows 30 s, plan for 20 and cap every phase.
  • Signal handlers belong on the loop. loop.add_signal_handler() schedules the callback as a loop callback; signal.signal() interrupts arbitrary bytecode and cannot safely touch loop state.
The five phases, in the only order that works A vertical sequence of the five shutdown phases with annotations. The five phases, in the only order that works fail readiness load balancer stops routing stop accepting close listeners, pause the consumer drain in flight bounded by a deadline cancel the remainder and wait a bounded time for it close pools and executors then the process can exit Reorder any two and you get either 502s or a SIGKILL mid-write.

Execution model: what a signal does to a running loop

At the OS level a signal interrupts the main thread. CPython's default handling sets a flag and runs the Python-level handler at the next bytecode boundary — which, inside a running event loop, could be in the middle of a callback, halfway through mutating a queue's internals. That is why signal.signal() handlers may only do trivial, reentrancy-safe work, and why calling asyncio APIs from one is unsafe.

loop.add_signal_handler() avoids the problem entirely. On Unix, asyncio uses signal.set_wakeup_fd() to turn a signal into a byte written to a self-pipe that the selector is already watching. The signal itself does almost nothing; on the next loop iteration the selector reports the pipe readable and your callback runs as an ordinary loop callback, with the loop in a consistent state and full access to tasks, queues and create_task(). The cost is one file descriptor and the requirement that the loop be running.

From there, shutdown is ordinary async code. The usual shape is a shutdown Event that the handler sets and that long-lived tasks select on, plus a coordinator that runs the phases. What must not happen is the handler doing the work itself: a handler that awaits cannot (it is a plain callback), and a handler that cancels every task immediately skips the drain phase entirely — the single most common cause of "we lose in-flight requests on every deploy".

How SIGTERM reaches your callback Five lifelines tracing a signal from the kernel to a loop callback. How SIGTERM reaches your callback kernel self-pipe selector loop your callback signal writes a byte fd becomes readable next iteration callback runs, loop consistent add_signal_handler turns an interrupt into an ordinary loop callback.

Pattern catalogue

The shutdown event

The simplest coordination: one Event that every long-lived task watches.

import asyncio
import signal

shutdown = asyncio.Event()


def install_handlers() -> None:
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGTERM, signal.SIGINT):
        loop.add_signal_handler(sig, shutdown.set)      # runs as a loop callback


async def consumer(queue: asyncio.Queue) -> None:
    while not shutdown.is_set():
        try:
            async with asyncio.timeout(1.0):
                item = await queue.get()
        except TimeoutError:
            continue                                     # re-check the flag
        await process(item)

The timeout inside the loop is what makes the flag observable: without it, a consumer parked on queue.get() never notices the event. On Windows, add_signal_handler() is not supported — use signal.signal() with a handler that only calls loop.call_soon_threadsafe(shutdown.set).

Stop intake, then drain

The phase order that makes shutdown terminate.

async def shutdown_sequence(server: asyncio.Server, queue: asyncio.Queue,
                            grace: float = 20.0) -> None:
    server.close()                       # 1. stop accepting new connections
    await server.wait_closed()
    READY.clear()                        # 2. fail readiness so the LB stops routing
    try:
        async with asyncio.timeout(grace):
            await queue.join()           # 3. drain what is already accepted
    except TimeoutError:
        log.warning("drain incomplete after %.0fs: %d item(s) left", grace, queue.qsize())

Every step before the drain removes a source of new work; every step after it releases resources. Reversing the first two produces the classic deploy-time 502: the process stops serving while the load balancer is still routing to it.

Cancel the remainder, deliberately

Whatever has not finished by the deadline gets cancelled — with a bounded wait for the cancellation itself.

async def cancel_remaining(exclude: set[asyncio.Task] | None = None,
                           budget: float = 5.0) -> None:
    exclude = (exclude or set()) | {asyncio.current_task()}
    tasks = [t for t in asyncio.all_tasks() if t not in exclude]
    for task in tasks:
        task.cancel()
    if not tasks:
        return
    done, pending = await asyncio.wait(tasks, timeout=budget)
    for task in pending:                 # ignored the cancellation: shielded or blocked
        log.error("task %s did not stop within %.0fs", task.get_name(), budget)

A task can refuse cancellation — by shielding, by catching CancelledError, or by blocking the loop — so the second timeout is essential. Logging the offenders by name is how the next release fixes them; naming tasks pays for itself here, as described in inspecting pending tasks.

TaskGroup-scoped shutdown

Structured concurrency makes the drain implicit: leaving the TaskGroup block waits for its children.

async def main() -> None:
    install_handlers()
    async with asyncio.TaskGroup() as tg:
        tg.create_task(serve(), name="server")
        tg.create_task(worker(), name="worker")
        await shutdown.wait()                    # wake on SIGTERM
        for task in tg._tasks:                   # ask children to wind down
            task.cancel()
    # the TaskGroup has now awaited every child, including cancellation
    await close_resources()

The group's exit is the drain, which is why TaskGroup-based structure simplifies shutdown so much. The caveat: TaskGroup waits without a deadline, so a child that ignores cancellation blocks the exit — keep the outer asyncio.run() under a supervisor timeout if that risk is real.

Releasing external resources

Work finishing is not the same as the process being able to exit.

async def close_resources(pool, client, executor) -> None:
    async with asyncio.timeout(10):
        await asyncio.shutdown_asyncgens()      # close suspended async generators
        await client.aclose()                   # HTTP connections
        await pool.close()                      # database pool
    executor.shutdown(wait=True, cancel_futures=True)   # threads/processes

asyncio.run() calls shutdown_asyncgens() for you; a hand-rolled loop must do it or suspended generators' finally blocks never run. A ThreadPoolExecutor with wait=True is the classic reason a "finished" process hangs: non-daemon threads keep the interpreter alive, and a thread blocked on a socket read holds shutdown for as long as its own timeout allows.

What each shutdown mechanism covers Five-row matrix of shutdown mechanisms and their properties. What each shutdown mechanism covers covers bounded? watch out for shutdown Event long-lived loops you add the deadline tasks parked on get() server.close() new connections yes, fast established ones stay TaskGroup exit all children no deadline a child ignoring cancellation task.cancel() one task you add the wait shielded or blocking code shutdown_asyncgens suspended generators no deadline asyncio.run does it for you No single mechanism covers a whole shutdown; the sequence composes them.

Resource boundaries

Shutdown is a budget-allocation problem: the orchestrator gives you a fixed window, and every phase must fit inside it.

Phase Typical budget (30 s grace) What overruns it
Fail readiness, wait for the LB 2–5 s LB health-check interval longer than the wait
Stop listeners / broker consumption < 1 s Broker client without an async close
Drain in-flight work 10–15 s One long request; a join() that never completes
Cancel and await cancellation 3–5 s Tasks that swallow CancelledError
Close pools, clients, executors 3–5 s executor.shutdown(wait=True) on a blocked thread
Margin 3–5 s

Two rules keep it honest. Total the phases and compare with the grace period before deploying — if the sum exceeds it, the orchestrator's SIGKILL will land mid-drain. And subtract the load balancer's de-registration delay from the front: a 5 s health-check interval means up to 5 s of new requests arriving after SIGTERM, which is exactly the window the readiness flip covers.

Integrated production example

An HTTP-ish service with a queue-backed worker pool, a two-phase signal handler (second signal forces exit), a bounded drain, and full resource cleanup.

import asyncio
import contextlib
import logging
import signal
import time

log = logging.getLogger("service")

GRACE = 20.0            # must fit inside the orchestrator's grace period
CANCEL_BUDGET = 5.0
CLOSE_BUDGET = 5.0


class Service:
    def __init__(self) -> None:
        self.shutdown = asyncio.Event()
        self.forced = False
        self.ready = True
        self.queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
        self.server: asyncio.Server | None = None
        self.inflight = 0

    # ---- lifecycle ------------------------------------------------------
    def install_signals(self) -> None:
        loop = asyncio.get_running_loop()
        for sig in (signal.SIGTERM, signal.SIGINT):
            loop.add_signal_handler(sig, self._on_signal, sig)

    def _on_signal(self, sig: signal.Signals) -> None:
        if self.shutdown.is_set():                 # second signal: stop waiting
            log.warning("second %s — forcing immediate shutdown", sig.name)
            self.forced = True
            for task in asyncio.all_tasks():
                task.cancel()
            return
        log.info("%s received — starting graceful shutdown", sig.name)
        self.ready = False                         # readiness probe fails from now on
        self.shutdown.set()

    async def worker(self, name: str) -> None:
        while True:
            try:
                async with asyncio.timeout(0.5):
                    item = await self.queue.get()
            except TimeoutError:
                if self.shutdown.is_set() and self.queue.empty():
                    return                          # nothing left for us to do
                continue
            self.inflight += 1
            try:
                await self.handle(item)
            except asyncio.CancelledError:
                log.warning("item %s cancelled mid-flight", item)
                raise
            except Exception:
                log.exception("item %s failed", item)
            finally:
                self.inflight -= 1
                self.queue.task_done()

    async def handle(self, item) -> None:
        await asyncio.sleep(0.05)                   # stand-in for real work

    # ---- shutdown -------------------------------------------------------
    async def drain(self) -> None:
        started = time.monotonic()
        if self.server is not None:
            self.server.close()                     # stop accepting connections
            await self.server.wait_closed()
        try:
            async with asyncio.timeout(GRACE):
                await self.queue.join()             # let accepted work finish
            log.info("drained cleanly in %.1fs", time.monotonic() - started)
        except TimeoutError:
            log.warning("drain timed out: %d queued, %d in flight",
                        self.queue.qsize(), self.inflight)

    async def stop_tasks(self, keep: set[asyncio.Task]) -> None:
        tasks = [t for t in asyncio.all_tasks() if t not in keep and t is not asyncio.current_task()]
        for task in tasks:
            task.cancel()
        if tasks:
            _done, pending = await asyncio.wait(tasks, timeout=CANCEL_BUDGET)
            for task in pending:
                log.error("task %s ignored cancellation", task.get_name())

    async def close(self, pool=None, client=None, executor=None) -> None:
        with contextlib.suppress(TimeoutError):
            async with asyncio.timeout(CLOSE_BUDGET):
                await asyncio.shutdown_asyncgens()
                if client is not None:
                    await client.aclose()
                if pool is not None:
                    await pool.close()
        if executor is not None:
            executor.shutdown(wait=True, cancel_futures=True)


async def main() -> None:
    svc = Service()
    svc.install_signals()
    workers = {asyncio.create_task(svc.worker(f"w{i}"), name=f"worker.{i}")
               for i in range(8)}
    producer = asyncio.create_task(ingest(svc.queue), name="ingest")

    await svc.shutdown.wait()                       # blocks until SIGTERM/SIGINT
    producer.cancel()                               # 1. stop intake
    with contextlib.suppress(asyncio.CancelledError):
        await producer
    if not svc.forced:
        await svc.drain()                           # 2. finish accepted work
    await svc.stop_tasks(keep=set())                # 3. cancel the remainder
    await svc.close()                               # 4. release resources
    log.info("shutdown complete")


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(main())

Three properties make this survivable in production. The second signal is a distinct, documented behaviour — operators expect a second Ctrl-C to mean "now", and without it they reach for SIGKILL. Every phase is bounded, so the total is predictable and can be checked against the grace period. And readiness flips first, before any awaiting, which is what stops the load balancer sending requests into a process that has begun winding down.

Diagnostic Hook — prove the drain in staging, not in production

Log four things at shutdown: time from signal to readiness=false, time to drain (and items left if it timed out), the names of any tasks that ignored cancellation, and total shutdown duration. Export the total as a histogram — it is the number that must stay comfortably under the orchestrator's grace period, and it grows silently as the service gains dependencies. In staging, deploy under load and check for a single dropped or duplicated request; in production, alert if shutdown duration exceeds 70% of the grace period, because at that point you are one slow dependency away from SIGKILL mid-write.

Fitting the phases into a 30 s grace period Six bars summing to under thirty seconds of shutdown budget. Fitting the phases into a 30 s grace period LB de-registration 5 s stop listeners < 1 s drain in flight 13 s cancel and await 4 s close resources 4 s reserve 3 s Example allocation for terminationGracePeriodSeconds = 30. Total the phases before deploying; the orchestrator will not negotiate.

Failure modes

Failure mode Root cause Detection Fix
502s during every deploy Listener closed before the load balancer de-registered Errors cluster in the first seconds of a rollout Fail readiness first, wait one health-check interval, then close
Shutdown never completes, SIGKILL follows Unbounded queue.join() or await in the drain Process exits with 137; no "shutdown complete" log Wrap every phase in asyncio.timeout()
In-flight work lost Handler cancels all tasks immediately on SIGTERM Duplicate or missing messages after a deploy Cancel only after the drain deadline expires
Process hangs after work finishes ThreadPoolExecutor with wait=True on a blocked thread Loop is idle; process still alive Give thread work its own timeout; cancel_futures=True
Async generator finally never runs Custom loop without shutdown_asyncgens() Files or connections left open after exit Use asyncio.run(), or call it explicitly
Signal ignored under load Handler installed with signal.signal() touching loop state Occasional corruption or missed shutdown Use loop.add_signal_handler()
Second Ctrl-C does nothing Handler only sets the flag, with no escalation Operators reach for kill -9 Make the second signal force cancellation

Frequently Asked Questions

How do I handle SIGTERM in an asyncio application?

Install the handler on the loop with loop.add_signal_handler(signal.SIGTERM, callback). The callback runs as an ordinary loop callback, so it can safely touch loop state — typically setting an asyncio.Event that the rest of the service watches. Avoid signal.signal() here: it interrupts arbitrary bytecode and cannot safely call asyncio APIs. On Windows, where add_signal_handler is unavailable, use signal.signal() with a handler that only calls loop.call_soon_threadsafe.

What is the correct order for graceful shutdown?

Fail readiness so the load balancer stops routing, stop accepting new work (close listeners, stop consuming from the broker), drain in-flight work under a deadline, cancel whatever remains and wait a bounded time for it, then close pools, clients and executors. Reversing the first two steps causes deploy-time 502s; skipping the deadline causes SIGKILL mid-write.

How long should the drain timeout be?

Shorter than the orchestrator's grace period minus every other phase. With a 30 second Kubernetes terminationGracePeriodSeconds, budget roughly 5 seconds for load-balancer de-registration, 10-15 for the drain, 5 for cancellation, and keep 3-5 in reserve. Measure real shutdown duration as a histogram and alert when it exceeds about 70% of the grace period.

Why does my asyncio process hang after all the work is finished?

Usually a non-daemon thread or an unclosed resource. ThreadPoolExecutor.shutdown(wait=True) blocks until every worker thread returns, and a thread blocked on a socket read holds the process for as long as its own timeout allows. Suspended async generators and open connection pools have the same effect. Close them explicitly under a timeout, and pass cancel_futures=True to the executor.

Should SIGINT and SIGTERM be handled the same way?

Broadly yes — both should trigger the same graceful sequence — but treat a repeated signal as escalation. The first sets the shutdown event; the second cancels everything immediately. Operators expect a second Ctrl-C to mean "stop now", and providing it keeps them from reaching for SIGKILL, which gives you no cleanup at all.