chaowen.← Back to archive

Linux signal delivery / signalfd

Linux signalfd: making asynchronous signals ordinary epoll events

Signals are useful for process control but awkward inside an event-driven server: they arrive asynchronously, interrupt unrelated code, and leave handlers with very few safe operations. Linux signalfd offers a different contract: block the signals, create a file descriptor for that mask, and read structured signalfd_siginfo records from the same loop that already handles sockets.

Linux deep dive · Signal handling and event loops

2026-08-16

The concrete problem

Suppose a daemon accepts network connections in an epoll_wait() loop, but must also handle SIGTERM for graceful shutdown, SIGHUP for configuration reload, and perhaps SIGCHLD for child reaping. A conventional signal handler runs at an arbitrary interruption point. It cannot safely call most of the functions that the rest of the program uses: memory allocation, logging through a buffered library, taking a mutex, or touching complex application state can deadlock or corrupt state.

The traditional workaround is a self-pipe: the handler writes one byte to a pipe and the main loop watches the read end. That works, but the handler still has to perform a write, handle a full pipe, and encode enough information for the consumer. Linux signalfd moves the conversion into the kernel and returns a record containing the signal number, sender, PID, UID, timer details, and other metadata.

Core invariant: a signalfd does not create a second signal system. It changes how signals in a selected mask are consumed. They must be blocked in the threads that should not run handlers; then a read from the descriptor dequeues pending signal information.
normal delivery signalfd delivery signal → pending set → handler runs signal → pending set → blocked at interruption │ ▼ signalfd fd becomes readable │ ▼ read(signalfd_siginfo) │ ▼ event loop dispatches epoll_wait() returns sockets, timerfds, eventfds, and signalfd events under one control flow.

The API contract

The syscall is exposed through the C library as int signalfd(int fd, const sigset_t *mask, int flags). With fd == -1, it creates a new descriptor. With an existing signalfd descriptor, it replaces that descriptor's watched mask. The normal creation flags are SFD_CLOEXEC and SFD_NONBLOCK; both are set atomically, avoiding a race between open-like creation and fcntl.

StepCode-level actionReason
1. Build a masksigemptyset(), then sigaddset() for each signal.The mask is the descriptor's filtering and routing policy.
2. Block itpthread_sigmask(SIG_BLOCK, &mask, NULL).Prevents ordinary asynchronous handlers from winning the race.
3. Create the fdsignalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK).Creates a pollable endpoint for those blocked signals.
4. Register itepoll_ctl(epfd, EPOLL_CTL_ADD, sfd, ...).Integrates signal processing with the existing event loop.
5. Drain recordsRead multiples of sizeof(struct signalfd_siginfo) until EAGAIN.Nonblocking edge-triggered loops must consume all available records.

Each complete record is 128 bytes on Linux and has the stable UAPI type struct signalfd_siginfo. The first fields include ssi_signo, ssi_errno, ssi_code, ssi_pid, ssi_uid, and signal-specific fields such as ssi_fd for SIGIO or ssi_overrun for timer notifications. Use the structure supplied by <sys/signalfd.h>; do not invent a smaller wire format.

A successful read returns one or more whole records. A read buffer smaller than one record fails with EINVAL. A buffer that is not an exact multiple of the record size can return the complete records that fit; a robust loop simply allocates an array of the structure and uses sizeof records. With SFD_NONBLOCK, no pending record means EAGAIN.

Signal masks are the real correctness boundary

The descriptor only receives signals that are both in its mask and blocked for the receiving thread. Creating a signalfd without blocking the same signals is a common bug: the kernel may deliver a signal through the ordinary action before the event loop reads the descriptor. Calling signal(SIGTERM, SIG_IGN) is not equivalent; ignored signals are discarded rather than turned into records.

Signal disposition and signal mask are different pieces of state. Dispositions are process-wide, while masks are per-thread. A multithreaded program should block the selected signals before creating worker threads, then let one designated event-loop thread own the signalfd. New threads inherit the creator's mask, so the ordering matters.

Signal sourcePending stateWhat the event loop observes
kill(pid, SIGTERM)Process-directed; eligible for a thread according to the mask.A record with sender PID/UID and ssi_code.
pthread_kill(thread, SIGUSR1)Thread-directed; remains associated with that target thread.Only a signalfd in the target thread's delivery context can consume it.
Terminal Ctrl-CKernel sends SIGINT to the foreground process group.One or more eligible processes may receive a record.
POSIX timer / queued signalMay carry a value and may have overrun behavior.Signal-specific fields in signalfd_siginfo.

Standard signals are not a durable message queue. Multiple instances of the same standard signal may coalesce into one pending signal, so sending SIGUSR1 ten times does not guarantee ten records. Real-time signals are queued in order and can carry values, subject to per-user pending-signal limits. If the application needs durable work items, keep the payload in a queue and use the signal only as a wakeup or control hint.

Kernel data and control flow

The implementation lives primarily in fs/signalfd.c. The descriptor stores a reference to a signalfd_ctx, whose important state is the watched sigset_t. It does not own a private queue of arbitrary records. Instead, a read consults the calling task's pending signal queues and consumes signals that match the context's mask.

signalfd(-1, mask, flags) │ ├─ copy mask from userspace ├─ do_signalfd4() │ └─ allocate struct signalfd_ctx; store mask ├─ anon_inode_getfile_fmode("signalfd", &signalfd_fops, ctx, flags) └─ fd_install() → userspace fd send_signal() │ ├─ choose eligible task / process pending queue ├─ signal becomes pending (or coalesces) └─ wake signal waiters, including signalfd poll waitqueue epoll_wait() │ └─ signalfd_poll() └─ matching pending signal? → EPOLLIN read(signalfd) │ ├─ lock task signal state ├─ dequeue first pending signal matching ctx->sigmask ├─ fill signalfd_siginfo from kernel siginfo ├─ repeat for remaining buffer space └─ copy complete records to userspace

The exact helper names evolve, but the design has an important consequence: readiness is derived from pending signals, not from bytes sitting in a descriptor-local buffer. A signal can be pending for a process or thread, and signalfd makes matching pending state readable to the consumer that has the right mask and execution context.

The file operations include the usual descriptor lifecycle hooks: signalfd_release() drops the context reference, signalfd_poll() registers the wait queue and reports readability, and current kernels use signalfd_read_iter() (older trees used a signalfd_read() callback) to perform the dequeue and record conversion. The signal subsystem wakes signalfd waiters when pending state changes; the read path rechecks under the signal lock, so a wakeup is only a hint that a matching record may now be available.

Why the read loop must drain

Level-triggered epoll will continue to report a readable signalfd while a matching signal remains pending. Edge-triggered EPOLLET changes the obligation: after one notification, the handler must call read() repeatedly until it gets EAGAIN. Otherwise a record can remain pending while the loop waits forever for a transition that already happened.

static void drain_signalfd(int sfd) {
    struct signalfd_siginfo si[16];

    for (;;) {
        ssize_t n = read(sfd, si, sizeof si);
        if (n > 0) {
            if (n % (ssize_t)sizeof si[0] != 0)
                abort();                 /* kernel API invariant */
            for (size_t i = 0; i < (size_t)n / sizeof si[0]; i++)
                dispatch_signal(&si[i]);
            continue;
        }
        if (n < 0 && (errno == EINTR || errno == EAGAIN)) {
            if (errno == EINTR) continue;
            break;
        }
        if (n == 0)                         /* signalfd normally has no EOF */
            break;
        log_fatal("signalfd read");
    }
}

Do not call complex application code from a traditional signal handler and then also use signalfd for the same signal. Pick one consumer. If both paths race, one may consume the signal while the other sees nothing, producing behavior that looks like a lost wakeup.

Important functions and structures

SymbolRoleOperational implication
signalfd()Creates or updates a signalfd context and anonymous inode.Use SFD_CLOEXEC and SFD_NONBLOCK at creation.
signalfd_ctxStores the descriptor's watched signal mask and lifetime state.Changing the fd mask does not unblock signals in any thread.
signalfd_poll()Maps matching pending signals to EPOLLIN.Readiness is pending-signal state, not a guaranteed future read.
signalfd_read_iter()Finds matching pending signals and serializes siginfo.Reads consume signals; a second consumer cannot read the same record.
signalfd_siginfoFixed UAPI record returned to userspace.Inspect ssi_code, ssi_pid, and signal-specific fields.
sigprocmask() / pthread_sigmask()Controls which signals are blocked in the current thread.For pthreaded code, use pthread_sigmask and establish the mask before workers start.
sigwaitinfo()Alternative synchronous signal-consumption API.Useful for a dedicated blocking thread, but it is not an fd for epoll.

Tradeoffs and failure modes

ChoiceStrengthCost or trap
Traditional handlerWorks on portable POSIX systems and can react immediately.Only async-signal-safe operations are legal; easy to deadlock or corrupt state.
Self-pipePortable way to wake a descriptor loop.Handler must write, pipe capacity can fill, and signal metadata needs a protocol.
signalfdStructured records and one epoll control flow.Linux-specific; depends on correct blocking masks and signal semantics.
sigwaitinfoClean synchronous consumption in a dedicated thread.Does not directly compose with a socket epoll loop.

Security and isolation implications

A signalfd is a capability to consume selected pending signals available to the associated task context. Possession of the fd alone does not let an unrelated process inject signals, but a process that can send signals to the target can influence what the consumer sees. Treat sender metadata as untrusted input: ssi_pid and ssi_uid identify the claimed kernel sender, not an authorization decision for the action your daemon is about to take.

Signals are also an availability surface. A permitted sender can flood real-time signals until the per-user pending-signal limit is reached, causing sigqueue() to fail or starving the loop. Keep signal handlers cheap, drain in bounded batches if one sender can create untrusted load, and measure pending-signal pressure rather than assuming every readable event represents one ordinary signal.

Use SFD_CLOEXEC to avoid descriptor inheritance across execve(), minimize which signals are routed to the event loop, and avoid using signals as a privileged command channel unless the sender identity and authorization policy are independently checked. Seccomp can deny signalfd4, and a sandbox may restrict kill/rt_sigqueueinfo even when the receiving fd exists.

Debugging method

  1. Print the exact mask in the creating thread and verify it with pthread_sigmask(SIG_SETMASK, NULL, ...). “The process blocks SIGTERM” is too vague for a multithreaded program.
  2. Trace delivery and consumption with strace -f -tt -e trace=signalfd4,rt_sigprocmask,rt_sigaction,kill,rt_sigqueueinfo,epoll_wait,read. Look for a signal action that was installed but never blocked, or a signal sent to a different thread.
  3. Inspect /proc/<pid>/status. SigBlk, SigIgn, and SigCgt are hexadecimal bitmasks that expose whether the intended signal is blocked, ignored, or caught.
  4. Temporarily use level-triggered epoll. If level-triggered mode works but edge-triggered mode loses later signals, the read loop is not draining to EAGAIN.
  5. Test one signal at a time with kill -TERM $pid, then test bursts with for i in $(seq 1 10); do kill -USR1 $pid; done. Compare standard-signal coalescing with a real-time signal such as SIGRTMIN.
  6. Log ssi_signo, ssi_code, ssi_pid, and the action taken. Never infer “one record equals one request” without considering the signal class.
# Linux-only observation commands
grep -E '^(Name|Pid|SigBlk|SigIgn|SigCgt|Threads):' /proc/$PID/status
strace -f -tt -e trace=signalfd4,rt_sigprocmask,kill,epoll_wait,read ./signalfd-lab
kill -TERM $PID
kill -HUP $PID
kill -USR1 $PID

Compact reproducible lab

Goal

Create a nonblocking signalfd for SIGUSR1 and SIGTERM, block both signals, send them from a child process, and print the structured records that an event loop can consume. The lab then demonstrates the revealing difference between a standard signal burst and a real-time signal burst.

Prerequisites

Linux 2.6.22 or newer, a C compiler, and the standard Linux headers. signalfd is Linux-specific; it is not available in the macOS SDK or on a generic POSIX system.

Exact commands

cc -O2 -Wall -Wextra -o /tmp/signalfd-lab -x c - <<'C'
#define _GNU_SOURCE
#include <errno.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/signalfd.h>
#include <sys/wait.h>
#include <unistd.h>

static void die(const char *what) { perror(what); exit(EXIT_FAILURE); }

int main(void) {
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGUSR1);
    sigaddset(&mask, SIGTERM);
    if (sigprocmask(SIG_BLOCK, &mask, NULL) < 0) die("sigprocmask");

    int fd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
    if (fd < 0) die("signalfd");

    pid_t child = fork();
    if (child < 0) die("fork");
    if (child == 0) {
        usleep(100000);
        if (kill(getppid(), SIGUSR1) < 0) _exit(2);
        if (kill(getppid(), SIGUSR1) < 0) _exit(3);
        if (kill(getppid(), SIGTERM) < 0) _exit(4);
        _exit(0);
    }

    usleep(250000);                 /* let all three signals become pending */
    struct signalfd_siginfo si[8];
    for (;;) {
        ssize_t n = read(fd, si, sizeof si);
        if (n > 0) {
            for (size_t i = 0; i < (size_t)n / sizeof si[0]; i++)
                printf("record[%zu]: signo=%u code=%d sender_pid=%u sender_uid=%u\n",
                       i, si[i].ssi_signo, si[i].ssi_code,
                       si[i].ssi_pid, si[i].ssi_uid);
            continue;
        }
        if (n < 0 && errno == EAGAIN) break;
        if (n < 0 && errno == EINTR) continue;
        if (n < 0) die("read");
        break;
    }
    if (waitpid(child, NULL, 0) < 0) die("waitpid");
    close(fd);
    return 0;
}
C
/tmp/signalfd-lab

Expected result

You should see one SIGUSR1 record and one SIGTERM record, although their ordering is subject to the signal-pending rules. The two identical standard SIGUSR1 sends may coalesce:

record[0]: signo=10 code=0 sender_pid=... sender_uid=...
record[1]: signo=15 code=0 sender_pid=... sender_uid=...

The numeric values are commonly 10 for SIGUSR1 and 15 for SIGTERM, but portable code uses the macros. The key observation is that the second identical standard signal is not guaranteed to produce a second record.

Revealing variations

First, remove the sigprocmask(SIG_BLOCK, ...) call and run again. The child may terminate the parent with SIGTERM instead of producing a record, because the normal default action wins. This is the central signalfd contract, not a timing bug.

Second, replace both SIGUSR1 sends with sigqueue(getppid(), SIGRTMIN, (union sigval){.sival_int = 7}) and include SIGRTMIN in the mask. Real-time signals are queued separately and their ssi_int/ssi_ptr fields carry the value. Do not assume the same multiplicity for ordinary signals.

Environment result: this host is macOS (Darwin 24.6, Apple clang 17), whose SDK has no <sys/signalfd.h> and no signalfd4 syscall. The compile command therefore cannot run natively here. A disposable Docker attempt also could not complete because debian:bookworm was not cached and its registry pull stalled. Run it on a Linux host or on a machine with the image available, for example: docker run --rm -v "$PWD":/lab -w /lab debian:bookworm bash -lc 'apt-get update && apt-get install -y build-essential strace && cc -O2 -Wall -Wextra signalfd-lab.c -o signalfd-lab && ./signalfd-lab'. Containers share the host kernel, so a restrictive seccomp profile can still deny signalfd4.

Production shape: graceful shutdown in an epoll server

A production daemon can block SIGTERM, SIGINT, and SIGHUP before starting worker threads. One event-loop thread owns the signalfd. On SIGTERM, it stops accepting new work, marks the service as draining, and lets existing connections finish under a deadline. On SIGHUP, it reloads configuration in the same serialized control path as socket events. No signal handler needs to take the configuration mutex or call the logger.

startup: build mask → block in initial thread → create signalfd → create workers │ ▼ event loop: epoll_wait() ├─ listening socket → accept ├─ client socket → read/write protocol data └─ signalfd → read records → SIGTERM: drain / SIGHUP: reload all state transitions happen in one ordinary control flow

This pattern is especially useful when the rest of the service already uses timerfd, eventfd, and sockets. It is not a replacement for a supervisor's lifecycle protocol or for a durable control plane. If a reload command needs a payload, use a Unix socket or another authenticated IPC mechanism; use the signal only as a small, permission-controlled nudge.

Primary sources

Remember: block the signals first, route them to one owner, register the fd with the event loop, and drain complete records to EAGAIN. signalfd makes signal handling ordinary control flow; it does not make signals a durable message queue or an authorization mechanism.