Linux signalfd: Consume Signals in an Event Loop Without Async Handlers
Integrate Linux signals with poll or epoll using signalfd, block the intended signal mask correctly, and preserve signal coalescing and thread semantics.
Traditional Unix signal handlers interrupt the flow of a program. The handler runs asynchronously with respect to ordinary application code, so it is restricted in which functions it can safely call and how it can interact with shared state. Linux signalfd offers another model: selected blocked signals can be read from a file descriptor and monitored by poll or epoll alongside sockets, timers, and other event sources.
A signalfd does not make signals into a reliable message queue. Standard signals can coalesce while pending, signal masks are per-thread, and the kernel still applies signal delivery rules. The design is useful when an application wants a single event loop to handle termination requests or child-state notifications, but it must preserve the semantics of the signals it consumes.
Block signals before creating worker threads
Create a signal set for the asynchronous signals the event loop will own, and block that set in the thread before creating worker threads. New threads inherit the creator’s signal mask, which helps keep those signals from being delivered to an arbitrary worker with an unrelated mask. In a multithreaded program, use pthread_sigmask to manage the calling thread’s mask; do not assume that changing one thread’s mask changes every other thread’s mask.
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <stddef.h>
#include <sys/signalfd.h>
#include <unistd.h>
int create_control_signal_fd(void) {
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGCHLD);
int error = pthread_sigmask(SIG_BLOCK, &mask, NULL);
if (error != 0) {
errno = error;
return -1;
}
return signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
}
This function must run before the process starts threads that should inherit the blocked mask. If the process already has worker threads, update their masks deliberately or redesign ownership so the intended signal is blocked everywhere it could otherwise be delivered. Creating a signalfd with a mask does not by itself block the signals. If a selected signal remains unblocked in a thread, the normal disposition or handler can still receive it instead of the event-loop descriptor.
SIGKILL and SIGSTOP cannot be received through signalfd. Synchronous signals caused by faults in the executing thread, such as a segmentation fault, are not general-purpose control notifications and should not be redirected into an event loop. Preserve normal handling for programming faults and use signalfd for asynchronous signals whose ownership is intentionally defined.
Read records from the descriptor
When one or more matching signals are pending, a read returns one or more fixed-size signalfd_siginfo records. The buffer should be sized for whole records, and the returned byte count should be checked for alignment before iterating. With SFD_NONBLOCK, an empty descriptor returns EAGAIN instead of sleeping, which is appropriate when epoll has already reported readiness.
#include <errno.h>
#include <signal.h>
#include <stddef.h>
#include <sys/signalfd.h>
#include <unistd.h>
int drain_signal_fd(int signal_fd) {
struct signalfd_siginfo records[8];
for (;;) {
ssize_t n = read(signal_fd, records, sizeof(records));
if (n > 0) {
if ((size_t)n % sizeof(records[0]) != 0) {
errno = EIO;
return -1;
}
size_t count = (size_t)n / sizeof(records[0]);
for (size_t i = 0; i < count; i++) {
switch (records[i].ssi_signo) {
case SIGINT:
case SIGTERM:
/* Request orderly shutdown through normal application state. */
break;
case SIGCHLD:
/* Reap all waitable children; one signal is not one child. */
break;
default:
/* Record or reject signals outside the declared protocol. */
break;
}
}
continue;
}
if (n == -1 && errno == EINTR)
continue;
if (n == -1 && errno == EAGAIN)
return 0;
return -1;
}
}
The sample is an event-loop handler outline, not a complete shutdown implementation. Do not block in a long shutdown operation while processing the descriptor. Convert the received signal into an application state transition, wake the correct component, and let regular code perform cleanup with normal synchronization and error handling.
Signals are not queued uniformly
Standard signals generally represent a pending condition rather than a count of every occurrence. If the same standard signal is generated repeatedly while already pending, occurrences may coalesce into one pending signal. Do not use repeated SIGTERM or SIGCHLD delivery as a work queue where every occurrence must be counted.
Real-time signals have queueing behavior and ordering rules that differ from standard signals, but they still have finite limits and delivery semantics. If the application requires durable per-event payloads, use an explicit IPC or work-queue protocol instead of expanding signal use until it resembles a message bus. A signal can notify a process to inspect authoritative state; it should not be the only record of critical work.
SIGCHLD illustrates why the signal count is not a child count. Several children may change state before the event loop reads one pending SIGCHLD. On receipt, loop over waitpid or an equivalent child-status API until no more state is available. A pidfd can provide a more direct file-descriptor lifecycle for an individual process, but it does not remove the need to understand child reaping and exit status.
Thread-directed and process-directed signals
Signal masks are maintained per thread. A signal sent to a specific thread has different eligibility from a signal directed to the process as a whole. A process-directed signal can be delivered to an eligible thread that does not block it, while a thread-directed signal is associated with its target thread. If several threads use signalfd with overlapping masks, a pending signal is consumed once by one eligible read; it is not broadcast to every descriptor.
Choose one owner for control signals where possible. Block them in all threads that should not receive them asynchronously, centralize the signalfd read in one event loop, and document how worker components request shutdown. Multiple descriptors with overlapping masks can be useful, but they complicate reasoning about which loop consumes each signal and where the associated state transition occurs.
If the process changes the signalfd’s mask using an existing descriptor, coordinate the update with all threads and with event-loop registration. A mask change does not retroactively create a signal or transform already handled state. Treat mask changes as a protocol transition, not as a casual runtime toggle.
Integrate with epoll without blocking
Register the signalfd with epoll using the same lifecycle discipline as any other descriptor. Under level-triggered operation, leave the descriptor readable until records are consumed. Under edge-triggered operation, read records until EAGAIN so no pending records are left behind without a future edge. Handle EINTR, unexpected short reads, descriptor closure, and errors as explicit states.
The event loop should not assume that an epoll event corresponds to exactly one signal. One read can return several records, and one pending standard signal may represent many generated occurrences. If a control signal triggers expensive work, enqueue a shutdown or reload request and return to the loop rather than performing the entire operation in the signal-drain path.
Set close-on-exec at descriptor creation so child programs do not accidentally inherit the signal channel. At shutdown, remove the descriptor from epoll, stop any thread that can read it, close the descriptor once ownership is clear, and restore or discard the process mask only according to the application’s lifecycle. A leaked descriptor can keep a control channel alive after the component that owned it has gone away.
Choose signalfd versus a handler or sigwait
Use signalfd when signals should become ordinary file-descriptor events in a Linux event loop. Use a signal handler when the program needs a small asynchronous action that cannot wait for an event loop, while obeying async-signal-safety restrictions. Use sigwait or sigwaitinfo when a dedicated thread should synchronously wait for a signal set without integrating it into poll.
These are different ownership models. Do not install a handler for a signal and also expect signalfd to own it without a carefully defined disposition and mask policy. Do not block a synchronous fault signal and expect the descriptor to recover the faulting instruction safely. Document which component owns SIGINT, SIGTERM, SIGHUP, SIGCHLD, and any application-specific real-time signals.
For child lifecycle monitoring, a signalfd carrying SIGCHLD can fit an existing event loop, but it is still a process-level notification. If the application needs per-process readiness or exit notification and can use pidfds, those descriptors may produce clearer ownership. Use the mechanism that matches the lifecycle data the program needs rather than stacking multiple notification systems without a clear contract.
Verify signal ownership under concurrency
Test that signals are blocked before worker threads are created, that only the intended event loop consumes the configured set, and that a burst of standard signals is handled as a coalesced condition. Exercise multiple child exits before one SIGCHLD read and prove that every child is reaped. Verify that SIGINT or SIGTERM requests shutdown without running unsafe cleanup from an asynchronous handler.
In diagnostics, record the signal number, process and thread IDs, receipt time, and resulting state transition. Do not log secrets or assume that a logged signal means the requested action completed. Separate “shutdown requested” from “shutdown completed,” and keep enough state to diagnose a process that received a request but became blocked during cleanup.
signalfd makes Linux signals composable with descriptor-based event loops, but the process still follows signal masks, pending-state, and thread-delivery rules. Block the right set, centralize ownership, drain reads correctly, and treat each signal as a notification whose payload and lifecycle must be handled elsewhere.
Related:
- Linux epoll Readiness Semantics: Edge-Triggered I/O, Lifetimes, and Fairness
- Linux pidfds: Race-Free Process Handles Beyond Numeric PIDs
Sources: