Linux signalfd Moves Signal Delivery Into File-Descriptor Readiness

signalfd() changes the consumption interface for selected Linux signals. Instead of arranging for an asynchronous signal handler to run when one of those signals is delivered, a process can block the signals and receive their information by reading a file descriptor. That descriptor can participate in poll(), epoll, and related readiness mechanisms, so signal handling can share the same dispatch boundary as sockets, pipes, timers, and other pollable objects.

The mechanism does not replace Linux signal semantics. Signal generation, process- and thread-directed delivery, masks, pending state, and standard-versus-real-time queuing rules still apply. signalfd changes the interface used to consume signals that are pending and included in its mask.

The signal mask is part of the delivery contract

A signalfd is created with a signal set. A successful read returns one or more signalfd_siginfo records for pending signals contained in that set. For this model to prevent ordinary asynchronous delivery, those signals also need to be blocked in the threads that could otherwise receive them.

That distinction matters because the mask passed to signalfd() and a thread’s blocked-signal mask have separate roles. The descriptor mask selects which pending signals may be consumed through the descriptor. The thread mask controls which signals are blocked from ordinary delivery to that thread.

A common process-level arrangement blocks the selected set before creating additional threads. New threads inherit the creator’s signal mask, so the blocked state propagates through normal thread creation. A dedicated event-loop thread can then monitor the descriptor.

sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGHUP);

if (pthread_sigmask(SIG_BLOCK, &mask, NULL) != 0)
    fail();

int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
if (sfd == -1)
    fail();

This pattern is a process design constraint, not a property automatically enforced by signalfd(). If another thread leaves a selected signal unblocked, Linux may deliver an eligible process-directed signal to that thread through the normal signal mechanism instead of leaving it pending for the descriptor.

Reading consumes pending signals

Readiness on a signalfd means at least one signal matching the descriptor mask is pending. read() transfers structured signalfd_siginfo records and consumes the corresponding pending signals. A buffer must be large enough for at least one complete record; larger buffers can receive multiple records in one operation.

With SFD_NONBLOCK, a read with no matching pending signal fails with EAGAIN. That makes the descriptor compatible with nonblocking event-loop patterns. With level-triggered readiness, unread matching signals keep the descriptor readable. With edge-triggered epoll, the same drain discipline used for other nonblocking descriptors applies: processing should continue until no matching record can be read without blocking.

The record carries fields derived from signal information, including the signal number and, when applicable, sender PID, sender UID, queued integer data, timer data, or child status. Availability and meaning depend on the signal source. Applications still need signal-specific interpretation rather than treating every field as universally populated.

Standard and real-time signals retain different queue semantics

signalfd does not turn every signal occurrence into a durable queue entry. Standard Linux signals do not queue multiple instances in the same manner as real-time signals. If a standard signal is already pending, another instance of that same signal can collapse into the existing pending state. Reading from signalfd therefore cannot reconstruct an occurrence count that the underlying signal model never retained.

Real-time signals have queuing semantics and preserve multiple pending instances subject to system resource limits. Their ordering rules also remain signal rules rather than properties introduced by the file descriptor.

This boundary is important for event-loop design. A SIGTERM record can represent the pending termination signal state, but it is not a reliable counter of every attempt to send SIGTERM. If an application requires lossless counting or payload delivery, the communication primitive has to provide those semantics explicitly.

Process-directed and thread-directed signals keep their targeting rules

Linux distinguishes signals directed at a process from signals directed at a particular thread. A process-directed signal may be delivered to an eligible thread according to the kernel’s signal-delivery rules. A thread-directed signal targets the specified thread.

signalfd operates within that existing model. A thread can read signals that are pending for the process and signals pending for the reading thread when they match the descriptor mask. It does not provide a generic mechanism for one thread to consume a signal that is pending specifically for another thread.

That constraint can become visible when libraries create threads or manipulate signal masks internally. Centralized signal consumption depends on maintaining a coherent masking policy across the process. Code that silently unblocks a centrally managed signal can reopen asynchronous delivery on that thread.

SIGKILL and SIGSTOP remain outside interception

SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. Including either signal in the mask supplied to signalfd() does not make it consumable through the descriptor. Linux silently excludes them from the effective set used by the interface.

This is a kernel-level boundary, not an event-loop limitation. A design that routes ordinary shutdown signals through signalfd still cannot convert forced termination or forced stop into application-managed events.

Descriptor lifetime and mask updates are separate state

Calling signalfd() with fd set to -1 creates a new descriptor. Passing an existing signalfd descriptor updates the signal set associated with that descriptor. Updating the descriptor mask does not update any thread’s blocked-signal mask. Applications that change the set dynamically must keep both pieces of state consistent with the intended delivery policy.

SFD_CLOEXEC controls descriptor inheritance across execve() in the same manner as close-on-exec state on other descriptors. SFD_NONBLOCK controls blocking behavior for reads. Neither flag changes which signals are blocked in threads.

After fork(), the child inherits the descriptor, but readiness monitoring has additional process-boundary details. In particular, an epoll instance inherited across fork() does not report child-generated signals through a signalfd registration established in the parent in the same way as a registration created for the child’s own event loop. Processes that continue independently after a fork should establish event ownership explicitly rather than treating inherited polling state as a transparent cross-process signal router.

A file descriptor narrows the asynchronous execution surface

Traditional signal handlers execute asynchronously relative to ordinary program flow and are restricted to operations that are safe in that context. Moving selected signal consumption to signalfd means the application can process those signals during ordinary event-loop execution instead. The handler path no longer needs to mutate shared state from an asynchronous signal context for those blocked signals.

That architectural benefit has a precise limit: it applies only while the masking policy keeps those signals on the pending path consumed by signalfd. Other unblocked signals can still invoke handlers or take their default actions. The process therefore has two independent dimensions to maintain: signal disposition and signal masking.

The resulting interface is useful when an application already treats readiness as its synchronization boundary. signalfd lets selected signal state enter that boundary as structured reads, while Linux signal targeting, pending-state semantics, and non-interceptable signals remain intact underneath it.