A conventional POSIX signal can interrupt a thread at almost any instruction boundary and transfer control to a signal handler. That asynchronous control flow imposes strict limits on handler code and complicates programs whose main control plane already runs through epoll, poll, or select.
Linux signalfd() provides a different delivery interface. A process blocks selected signals in the normal signal mask, creates a signalfd for that set, and receives pending signals by reading structured signalfd_siginfo records from the descriptor. The signal remains a signal at the kernel interface; only its consumption moves into ordinary file-descriptor I/O.
Blocking is part of the delivery contract
Creating a signalfd does not automatically prevent the same signals from reaching a traditional handler. The target signals must be blocked, commonly with sigprocmask() in a single-threaded process or pthread_sigmask() in a multithreaded process.
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGHUP);
pthread_sigmask(SIG_BLOCK, &mask, NULL);
int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);Blocking changes the point at which the signal is consumed. A matching pending signal can then be returned by read() on the signalfd instead of invoking a handler for that blocked signal.
Thread masks require special care. Signal masks are per-thread, and a newly created thread inherits a copy of its creator’s mask. Programs that intend centralized signal consumption therefore commonly establish the mask before creating worker threads.
Each read returns structured signal metadata
The descriptor returns one or more struct signalfd_siginfo records. A successful read must use a buffer large enough for at least one complete record.
struct signalfd_siginfo si;
ssize_t n = read(sfd, &si, sizeof(si));
if (n == sizeof(si)) {
if (si.ssi_signo == SIGTERM) {
/* begin shutdown */
}
}Fields can include the signal number, sending PID and UID, queued integer or pointer data, timer information, and other metadata whose relevance depends on the signal source. This avoids moving state through global variables merely to communicate from an asynchronous handler to the main loop.
The descriptor is consumptive: reading a pending signal removes that signal instance from the pending set represented by the signalfd operation. The same pending instance is not also available for separate synchronous consumption afterward.
Standard and real-time signals retain different queue semantics
signalfd() does not convert standard signals into a fully queued message channel. Standard signals can coalesce while blocked: multiple occurrences of the same standard signal may be represented by a single pending instance.
Real-time signals have queueing semantics and ordering rules defined for POSIX real-time signal delivery. signalfd() preserves those underlying signal semantics rather than replacing them with descriptor-specific queue rules.
That distinction matters when an application treats the descriptor as an event source. A standard signal such as SIGHUP is suitable for a state-change notification where repeated arrivals may collapse. It is not a reliable counter for every occurrence.
epoll can treat signals like another readiness source
A signalfd is pollable. When a matching signal is pending, readiness mechanisms can report the descriptor as readable.
int ep = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = {
.events = EPOLLIN,
.data.fd = sfd,
};
epoll_ctl(ep, EPOLL_CTL_ADD, sfd, &ev);This allows one event loop to coordinate sockets, pipes, timers, and signals without an asynchronous handler writing into a self-pipe. The descriptor does not make signal generation synchronous; it makes consumption compatible with the file-descriptor readiness model.
SFD_NONBLOCK is useful when the event loop drains available records until read() returns EAGAIN. SFD_CLOEXEC prevents accidental descriptor inheritance across execve() unless inheritance is explicitly required.
The descriptor mask can be replaced
Calling signalfd() with an existing signalfd descriptor updates the signal set associated with that descriptor.
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
if (signalfd(sfd, &mask, SFD_CLOEXEC | SFD_NONBLOCK) == -1) {
/* handle error */
}Updating the descriptor mask and updating thread signal masks are separate operations. Changing one does not implicitly synchronize the other. A signal omitted from the signalfd set but still blocked can remain pending with no matching read path through that descriptor.
Process-directed signals still interact with thread selection
A signalfd does not erase POSIX distinctions between process-directed and thread-directed signals. For process-directed signals, the kernel may select an eligible thread according to normal signal-delivery rules. Centralized consumption works reliably when the relevant signals are blocked in all threads that should not receive them asynchronously.
Thread-directed signals also retain their targeting semantics. A signalfd read cannot be treated as a universal mailbox for signals directed to unrelated threads.
This boundary is central to robust designs: signalfd() changes the interface used to accept selected pending signals, but it does not replace the process and thread signal model.
Descriptor-based delivery narrows asynchronous code
The practical value of signalfd() is architectural. Signal policy can remain inside the same state machine that owns the rest of an event-driven service. Shutdown, reload, and child-management transitions can execute in ordinary control flow rather than inside an async-signal context.
That model still depends on correct masking, inherited thread state, and the queue semantics of each signal class. With those boundaries explicit, signalfd() turns selected Linux signal consumption into a normal pollable I/O path without changing the signals themselves.