signalfd in Multithreaded Embedded Services: Make the Signal Mask Consistent
Create a signalfd-based control path only after arranging the same blocked signal set in every thread that could otherwise receive those process-directed signals. The simplest startup sequence blocks the set in the initial thread before creating workers, then gives one dispatcher responsibility for reading the signalfd. Creating the descriptor alone does not change anyone’s signal mask.
This guide follows Linux man-pages 6.12 and uses SFD_NONBLOCK and SFD_CLOEXEC, available since Linux 2.6.27. It covers ordinary service-control signals, not synchronous hardware faults or a general crash handler. All multithreaded and signal-delivery fixtures described below are proposed; no signals or thread-mask changes were executed for this article.
Build a signal-ownership table
List each signal, its producer, whether it is process-directed or thread-directed, the intended consumer, and the state change it requests. A shutdown request, reload request and child-state notification need different application handling even when all arrive through one descriptor. Do not reserve a signal already used internally by the threading library or another component.
The Linux signal model gives each thread its own signal mask. A process-directed signal can be delivered to an eligible thread that does not block it. Blocking it only in the event-loop thread leaves an unblocked worker available to take the default action or run an installed handler. This explains a service that sometimes terminates instead of producing a signalfd record.
Signal dispositions are a different layer from masks. A mask postpones ordinary delivery in that thread; it does not install a handler, make the signal process-wide blocked, or ensure a particular queue capacity. Record inherited masks and dispositions at service startup rather than assuming a launcher left the default environment.

Make initialization precede worker creation
- Construct the exact control-signal set using signal-set APIs.
- In the initial thread, block that set with pthread_sigmask and check its returned error number directly.
- Create the signalfd with the same set and nonblocking/close-on-exec flags; fail startup visibly if it cannot be created.
- Create workers only after the initial mask is established, and keep their signal-ownership obligations in the thread-entry contract.
- Register the descriptor with the event loop and assign one consumer for the control records.
The pthread_sigmask documentation states that a new thread inherits a copy of its creator’s signal mask. This is why ordering matters. If a library starts threads earlier, blocking signals later in the main thread cannot retroactively change those workers. Either move initialization earlier or use a coordinated per-thread setup with explicit acknowledgements before accepting control requests.
A worker that creates further threads passes on its own current mask. Audit all thread creation paths, including lazy library initialization and replacement workers after failures. One late thread with an altered mask can invalidate a startup test that originally passed.
Read records as a stream of notifications
The signalfd interface returns one or more complete signalfd_siginfo records per successful read. Supply a buffer large enough for at least one record, iterate over the returned records, and interpret fields according to the signal and ssi_code. A nonblocking EAGAIN means no eligible record is currently available to that reader.
Reading consumes the returned pending signals. Do not run an independent sigwait consumer for the same signals and expect both it and signalfd to receive every request. If multiple signalfds or threads compete, ownership becomes a consumption race rather than a broadcast design.
Standard signals coalesce while pending. Two identical reload requests need not produce two records. Make reload mean “reconcile with the current authoritative configuration” when that fits the application. If every command and payload must survive, use a bounded message channel with admission and acknowledgment rather than counting standard-signal deliveries.
Distinguish process and thread destinations
A thread reading a signalfd can consume signals directed to itself and eligible signals directed to the process. It cannot drain a signal pending specifically for another thread. Therefore pthread_kill directed at a worker is not a valid substitute for a process-directed request expected by a central dispatcher.
This distinction also limits fault handling. A synchronous fault associated with an instruction is not an ordinary cross-thread control message. Do not block SIGSEGV and claim that a central signalfd makes invalid memory access recoverable. SIGKILL and SIGSTOP cannot be received through signalfd at all; including them in its mask does not create a catchable shutdown path.
| Proposed case | Expected diagnostic boundary |
|---|---|
| Process-directed control signal, blocked everywhere | Designated signalfd reader can consume the process’s pending signal. |
| Same signal unblocked in a worker | Ordinary delivery can bypass the intended descriptor path. |
| Signal directed specifically to another worker | Central reader cannot consume that worker’s private pending signal. |
| Repeated standard signal while pending | Coalescing is permitted; record count cannot prove command count. |
Recheck fork, exec and shutdown boundaries
After fork, the child inherits the descriptor, but reads there concern signals pending for that child. The documented epoll interaction is more subtle: an inherited signalfd registration does not make the child’s epoll wait report its new child-directed signals as expected. Create and register a child-specific signalfd after fork, or delay separate setup until both processes exist.
Close-on-exec prevents descriptor inheritance into an executed helper, but the blocked signal mask itself survives exec. A helper that expects ordinary SIGTERM delivery therefore needs the intended mask restored in its controlled launch path. Closing the signalfd alone does not unblock signals. Keep mask changes coordinated with pending signals and lifecycle state so teardown does not unexpectedly release a control signal into a default action.
Qualify startup with a bounded disposable test process: signal before worker creation, after all workers start, while a worker is busy, and while a replacement worker is created. Add a deliberate wrong-mask negative case inside that isolated process and a separate thread-directed case. Record recipient, mask, descriptor result and application action rather than merely counting wakeups.
The final deliverable is a thread-creation and signal-ownership map. It should explain how every worker gets the mask, who consumes records, what coalescing means, and how helpers regain their intended signal environment. A signalfd descriptor in a process listing is insufficient evidence that the control path is consistently installed.