Unix signals are asynchronous by design: a signal can interrupt a program between ordinary instructions and transfer control to a signal handler. That model is useful, but it creates an awkward boundary for event-driven programs.
A network server may already spend most of its time inside poll(), epoll_wait(), or another readiness API. Its sockets, pipes, and timers appear as file-descriptor events, while SIGTERM and SIGHUP arrive through a separate execution path with much stricter rules about what code may safely run.
Linux provides another option: signalfd(). It lets a program block selected signals and receive information about them by reading a file descriptor. The signal then becomes something the same event loop can wait for alongside sockets and other descriptors.
The useful mental model is:
traditional handler: signal -> asynchronous function call
signalfd: signal -> pending blocked signal
-> readable file descriptor
-> ordinary event-loop codesignalfd() is Linux-specific. It is a good fit when the program already has a descriptor-driven event loop and does not require portable POSIX-only signal handling.
Why ordinary signal handlers are difficult in event-driven code
A signal handler does not run like a normal callback scheduled by your application. It can interrupt the thread while that thread is inside unrelated code.
That matters because many library functions are not async-signal-safe, meaning they are not guaranteed to behave correctly when called from a signal handler. For example, calling buffered stdio functions such as printf() from a handler can interfere with stdio state that the interrupted code was already modifying.
A handler therefore often has to do very little:
static volatile sig_atomic_t stop_requested;
static void
handle_sigterm(int signo)
{
(void) signo;
stop_requested = 1;
}This pattern is valid for simple state changes, but an event loop still needs a reliable way to wake up and notice the flag. More elaborate programs often use the self-pipe trick: the handler writes a byte to a pipe, and the event loop waits on the pipe.
signalfd() removes that handler-to-pipe bridge for signals that can be handled synchronously. The event loop reads the signal information directly from a descriptor.
Block the signal before creating the signalfd
A common mistake is to create a signalfd but leave the corresponding signal unblocked.
The descriptor does not automatically replace the signal’s normal disposition. Signals intended for signalfd() should normally be blocked first so that they remain pending instead of being delivered to a conventional handler or default action.
A minimal setup for SIGINT and SIGTERM looks like this:
#include <signal.h>
#include <sys/signalfd.h>
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
/* handle error */
}
int signal_fd = signalfd(-1, &mask, SFD_CLOEXEC);
if (signal_fd == -1) {
/* handle error */
}SIG_BLOCK adds these signals to the calling thread’s signal mask. While blocked, matching signals can become pending. signalfd() then provides a way to consume matching pending signals.
SFD_CLOEXEC sets close-on-exec on the new descriptor. That is usually appropriate for an event-loop descriptor that should not accidentally remain open across execve().
Read signal records like ordinary descriptor data
When a selected signal is pending, the signalfd becomes readable.
A read returns one or more struct signalfd_siginfo records. Each record includes the signal number and additional metadata.
Here is the smallest useful read:
struct signalfd_siginfo info;
ssize_t n = read(signal_fd, &info, sizeof info);
if (n == -1) {
/* handle read error */
} else if (n != sizeof info) {
/* treat an unexpected short record as an error */
}
if (info.ssi_signo == SIGTERM) {
/* begin graceful shutdown */
}Unlike code inside an asynchronous signal handler, this code runs as part of the program’s ordinary control flow after read() returns. It can therefore use normal application abstractions, subject to the usual rules of the thread and program.
A larger buffer can receive multiple records in one read(). For a simple loop that processes one record at a time, using a single struct signalfd_siginfo is easier to reason about.
Integrate signals with poll
The main advantage appears when signals join the same readiness set as other I/O.
This program waits for SIGINT or SIGTERM through poll():
#define _GNU_SOURCE
#include <errno.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/signalfd.h>
#include <unistd.h>
int
main(void)
{
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
perror("sigprocmask");
return EXIT_FAILURE;
}
int signal_fd = signalfd(-1, &mask, SFD_CLOEXEC);
if (signal_fd == -1) {
perror("signalfd");
return EXIT_FAILURE;
}
struct pollfd pfd = {
.fd = signal_fd,
.events = POLLIN,
};
for (;;) {
int ready;
do {
ready = poll(&pfd, 1, -1);
} while (ready == -1 && errno == EINTR);
if (ready == -1) {
perror("poll");
close(signal_fd);
return EXIT_FAILURE;
}
if (pfd.revents & POLLIN) {
struct signalfd_siginfo info;
ssize_t n = read(signal_fd, &info, sizeof info);
if (n != sizeof info) {
perror("read");
close(signal_fd);
return EXIT_FAILURE;
}
if (info.ssi_signo == SIGINT ||
info.ssi_signo == SIGTERM) {
break;
}
}
}
close(signal_fd);
return EXIT_SUCCESS;
}A real server would put listening sockets, client sockets, pipes, timer descriptors, and the signalfd in the same poll() or epoll set. Shutdown then becomes an ordinary event-loop transition rather than logic split between a handler and the main loop.
Use nonblocking mode when the event loop requires it
signalfd() also accepts SFD_NONBLOCK.
With that flag, a read that would otherwise wait fails with EAGAIN. This is useful with edge-triggered epoll, where code commonly drains a ready descriptor until no more data is immediately available.
For example:
int signal_fd = signalfd(
-1,
&mask,
SFD_CLOEXEC | SFD_NONBLOCK
);Nonblocking mode is not automatically better. If the descriptor is read only after poll() reports it readable and the program consumes one record at a time, blocking mode can be simpler.
Use SFD_NONBLOCK when it matches the rest of the event-loop design rather than adding it by habit.
Treat signal masks carefully in multithreaded programs
Signal masks are per-thread. This creates an important boundary condition.
A process-directed signal can be delivered to a thread that does not block it. If one thread blocks SIGTERM for a signalfd but another thread leaves SIGTERM unblocked, the signal can bypass the signalfd path and be delivered to that other thread.
The safest common design is to block the relevant signals before creating worker threads. New POSIX threads inherit a copy of the creating thread’s signal mask.
In a threaded program, use pthread_sigmask() to manipulate thread signal masks:
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
int error = pthread_sigmask(SIG_BLOCK, &mask, NULL);
if (error != 0) {
/* handle pthread error code */
}
/* Create worker threads after the mask is established. */The event-loop thread can then own the signalfd while worker threads inherit the blocked signals.
If threads already exist, changing only one thread’s mask is not enough. The program must establish a consistent mask policy across the relevant threads.
Remember that standard signals do not count every occurrence
Turning a signal into descriptor data does not change the underlying queueing semantics of that signal.
Standard signals such as SIGTERM do not queue multiple identical pending instances. If SIGTERM is blocked and generated several times before the pending instance is consumed, the program is not guaranteed to receive one signalfd record per generation.
That means this is a poor counting mechanism:
send SIGUSR1 ten times
expect exactly ten signalfd recordsIf each event must be counted, use a mechanism with appropriate queueing semantics, such as an application pipe, socket, eventfd, or carefully designed real-time signal use.
Real-time signals have different queueing semantics, but they introduce their own limits and design concerns. Do not switch to them merely to make a control path look convenient.
SIGCHLD still requires child reaping
signalfd() can receive SIGCHLD, which is useful when a supervisor wants child-exit notification inside an event loop.
But reading a SIGCHLD record is not the same as reaping the child.
After the notification, the program still needs an appropriate wait operation, such as waitpid() or waitid(), to collect child status and prevent terminated children from remaining zombies.
A robust supervisor usually treats SIGCHLD as “child state may have changed” and then drains the relevant wait API:
for (;;) {
pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid > 0) {
/* process this child's status */
continue;
}
if (pid == 0) {
break;
}
if (errno == EINTR) {
continue;
}
if (errno == ECHILD) {
break;
}
/* handle another error */
break;
}This also avoids assuming a one-to-one relationship between SIGCHLD records and waitable child-state changes.
Some signals cannot be handled through signalfd
signalfd() is not a universal replacement for signal handlers.
SIGKILL and SIGSTOP cannot be caught, blocked, or consumed through a signalfd. If they are included in the mask passed to signalfd(), they are ignored for this purpose.
More subtly, synchronously generated fault signals such as a SIGSEGV caused by an invalid memory access or a SIGFPE caused by certain arithmetic faults cannot be handled through signalfd() in the normal blocked-signal pattern. Those faults are tied to the instruction that caused them and require traditional signal handling when an application intentionally handles them.
This distinction gives a practical rule:
- use
signalfd()for asynchronous control and notification signals that belong in an event loop; - use traditional signal mechanisms when the signal must interrupt execution at the faulting context or when portability requires them.
Child processes inherit blocked masks
Signal masks survive fork(), and they are preserved across execve() unless the new program changes them.
That can surprise programs that launch helpers after blocking signals for signalfd(). A child that executes another program may inherit SIGINT, SIGTERM, or other signals as blocked even though the helper expects normal signal behavior.
If a child should not inherit the event loop’s policy, reset the mask in the child before execve() or use a process-creation interface that lets you configure the child’s signal mask deliberately.
This is an operational concern, not just a local implementation detail. A helper that mysteriously ignores termination can make shutdown and deployment behavior unreliable.
Choose signalfd when signals belong in the event loop
signalfd() is especially useful for Linux daemons, supervisors, and servers that already centralize state transitions in poll() or epoll.
It provides three practical benefits in that setting:
- signal notification arrives through the same readiness mechanism as other I/O;
- signal-specific logic runs in ordinary control flow instead of an asynchronous handler;
- shutdown, reload, and child-management behavior can remain centralized in the event loop.
The costs are equally important. The design is Linux-specific, signal masks must be correct across threads and child processes, standard-signal coalescing still applies, and some signals still require traditional handlers.
For a small portable command-line program, sigaction() plus a minimal handler may remain the simpler choice. For a Linux event loop that already thinks in file descriptors, signalfd() can make asynchronous process control fit the rest of the architecture without pretending that signals have become ordinary queued messages.