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:

  1. Catch SIGTERM
  2. Stop accepting new work
  3. Finish in-flight requests
  4. 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

FeatureUnixWindows
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.

pythonsystemsadvanced

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.