Operating Systems · Processes and Threads
Signal Delivery, Thread Masks, and Which Thread Runs the Handler
How the kernel chooses a thread for a process-directed signal, why the mask is per thread while the handler is shared, and where the choice becomes deterministic.
A signal handler is a function that runs in whatever thread happens to be executing when the signal is delivered. That sentence is the source of nearly every signal bug in threaded programs, because it means the handler’s thread is not chosen by the code that installed it, not chosen by the code that sent the signal, and in the common case not chosen by anything the programmer can observe.
Two Kinds of Target
A signal can be directed at a thread or at a thread group. The distinction is in the target identifier used to send it. Sending to a thread identifier makes the signal thread-directed: it is queued against that specific task and will be delivered to it when it next returns to user space with the signal unblocked. Sending to the process identifier, which for a threaded process is the identifier shared by the group, makes the signal process-directed: it is queued against the group, and the kernel picks a recipient at delivery time.
Hardware-generated signals are thread-directed by nature. A synchronous fault such as an invalid memory access is raised in the context of the instruction that caused it, and the kernel delivers it to that thread, because no other thread has any relationship to the fault. This is why a SIGSEGV in a threaded program cannot be caught by a dedicated handler thread: the fault is the faulting thread’s, and if that thread blocks the signal, the default action terminates the process rather than the signal being redirected elsewhere.
Asynchronous signals, such as a timer expiry or a signal sent by another process, are usually process-directed. The choice of recipient is where the interesting behaviour lives.
How the Kernel Picks
The kernel maintains a pending set per thread and a pending set per thread group. A process-directed signal is placed in the group’s set, and a recipient is selected when delivery is possible. The selection walks the threads of the group and takes the first one that does not have the signal blocked and is not already handling it. If every thread blocks the signal, the signal stays pending in the group set indefinitely, and it is delivered when some thread unblocks it.
Two refinements make this less arbitrary than “first one wins”. The kernel prefers a thread that has the signal unblocked and is currently running, because delivering to a running thread avoids waking a sleeping one. It also avoids selecting a thread that is in the middle of a system call it cannot be interrupted out of. Neither refinement is a contract; they are implementation choices that reduce latency without changing the observable semantics, and a program that depends on which thread receives a signal is depending on something the interface does not promise.
Signals that are generated by a specific event are frequently delivered to the thread that caused the event. A SIGCHLD for a child of the process is process-directed, but the thread that was running when the child exited is a natural candidate, so a supervisor’s reaping loop sees the signal on whichever thread happened to be scheduled. This is why the reaping pattern described in Zombies, Orphans, and the Mechanics of Reaping needs a signal mask discipline rather than an assumption about the recipient.
The standard queueing distinction also applies. Standard signals are not queued: a second occurrence of a signal that is already pending is merged into the first, and the receiver sees one delivery. Real-time signals are queued, in order of signal number and then of arrival, and a program that needs to count events rather than merely learn that at least one occurred must use them. The boundary is a fixed range of signal numbers, and the guarantee that a queued signal carries a value is what makes them usable for event notification.
The Mask Is Per Thread, the Handler Is Not
The disposition of a signal, meaning the handler or the ignore or default setting, is a property of the thread group. Every thread sees the same handler for the same signal. The mask, the set of signals blocked from delivery, is a property of the task. Two threads in one process can therefore have different masks for the same signal, and this asymmetry is the only mechanism the interface offers for steering a signal to a chosen thread.
The steering technique is to block the signal in every thread at creation, then unblock it in exactly one thread that dedicates itself to handling it. Blocking must happen before the thread is created, because a thread inherits the mask of its creator; setting the mask after creation leaves a window in which the signal can be delivered to the new thread. The C library’s thread creation call accepts an attribute that sets the initial mask, and using it removes the window entirely.
There is a related restriction worth stating precisely: a handler installed for a process-directed signal runs on some thread of the group, and the handler may run concurrently with itself if the signal is delivered twice to two different threads. A handler that manipulates shared state without a lock is therefore unsafe even if the signal is rare, and a handler that calls a non-reentrant library function is unsafe regardless. The set of functions that are safe to call from a handler is small, and it excludes allocation, stdio, and anything that takes a lock the interrupted thread might hold.
The mask is also modified by the handler’s own execution. When a handler begins, the signal that triggered it is automatically blocked, and the mask in effect during the handler is the mask that was installed with the handler, not the mask of the interrupted thread. On return, the previous mask is restored. A handler installed without an explicit mask therefore runs with the signal blocked but with everything else as the interrupted thread had it, which is why a handler that waits on a condition variable can deadlock against the thread it interrupted.
What Interruption Costs
A signal delivered to a thread blocked in a slow system call causes that call to return early with an error, unless the call was started with a restart indication or the handler was installed with the flag that requests restart. The error is the same one used for other interruptions, and the convention is that a program retries the call unless it has a reason to abandon it.
Retrying is not always correct. A read that returned part of its data must not be retried as if nothing happened, because the retry would read again from a position that has already advanced. The general rule is that only a call which transferred nothing can be safely retried, and this is why the restart machinery exists: the kernel knows whether a restart is possible and does it on the program’s behalf when asked.
The interaction with a thread’s mask is that a blocked signal does not interrupt anything. A thread that has a signal blocked will not have its system calls interrupted by it, because the signal is not delivered. A program that blocks a signal in the main thread and expects its blocking reads to be interrupted will wait forever, and the symptom is a program that ignores a signal it thought it had handled.
The cost of the interruption itself is a trip through the kernel’s signal-delivery path: the handler frame is pushed onto the user stack, the signal mask is swapped, the handler runs, and a return sequence restores the state. The kernel arranges the return by placing a small trampoline on the stack or by pointing the return address at a kernel-provided stub, because the handler was entered by the kernel rather than by a call instruction and there is no return address for it.
Interruption is not the same thing as termination, and the mechanism that asks a thread to end itself is separate from the signal machinery: it is a flag checked at agreed points rather than a signal, as described in Thread Cancellation, Cleanup Handlers, and Deferred Delivery.
Fatal Signals in a Thread Group
A signal whose default action is to terminate the process terminates the entire thread group, not just the receiving thread. This is deliberate: the process is the unit of resource ownership, and a termination that left other threads running would leave the address space in an undefined state. The receiving thread does not get to run cleanup in the other threads; they are stopped as the group exits.
A handler can change this for signals whose disposition is under program control, but not for the ones the kernel treats as unrecoverable. A fault such as a stack overflow or an invalid instruction may be raised again immediately after the handler returns, because the instruction that faulted is retried and faults identically. A handler for such a signal is useful for logging and for terminating in an orderly way, not for recovery.
The exit path for a fatal signal has an observable consequence for the parent. The status word reports termination by signal rather than a normal exit, and the signal number is in the low bits of the status. A supervisor that distinguishes clean shutdown from a crash reads that field, which is why the status word’s packing is worth understanding even though the macros are the correct way to decode it.
Where the Model Cannot Help
Two situations have no clean solution within the mechanism. The first is a signal that must be handled by a specific thread for a reason the kernel cannot know, such as a signal that means “cancel the operation this thread is performing”. The interface can steer delivery by masking, but it cannot name a recipient at send time for a process-directed signal. Thread-directed sending solves it, at the cost of the sender knowing the target’s identifier.
The second is a handler that must run to completion without being interrupted by the same signal again. The automatic blocking of the triggering signal during the handler covers the common case, but a handler installed for several signals that are delivered in quick succession can be re-entered through a different signal number, and the mask installed with the handler must account for every signal the handler touches. Getting this wrong produces a handler that corrupts its own state, with a failure that appears only when two signals arrive close together.
Neither problem is a defect in the interface. Both follow from the decision to make the handler a shared property and the mask a private one, which is the only arrangement that lets a program express “this code handles the signal, and no other code will be interrupted by it”. The arrangement costs the program an explicit discipline at thread creation, and the cost of skipping that discipline is a program whose behaviour depends on scheduling.
References
- signal(7) — overview of signals — Linux man-pages project
- POSIX.1-2017, System Interfaces — pthread_sigmask() — IEEE Std 1003.1-2017
- Advanced Programming in the UNIX Environment, 3rd edition — Chapter 10, Signals — Stevens and Rago; Addison-Wesley
- sigaction(2) — examine and change a signal action — Linux man-pages project