Python Signal Handling — Deep Dive
How CPython Handles Signals Internally
CPython doesn’t invoke Python-level signal handlers directly from the OS signal context. Instead, it uses a two-phase approach:
Phase 1: C-level handler (immediate)
When the OS delivers a signal, CPython’s C-level handler (trip_signal() in Modules/signalmodule.c) sets a flag in a global array:
static volatile struct {
_Py_atomic_int tripped;
PyObject *func;
} Handlers[NSIG];
This is async-signal-safe — it only sets an atomic flag and writes to a “wakeup fd” (if configured). No Python code runs here.
Phase 2: Python-level dispatch (deferred)
The interpreter’s eval loop (_PyEval_EvalFrameDefault) checks Handlers[i].tripped periodically — specifically, every time it processes the eval_breaker flag (which is checked between bytecodes). When it finds a tripped signal, it calls the Python handler function.
This means:
- Python handlers are never called from inside C extensions or blocking system calls
- There’s a small delay (microseconds to milliseconds) between signal delivery and handler execution
- A long-running C extension can delay signal delivery significantly
The Wakeup FD: Self-Pipe Trick
signal.set_wakeup_fd() tells CPython to write a byte to a file descriptor whenever a signal arrives. This is essential for event-loop-based programs:
import signal
import os
read_fd, write_fd = os.pipe()
os.set_blocking(write_fd, False)
old_fd = signal.set_wakeup_fd(write_fd)
# Now the event loop can select() on read_fd
# and wake up when a signal arrives
asyncio uses this internally — the event loop monitors the wakeup FD alongside I/O file descriptors. When a signal arrives, the C handler writes to the pipe, the selector wakes up, and the loop dispatches the Python-level handler in the next iteration.
Without the wakeup FD, a signal during select()/epoll_wait() would set the flag but the loop might not check it until the select timeout expires.
Signal Safety and Re-Entrancy
What You Can’t Do in Signal Handlers
Even though Python handlers are deferred (not truly async-signal-unsafe), there are still dangerous patterns:
lock = threading.Lock()
def handler(signum, frame):
with lock: # DANGER: may deadlock if main code also holds lock
save_state()
If the signal interrupts code that already holds lock, the handler will deadlock trying to acquire it. This is the classic re-entrancy problem.
Safe patterns in handlers:
- Set a flag (boolean or
threading.Event) - Write to a pipe or socket
- Call
os._exit()(for emergency shutdown — bypasses cleanup) - Raise an exception (will propagate when the handler returns)
Unsafe patterns:
- Acquiring locks
- File I/O (can corrupt buffered writes)
- Logging (logging module uses locks internally)
- Allocating large objects (can trigger GC)
The Raise-in-Handler Pattern
class GracefulShutdown(Exception):
pass
def handler(signum, frame):
raise GracefulShutdown()
signal.signal(signal.SIGTERM, handler)
try:
main_loop()
except GracefulShutdown:
cleanup()
This works but has a subtle issue: the exception can interrupt a try/finally block between the try and finally, potentially skipping cleanup code. Python 3.x mitigates this by deferring signal delivery during critical sections, but it’s not foolproof.
Signals in Asyncio Applications
asyncio provides its own signal API that integrates with the event loop:
import asyncio
import signal
async def main():
loop = asyncio.get_running_loop()
stop_event = asyncio.Event()
def handle_signal():
print("Signal received, shutting down...")
stop_event.set()
loop.add_signal_handler(signal.SIGTERM, handle_signal)
loop.add_signal_handler(signal.SIGINT, handle_signal)
# Main application logic
server = await start_server()
await stop_event.wait()
await server.shutdown()
asyncio.run(main())
loop.add_signal_handler() is preferred over signal.signal() in async code because:
- The handler runs as a callback in the event loop, not between arbitrary bytecodes
- It’s safe to interact with async primitives (Events, Futures)
- It plays well with the wakeup FD mechanism
Production Signal Architectures
Docker and Kubernetes
Docker sends SIGTERM to PID 1 in the container, waits stop_grace_period (default 10s), then sends SIGKILL. Your application must:
- Catch SIGTERM
- Stop accepting new work
- Finish in-flight requests
- Exit with code 0
class GracefulServer:
def __init__(self):
self.shutting_down = False
signal.signal(signal.SIGTERM, self._handle_term)
def _handle_term(self, signum, frame):
self.shutting_down = True
def run(self):
while not self.shutting_down:
request = self.accept_request(timeout=1.0)
if request:
self.handle(request)
self.drain_connections()
self.close_resources()
Systemd Integration
systemd sends SIGTERM by default. For Type=notify services, use sd_notify:
import signal
import socket
import os
def notify_systemd(message):
addr = os.environ.get("NOTIFY_SOCKET")
if addr:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
sock.connect(addr)
sock.send(message.encode())
sock.close()
notify_systemd("READY=1") # tell systemd we're ready
def handle_term(signum, frame):
notify_systemd("STOPPING=1")
shutdown()
signal.signal(signal.SIGTERM, handle_term)
SIGHUP for Config Reload
Long-running daemons conventionally reload configuration on SIGHUP:
config = load_config()
def reload_config(signum, frame):
global config
print("Reloading configuration...")
try:
new_config = load_config()
config = new_config
print("Configuration reloaded successfully")
except Exception as e:
print(f"Config reload failed, keeping old config: {e}")
signal.signal(signal.SIGHUP, reload_config)
The try/except is critical — a failed reload shouldn’t crash the running server.
Cross-Platform Considerations
| Feature | Unix | Windows |
|---|---|---|
| SIGINT | ✅ | ✅ |
| SIGTERM | ✅ | ✅ (limited) |
| SIGHUP | ✅ | ❌ |
| SIGALRM | ✅ | ❌ |
| SIGUSR1/SIGUSR2 | ✅ | ❌ |
| signal.alarm() | ✅ | ❌ |
| set_wakeup_fd() | ✅ | ✅ (socket only) |
| add_signal_handler() | ✅ | SIGINT only |
On Windows, only SIGINT (Ctrl+C), SIGTERM, SIGABRT, and SIGBREAK are available. For cross-platform code, stick to SIGINT and SIGTERM.
Debugging Signal Issues
Listing Registered Handlers
import signal
for sig in signal.valid_signals():
handler = signal.getsignal(sig)
name = signal.Signals(sig).name
if handler not in (signal.SIG_DFL, signal.SIG_IGN):
print(f"{name}: custom handler {handler}")
elif handler == signal.SIG_IGN:
print(f"{name}: ignored")
Sending Signals Programmatically
import os
import signal
# To self
os.kill(os.getpid(), signal.SIGUSR1)
# To another process
os.kill(other_pid, signal.SIGTERM)
# To a process group
os.killpg(os.getpgrp(), signal.SIGTERM)
One thing to remember: Signal handlers in CPython are deferred to between bytecodes — keep them minimal (set a flag, write to a pipe), avoid locks and I/O, and use loop.add_signal_handler() in asyncio applications for safe integration with the event loop.
See Also
- Python Select And Polling How Python watches many connections at once without wasting energy — like a lifeguard scanning an entire pool.
- Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.
- Containerization Why does software that works on your computer break on everyone else's? Containers fix that — and they're why Netflix can deploy 100 updates a day without the site going down.
- Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
- Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.