Why can't signal.alarm time out a blocking call running in a worker thread?
answer
- Signals belong to the process, not a thread
- The C handler only sets a flag
- One thread checks that flag between bytecodes
- Registering and arming have different rules
- A Timer schedules; it cannot interrupt
basics
~20 sCPython runs every Python signal handler in the main thread. The SIGALRM handler's exception unwinds the main thread's stack, while the worker thread stays blocked exactly where it was. Signals cannot interrupt one specific thread.
solid answer
~50 sTwo separate restrictions get conflated here. First, `signal.signal` refuses to register a handler from anywhere but the main thread of the main interpreter — it raises `ValueError`. Second, and more important, even when the handler is registered correctly, CPython always *runs* it in the main thread: the C-level handler only sets a flag, and the main thread's eval loop picks it up between bytecodes. So a `SIGALRM` handler that raises `TimeoutError` unwinds the main thread, not the worker that is actually blocked; the worker keeps running to completion. `signal.alarm` itself is callable from a worker, since it just arms the process-wide timer, but that does not help. To bound work off the main thread you need a call that accepts its own deadline, or a child process you can terminate — `threading.Timer` schedules a callback, it does not interrupt anyone.
code
python · 22 linesimport signal, threading, time
def on_alarm(signum, frame):
raise TimeoutError("deadline")
signal.signal(signal.SIGALRM, on_alarm)
def slow():
time.sleep(3)
print("worker finished anyway")
t = threading.Thread(target=slow, daemon=True)
t.start()
signal.alarm(1)
try:
t.join()
except TimeoutError as exc:
print("main raised:", exc)
finally:
signal.alarm(0)
print("worker still alive:", t.is_alive())
time.sleep(2.5)go deeper
Remember the one rule that covers most cases: signal handling in Python happens on the main thread. If your timeout code lives in a worker, it is not going to fire there, however correct the arming call looks.
Explain the split cleanly — signal.signal raises ValueError off the main thread, while signal.alarm arms happily from anywhere — and say why the handler still runs in the main thread's eval loop. Know that threading.Timer schedules rather than interrupts.
Diagnose the real symptom: a timeout that appears to work because the main thread's join or wait raised, while the worker ran to completion and held a resource open. Reach for a real timeout argument, a killable child process, or a watchdog that closes the resource.
Own the design consequence: if deadlines must be enforced, threads are the wrong isolation boundary, because CPython gives no cancellation primitive at all. Decide early whether a workload needs a process boundary, and set the expectation that thread-level timeouts are cooperative by nature.
Signals are a **process**-level facility, not a thread-level one, and CPython layers a further simplification on top: all Python-level signal handling happens in the main thread. Understanding those two sentences is the whole answer. ### What the kernel does, and what CPython does When `SIGALRM` is delivered, the C handler CPython installed runs on whichever thread the kernel picked. That handler is deliberately tiny: it records which signal arrived and sets a flag telling the interpreter that a signal is pending. It cannot call Python code — almost nothing is safe to do inside a C signal handler. The actual Python callable you registered runs later, from the eval loop, **and only from the main thread's eval loop**. The main thread checks the pending-signal flag between bytecode instructions, calls your handler, and if that handler raises, the exception propagates from whatever the main thread happened to be executing. A worker thread never checks that flag. It has no opportunity to run your handler and no exception is ever injected into it. If the worker is parked inside a blocking read, it stays parked. ### The two restrictions people mix up **Registration is main-thread-only.** Calling `signal.signal(signal.SIGALRM, handler)` from a worker raises `ValueError: signal only works in main thread of the main interpreter`. That is a hard, explicit refusal, and it is the one most people remember. **Arming is not restricted.** `signal.alarm` and `signal.setitimer` are thin wrappers over a process-wide interval timer, and calling them from a worker thread works fine — no exception, the timer arms, the signal is delivered on schedule. This is the trap: the code looks like it worked. It did arm a timer; it just armed one whose handler will fire in a different thread and interrupt a different stack. `signal.getsignal` is likewise callable anywhere. So "you can't arm an alarm off the main thread" is the wrong summary. The right one is: *you can arm it anywhere, and it will never interrupt the thread you armed it from unless that thread is the main thread.* ### What actually happens in the mixed case Suppose the main thread waits on `Thread.join()` while a worker performs a slow read, and the main thread arms a one-second alarm. The alarm fires, the handler raises in the main thread, and `join()` raises `TimeoutError`. That is genuinely useful — you have bounded your own *wait*. But the worker is untouched: it finishes its read later and, if it is not a daemon, it will still hold process exit open. You have a timeout on the observer, not on the work. ### Why threading.Timer is not a substitute `threading.Timer` is a `Thread` subclass that sleeps for an interval and then calls a function *in its own thread*; `cancel()` stops it if it has not fired yet. It works off the main thread, it works on Windows, and it needs no signal machinery. What it cannot do is interrupt anything: raising an exception inside the timer's own thread affects only that thread. It is a scheduler for a side effect — flip a flag, close a socket so a blocked reader sees an error, log a warning — never a cancellation primitive. That last variant is the practical bridge: a timer thread that *closes the resource* the worker is blocked on will unblock the worker, because the blocking call then fails with an OS error. You are still not cancelling the thread; you are removing what it was waiting on. ### The honest options for bounding off-main-thread work 1. **Use a real deadline on the call.** A socket's own timeout setter, a client library's timeout argument, a queue `get(timeout=...)`. Always first choice. 2. **Move the work into a child process** and terminate it when the deadline passes. This is the only mechanism that gives a hard kill, and it is what you fall back to for compiled work that never returns to the interpreter. 3. **Close the underlying resource from a watchdog** so the blocked call errors out. What is *not* available in CPython, at any version through 3.14, is cancelling or killing a running thread from outside. There is no `Thread.kill`, by design: a thread torn down at an arbitrary point leaves locks held and buffers half-written. Signals do not offer a back door to it, and that is the real content of this question.
- What does threading.Timer actually give you, and what can it never do?It is a Thread subclass that waits an interval and then calls a function in its own thread, with `cancel()` to stop it before it fires. It works off the main thread and on Windows, and needs no signal machinery. It cannot interrupt another thread: the most it can do is flip a flag or close the resource a blocked thread is waiting on, so that call fails and returns.
- Is signal.alarm itself callable from a worker thread?Yes, and that is the trap. It wraps a process-wide interval timer, so it arms successfully from any thread and the signal is delivered on time. Only `signal.signal` registration is main-thread-only. The handler still runs in the main thread, so arming from a worker to bound that worker's own blocking call is legal and useless.
- So how do you enforce a deadline on work that must not run on the main thread?Give the call a real timeout argument if it has one; otherwise run the work in a child process and terminate it when the deadline passes, or have a watchdog close the socket or file the worker is blocked on. CPython offers no way to kill or cancel a running thread from outside, and that is deliberate — an arbitrary teardown would leave locks held.
The alarm bell is wired to one desk in the building. Anyone can set it, but only the person at that desk ever hears it and drops what they are holding.
saying these in an interview costs you the question
- Thinks the handler runs in whichever thread was interrupted
- Believes threading.Timer can cancel a blocked call
- Claims signal.alarm cannot be called off the main thread
- Assumes each thread has its own signal handlers
- Expects a Thread object to expose a kill or cancel method
- Confuses arming the timer with registering the handler