skip to content

Handler Registration and Delivery

Only the main thread of the main interpreter may install a handler, and the handler runs between bytecode instructions, not the instant the signal lands. Interviewers probe the latency this creates.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why can a handler installed with signal.signal() run well after the signal actually arrives?

level: middleimportance: must knowfreq 58%

answer

  1. Two handlers, not one
  2. The C one just sets a flag
  3. Runs between bytecode instructions
  4. Main thread of the main interpreter
  5. One long C call delays everything

basics

~10 s

CPython installs its own C handler, which only records that the signal is pending. Your Python function runs later, when the main thread of the main interpreter reaches its next bytecode boundary check.

solid answer

~50 s

`signal.signal()` does not hand your callable to the kernel. CPython installs a tiny C handler that runs in whatever thread the kernel interrupts, sets a per-signal pending flag plus the interpreter's eval breaker, and returns. Your Python function is called in a second stage, by **the main thread of the main interpreter**, between two bytecode instructions. So the delay is however long it takes that thread to return to the bytecode loop: a single long call into compiled C code, a huge regular-expression match or a big sort finishes first, which is exactly why Ctrl-C can appear ignored for seconds. Pending signals are flags, not a queue, so several arrivals before the check collapse into one handler call. The practical rule: keep the main thread cycling through Python bytecode, and let the handler set a flag rather than assume it ran promptly.

code

python · 16 lines
python
import signal
import threading

def on_usr1(signum, frame):
    print("handler ran on:", threading.current_thread().name)

signal.signal(signal.SIGUSR1, on_usr1)

def worker():
    print("raised from:", threading.current_thread().name)
    signal.raise_signal(signal.SIGUSR1)

t = threading.Thread(target=worker, name="worker")
t.start()
t.join()
print("main thread is:", threading.main_thread().name)

go deeper

for a junior

Recall that Ctrl-C does not always stop a program instantly, and that a Python signal handler is an ordinary function you register with signal.signal rather than something the kernel calls directly.

for a middle

Explain the two stages: CPython's C handler flags the signal, and the main thread calls your Python function at its next bytecode boundary. Be ready to say why a long C call delays it and why repeated signals coalesce.

for a senior

Show you can reason about worst-case handler latency in a real process: identify the longest uninterruptible step on the main thread, keep heavy C work off it, and design handlers that only record intent.

for a principal

Own the policy: what responsiveness to signals your services promise on shutdown, how that constrains where CPU-bound work runs, and when a language whose handlers run promptly is the better fit for a component.

## Two handlers, not one The single most useful mental model here is that every Python signal handler is really two handlers. When you call `signal.signal(signal.SIGINT, on_int)`, CPython registers *its own* C function with the operating system and stores your callable in an internal table indexed by signal number. When the kernel delivers the signal, that C function runs — in whatever thread the kernel happened to pick — and does almost nothing: it records "signal N is pending" and sets the interpreter's *eval breaker*, a flag the bytecode evaluation loop tests. Then it returns and the interrupted machine code carries on. Your Python function runs in a second, deferred stage. The bytecode loop notices the eval breaker between two instructions, sees the pending signal, and calls your handler on top of whichever frame was executing. Everything interviewers probe about signals in Python falls out of that split. ## The handler always runs on the main thread The deferred stage happens only in the main thread of the main interpreter. A worker thread that reaches a bytecode boundary does not run your handler; it leaves the flag alone. Registration is symmetric: `signal.signal()` raises `ValueError` when called anywhere else, with the message "signal only works in main thread of the main interpreter". So the responsiveness of your whole process to signals is bounded by one thread. If the main thread is parked inside something that does not return to the bytecode loop, no amount of activity elsewhere will get your handler called. ## "Between bytecodes" means *not inside one* A single bytecode that calls into C runs to completion before the check happens. A long call into a compiled extension, a pathological regular-expression match, a large sort with a C-level comparison — each is one uninterruptible unit as far as Python-level signal handling is concerned. That is the mechanism behind the classic complaint that Ctrl-C "does nothing": `SIGINT` arrived, CPython's C handler flagged it immediately, and `KeyboardInterrupt` simply cannot be raised until the eval loop regains control. A well-behaved extension that runs for a long time calls back into the interpreter's pending-signal check periodically for exactly this reason; one that does not is a black hole for signals. Releasing the GIL does not help by itself — the main thread is still inside the C call. Blocking system calls are the friendlier case: the kernel interrupts them, CPython runs the pending handler and (since 3.5) retries the call automatically, so a sleeping or reading main thread reacts quickly. ## Signals coalesce Pending state is a flag per signal number, not a counter and not a queue. Ten deliveries of the same signal before the main thread checks produce **one** handler call. Never treat handler invocations as a count of events, and never assume a burst will be replayed. ## Consequences to state in an interview * Handler latency is bounded by the main thread's longest uninterruptible step — measure that, do not assume microseconds. * Long CPU-bound C work belongs in a worker thread or in chunks, so the main thread stays in the bytecode loop. * A loop blocked in a file-descriptor wait needs the wakeup-file-descriptor mechanism to be woken promptly, because it is sitting in C. * The handler should record intent — set a flag, write a byte — and let the main loop act at a safe point. * `signal.raise_signal()` is the clean way to test all of this: it triggers the C handler and then runs the pending Python handler before returning, if you call it on the main thread. ## What has *not* changed The free-threaded build, officially supported from 3.14 (PEP 779), removes the GIL but not this design: handlers still run only in the main thread of the main interpreter, and still between bytecodes. Sub-interpreters created through the 3.14 multiple-interpreters API cannot install Python-level handlers at all. The two-stage model has been stable for the whole 3.x line, so an answer that describes it earns credit regardless of the version on the machine.

  • If the same signal is delivered five times before the main thread checks, how many times does your Python handler run?
    Once. CPython records a pending flag per signal number, not a queue or a counter, so repeated deliveries that arrive before the eval loop checks collapse into a single handler call. Code that must count events has to count them somewhere else — for example by writing one byte per delivery to a wakeup file descriptor and draining it — rather than by counting handler invocations.
  • Does the free-threaded build change which thread runs a Python signal handler?
    No. Free-threading, officially supported in 3.14 under PEP 779, removes the global lock but leaves signal handling alone: the C-level handler may run in any thread, and the Python-level handler still runs only in the main thread of the main interpreter, still between bytecode instructions. Registration outside that thread still raises ValueError.
  • How would you keep a compute-heavy program responsive to SIGINT?
    Keep the main thread returning to the bytecode loop: push long C-level work into a worker thread or split it into chunks so the eval loop regains control frequently, and let the handler only set a flag the main loop polls at a safe point. If the main thread must block on file descriptors, register a wakeup file descriptor so the C handler's write breaks the wait immediately.

The kernel drops a note in a pigeonhole; only the main thread ever checks the pigeonhole, and only between finishing one task and starting the next.

saying these in an interview costs you the question

  • Says the handler interrupts the interpreter mid-instruction
  • Thinks the handler runs in whichever thread the kernel interrupted
  • Claims a long C-extension call can be interrupted by Python code
  • Assumes each of five rapid deliveries produces a handler call
  • Believes delivery is instant because the kernel is involved

context

open as a page

What is the difference between signal.SIG_DFL and signal.SIG_IGN in signal.signal()?

level: juniorimportance: should knowfreq 46%

basics

~10 s

signal.SIG_DFL restores the operating system's default action for that signal, such as terminating the process. signal.SIG_IGN discards the signal entirely, so neither a handler nor the default action runs.

open as a page

Why is doing real work inside a signal.signal() handler unsafe, and what belongs there instead?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The handler runs on the main thread between two bytecode instructions, so it re-enters code that was mid-update and can tear a multi-step change or deadlock on a lock that code already holds. Set a flag and return.

open as a page

What does signal.set_wakeup_fd() solve that a plain signal.signal() handler cannot?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

It makes CPython's C-level handler write the signal number to a file descriptor the moment the signal lands, so a thread blocked in a file-descriptor wait such as select.select wakes immediately instead of waiting for the bytecode loop.

open as a page