Python signal handling in production is less about catching signals and more about ensuring your application gracefully exits when one arrives.
Let’s watch a simple web server respond to signals.
import signal
import time
import os
import sys
class GracefulExit(Exception):
pass
def signal_handler(signum, frame):
print(f"Received signal {signum}, initiating graceful shutdown...")
raise GracefulExit("Exiting gracefully.")
def run_server():
print(f"Server running with PID: {os.getpid()}")
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
try:
while True:
print("Server is processing requests...")
time.sleep(5)
except GracefulExit as e:
print(e)
# Perform cleanup here (e.g., close database connections, save state)
print("Server shut down.")
sys.exit(0)
except KeyboardInterrupt: # For Ctrl+C if SIGINT handler isn't set up
print("KeyboardInterrupt caught, shutting down.")
sys.exit(1)
if __name__ == "__main__":
run_server()
Save this as server.py.
Now, run it in one terminal:
python server.py
You’ll see output like:
Server running with PID: 12345
Server is processing requests...
Server is processing requests...
In another terminal, send a SIGTERM signal. This is what kill <pid> or container orchestrators like Kubernetes send to stop a process:
kill 12345
The first terminal will output:
Received signal 15, initiating graceful shutdown...
Server shut down.
Now, try sending SIGINT (which is what Ctrl+C usually sends):
kill -INT 12345
(Or, if the server is still running, just press Ctrl+C in its terminal). The output will be similar:
Received signal 2, initiating graceful shutdown...
Server shut down.
This script defines a signal_handler function that raises a custom exception. By registering this handler for SIGTERM and SIGINT using signal.signal(), we intercept these signals. When a signal arrives, our handler is invoked, prints a message, and raises GracefulExit. The main while loop is wrapped in a try...except GracefulExit block, catching this exception and performing necessary cleanup before exiting. The sys.exit(0) indicates a clean shutdown.
The core problem signal handling solves is preventing abrupt process termination. When a process receives SIGTERM or SIGINT without a handler, the default action is to terminate immediately. This can leave resources like open files, database connections, or partially written data in an inconsistent state. By catching these signals and performing cleanup, you ensure data integrity and a smoother transition during deployments or restarts.
The difference between SIGTERM and SIGINT is primarily convention and how they are sent. SIGINT is typically generated by the terminal driver when you press Ctrl+C. SIGTERM is a more general-purpose termination signal, often used by system administrators or orchestrators to request a process to shut down. While their default actions are the same (terminate), your application can treat them distinctly if needed, though usually, a single graceful exit path handles both.
Crucially, signals interrupt the normal flow of Python execution. If your application is deep within a blocking C extension or a long-running computation that doesn’t yield control back to the Python interpreter, the signal might not be delivered or handled immediately. This is why it’s essential that your signal handler raises an exception, allowing the Python interpreter to regain control and process the exception flow. If the handler just printed a message and returned, the while True loop would simply continue without noticing the signal.
A common pitfall is forgetting to re-register signal handlers if you are using multiprocessing. Each child process inherits signal dispositions from the parent, but if a child process explicitly sets a new handler for a signal, that handler is specific to that child. If you need consistent signal handling across all processes in a multiprocessing pool, you’ll need to set the handlers in the target function executed by each worker, or ensure the parent sets them before forking.
The signal module in Python is not thread-safe. Signal handlers are installed globally for the process. If you have a multi-threaded application, signals are delivered to any thread, but the handler is executed in the thread that receives the signal. This can lead to race conditions if your signal handler accesses shared resources modified by other threads without proper locking. For this reason, it’s often recommended to have a dedicated "main" thread that handles signals and then communicates the shutdown request to other threads via a thread-safe mechanism like a threading.Event or a queue.
The most surprising thing about Python signal handling is that the default behavior for SIGINT (and SIGTERM) when running a script directly from the terminal is that the interpreter itself often catches it and raises a KeyboardInterrupt exception, even before you explicitly register a handler. This is why try...except KeyboardInterrupt often works for Ctrl+C out of the box. However, relying on this implicit handling is fragile, especially in production environments where signals might be sent programmatically or by orchestrators. Explicitly registering your handlers with signal.signal() gives you deterministic control.
The next concept you’ll likely grapple with is how to propagate this graceful shutdown signal across multiple processes or threads effectively, especially when dealing with complex application architectures.