Traditional signal handlers force programs to obey a narrow async-signal-safe API set and can introduce races when touching logging, allocators, locks, or event-loop state. On Linux, signalfd() turns asynchronous signals such as SIGTERM, SIGHUP, and SIGCHLD into records read from a file descriptor that can join sockets, timers, and eventfd in one epoll loop.
This guide covers correct setup order, multithreaded masking, zombie-free SIGCHLD handling, edge-triggered draining, and important limitations. It follows Linux man-pages for signalfd(2), signal(7), pthread_sigmask(3), and waitpid(2).
1. signalfd changes the signal model
With a handler, the kernel interrupts normal execution to invoke asynchronous callback code. That callback can safely call only a small API subset; locking mutexes, allocating memory, using a library logger, or mutating complex objects can deadlock or invoke undefined behavior.
With signalfd, the application blocks selected signals in its signal mask, creates an FD for that mask, and reads struct signalfd_siginfo records. Shutdown, reload, and child reaping then run in ordinary event-loop context where normal synchronization and state-machine rules apply.
signalfd does not make signals a durable message queue. It provides an FD interface to pending signals while preserving Linux signal semantics.
2. Order matters: block first, create threads later
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGHUP);
sigaddset(&mask, SIGCHLD);
int rc = pthread_sigmask(SIG_BLOCK, &mask, NULL);
if (rc != 0) {
/* pthread APIs return the error number directly. */
fail_pthread(rc);
}
/* Create workers only after blocking the control signals. */
start_worker_threads();
Each thread has its own signal mask, while a new thread inherits a copy from its creator. A multithreaded service should therefore block control signals in the main thread before any pthread_create(). If a worker leaves one unblocked, the kernel may deliver it there under its default disposition instead of making it available through signalfd.
3. Create the descriptor safely
#include <sys/signalfd.h>
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
if (sfd == -1)
fail_errno("signalfd");
SFD_NONBLOCKlets the event loop drain untilEAGAIN.SFD_CLOEXECprevents accidental inheritance acrossexecve().- Passing an existing signalfd instead of
-1replaces its associated mask.
SIGKILL and SIGSTOP cannot be blocked or received through signalfd and are silently ignored when included in the mask.
4. Add it to epoll
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1)
fail_errno("epoll_create1");
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET,
.data.fd = sfd
};
if (epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev) == -1)
fail_errno("epoll_ctl");
Level-triggered mode is also valid and simpler for many applications. Under EPOLLET, every nonblocking FD must be read until EAGAIN. Reading one record and returning to epoll_wait() can strand unread records without a new edge.
5. Drain multiple records per wakeup
static void drain_signals(int sfd, bool *stopping)
{
struct signalfd_siginfo info[16];
for (;;) {
ssize_t n = read(sfd, info, sizeof(info));
if (n == -1 && errno == EINTR)
continue;
if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK))
return;
if (n == -1)
fail_errno("read signalfd");
if (n == 0 || n % sizeof(info[0]) != 0)
fail_protocol("short signalfd read");
size_t count = (size_t)n / sizeof(info[0]);
for (size_t i = 0; i < count; i++) {
switch (info[i].ssi_signo) {
case SIGTERM:
case SIGINT:
*stopping = true;
begin_graceful_shutdown();
break;
case SIGHUP:
request_config_reload();
break;
case SIGCHLD:
reap_children();
break;
default:
log_unexpected_signal(info[i].ssi_signo);
}
}
}
}
One read() may return several records. Reading consumes those signals, so they are no longer pending for a handler or sigwaitinfo().
6. Standard signals may coalesce
Repeated generation of one standard signal while it is pending does not have to produce the same number of records. Twenty SIGHUP deliveries may mean only “reload is needed.” Model actions as state rather than counts:
SIGHUP: set a reload request and reread the canonical configuration;SIGTERM: transition to draining and make repeated requests idempotent;SIGCHLD: treat it as “children may be waitable,” then scan withwaitpid().
Real-time signals have queuing semantics, but still have queue limits and should not become an application message broker.
7. Reap SIGCHLD without zombies
static void reap_children(void)
{
for (;;) {
int status;
pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid > 0) {
record_child_exit(pid, status);
continue;
}
if (pid == 0)
return;
if (errno == EINTR)
continue;
if (errno == ECHILD)
return;
fail_errno("waitpid");
}
}
Do not call waitpid() exactly once per SIGCHLD record. Several children may change state before the loop runs and a standard signal may coalesce. Reap until the call returns zero or ECHILD.
Services needing exact per-process tracking can combine this design with pidfds. signalfd remains useful for control signals but does not provide pidfd's identity protection against PID reuse.
8. Shutdown and reload are state machines
On SIGTERM, do not immediately close everything inside the switch branch. Transition through a lifecycle:
- stop accepting new connections;
- give in-flight requests a bounded completion deadline;
- request worker shutdown;
- continue processing events required for draining;
- close descriptors in order and exit when clean or timed out.
For reload, parse and validate into a temporary configuration, then atomically or under a lock swap it into place. Keep the old state on failure. Ordinary signalfd context makes this flow observable and testable.
9. Important limitations and traps
- Synchronous faults: a
SIGSEGVcaused by invalid memory access or a synchronousSIGFPEcannot be handled correctly through signalfd; use an appropriate handler. - Thread-directed signals: a thread reads signals directed to itself and process-directed signals, not signals specifically targeted at another thread.
- Fork with epoll: a child can read from an inherited signalfd, but inherited epoll readiness has documented caveats. Prefer separate post-fork signalfd and epoll setup.
- Exec helpers: children inherit the signal mask. Before executing a helper that does not use signalfd, unblock the signals it expects.
- Libraries that spawn: when a library hides the fork/exec boundary, mask repair may be impossible; a minimal handler writing to a self-pipe can be the safer fallback.
- Competing consumers: do not let a handler,
sigwaitinfo(), and signalfd consume the same signal without an explicit design.
10. Observability and testing
Linux exposes a signalfd mask through /proc/PID/fdinfo/FD. Production metrics should cover shutdown and reload requests, drain duration, child exit reasons, waitpid() failures, and unexpected events.
A minimum test suite should burst SIGHUP, send SIGTERM during active requests, terminate several children together, stress edge-triggered draining, validate fork/exec mask behavior, and confirm every worker thread blocks the intended set.
Production checklist
- Block control signals before creating any worker thread.
- Use
SFD_NONBLOCK | SFD_CLOEXEC. - Under EPOLLET, batch-read until
EAGAIN. - Standard-signal actions are idempotent and do not rely on exact delivery counts.
SIGCHLDalways triggers awaitpid(-1, ..., WNOHANG)loop.- Shutdown and reload use deadline-aware state machines.
- Children repair their masks before executing programs with different contracts.
- Never expect signalfd to receive
SIGKILL,SIGSTOP, or synchronous faults. - Fork, worker threads, signal bursts, and zombie cleanup are tested.
Conclusion
signalfd() lets an event-driven Linux service unify signals, sockets, timers, and IPC under one waiting mechanism. Its largest benefit is not merely fewer handler lines: shutdown, reload, and child lifecycle return to normal, observable, testable execution context. The application must in exchange block signals before thread creation, respect coalescing semantics, drain the descriptor correctly, and handle fork/exec boundaries deliberately.




No comments yet. Be the first to share your thoughts.