Graceful Asyncio Shutdown on OS Signals in Python
Gracefully shutting down a Python asyncio application on
operating system signals (such as SIGINT or
SIGTERM) requires intercepting the termination request,
canceling in-flight tasks, and ensuring all ongoing cleanup logic
completes before the event loop closes. By default, signals either
abruptly kill the process or raise an unhandled
KeyboardInterrupt that skips crucial resource deallocation
like flushing logs or closing network sockets. This article explains the
underlying mechanism Python provides for capturing OS signals in
asynchronous programs and presents the standard pattern for a clean,
deterministic shutdown.
How Signals Interact with the Event Loop
Operating system signals are delivered asynchronously at the thread
level. In standard Python, signals interrupt the main thread's
synchronous execution. Within an asyncio application,
standard interrupts can terminate execution mid-coroutine, bypassing
cleanup blocks (try...finally or asynchronous context
managers).
To handle this cleanly on POSIX-compliant systems,
asyncio provides loop.add_signal_handler().
This method registers a callback directly with the event loop. When the
operating system delivers a signal, Python schedules the registered
callback inside the event loop itself, keeping control flow within the
asynchronous context.
The Graceful Shutdown Lifecycle
A proper shutdown sequence follows four distinct phases:
- Signal Interception: Catch the signal
(
SIGINTorSIGTERM) via the event loop callback. - Task Cancellation: Retrieve all currently running
tasks using
asyncio.all_tasks(), exclude the shutdown task itself, and call.cancel()on each one. - Awaiting Cancellation: Gather the canceled tasks
using
asyncio.gather(*tasks, return_exceptions=True). This allows running coroutines to handleasyncio.CancelledError, executefinallyblocks, and clean up. - Loop Termination: Stop asynchronous generators,
close remaining thread pools, and halt the loop with
loop.stop().
Implementation Pattern
The following example demonstrates a robust, production-ready pattern compatible with standard Python POSIX environments:
import asyncio
import signal
import sys
async def worker(task_id: int):
"""Simulates a long-running task that needs clean teardown."""
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
# Perform task-specific cleanup here
print(f"Task {task_id} canceled and cleaned up.")
raise
async def shutdown(sig, loop):
"""Collects and cancels all running tasks, allowing cleanup."""
print(f"Received exit signal {sig.name}...")
# Identify all active tasks
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
# Trigger cancellation on all running tasks
for task in tasks:
task.cancel()
print(f"Canceling {len(tasks)} outstanding tasks.")
# Wait until all tasks have finished handling CancelledError
await asyncio.gather(*tasks, return_exceptions=True)
# Stop the loop after tasks are completely flushed
loop.stop()
async def main():
loop = asyncio.get_running_loop()
# Register OS signal handlers (Unix-like systems)
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(
sig,
lambda s=sig: asyncio.create_task(shutdown(s, loop))
)
# Spawn background tasks
await asyncio.gather(worker(1), worker(2), worker(3))
if __name__ == "__main__":
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
print("Application stopped successfully.")Platform Considerations: Windows
The loop.add_signal_handler() API relies on OS-level
signal mechanics not present in the standard Windows event loop
implementation (ProactorEventLoop), which raises a
NotImplementedError if invoked.
On Windows, applications must rely on the standard library's
signal.signal(signal.SIGINT, handler) or wrap the execution
of asyncio.run() in a standard
try...except KeyboardInterrupt block. Once caught at the
top-level entry point, cleanup logic must explicitly retrieve the loop
and execute the task cancellation cycle manually before exiting.