signalfd

signalfd turns asynchronous signal delivery into synchronous file-descriptor reads. Instead of registering a handler that the kernel can call at any instruction boundary (with all the async-signal-safety hazards that entails), you create a descriptor with signalfd(-1, &mask, flags) that becomes readable whenever any signal in mask is pending for the caller; a read() then returns one or more struct signalfd_siginfo records, dequeuing those signals from the pending queue (signalfd(2)). The critical idiom that makes it work is to block the signals in the mask first with sigprocmask(SIG_BLOCK, ...): a blocked signal does not run its handler or take its default action but instead stays pending, and signalfd consumes it from that pending queue. The payoff is that you process signals in normal context — inside your event loop, with the full C library available — instead of inside a handler constrained to a tiny set of async-signal-safe functions. Because the descriptor is pollable, signals join sockets, timers, and eventfds in a single epoll loop. This note pins the mechanism to the Linux v6.12 source tree (fs/signalfd.c — v6.12 is this vault’s pin and one of several longterm kernels, per kernel.org/releases.json), and every behavioural claim in it was re-measured on a running 7.1.13 kernel. The file’s copyright line credits Linus Torvalds in 2003; the siginfo-returning read(), the poll() locking, the anonymous-inode backing and the fd-reuse update path were all added by Davide Libenzi in March 2007, with multi-signal reads from Davi E. M. Arnaut two months later.

Scope

This note is about the signalfd mechanism: how a pending signal reaches a file descriptor, what the 128-byte record contains, and the exact semantics across threads, fork, exec and epoll. The signal model it rests on — dispositions, blocking, sigset_t manipulation, standard versus realtime queuing — lives in Signal Sets and Masking, Standard Signals and Their Default Actions, Realtime Signals and Async-Signal-Safety and Reentrant Handlers; this note recaps only what is needed to make its own mechanism legible.

signalfd() was added in Linux 2.6.22, with a glibc wrapper since glibc 2.8; the signalfd4() variant that accepts a flags argument (SFD_CLOEXEC, SFD_NONBLOCK) arrived in Linux 2.6.27, and from glibc 2.9 the signalfd() wrapper routes to signalfd4() where available, so userspace has exposed flags ever since (signalfd(2) HISTORY and VERSIONS). The raw kernel entry points also take a sizemask argument giving sizeof(sigset_t), which the glibc wrapper fills in; a mismatch is -EINVAL.

Why it exists: the kevent argument of 2007

signalfd was not designed in isolation — it was one side of an architectural argument. In 2007 Evgeniy Polyakov was pushing kevent, a new kernel subsystem and user-space API modelled on BSD’s kqueue, to deliver arbitrary kernel events to applications. Jonathan Corbet’s contemporaneous write-up records the community’s reluctance: reviewers were “unconvinced that the interface is needed, and, if it is, that the proposed kevent API (which would have to be maintained forever) is the right one” (LWN, “Kernel events without kevents”, March 2007).

Davide Libenzi — the author of epoll — proposed the opposite strategy. Rather than a new event subsystem, make each event source a file descriptor so the existing select/poll/epoll machinery already knows how to wait on it. signalfd() and timerfd() were the first two instances of that idea, eventfd() followed, and the pattern has since produced pidfd, fanotify, io_uring’s ring fd, and more. The argument was won decisively: kevent never merged, and “everything is a file descriptor” became the Linux answer to “how do I wait on N different kinds of event at once.”

That history also explains the interface’s one famous wart. Corbet’s 2007 article already documented it: “reading from this file descriptor competes with normal signal delivery for queued signals; there is no way to predict whether the signal will be delivered in the usual way or will be read from the file descriptor. This situation can be avoided by using sigprocmask() to block normal delivery of the signal(s) of interest.” A reader in the comment thread immediately asked the obvious question — “if not, then why doesn’t signalfd() automatically mask the signals in its arguments?” — and nineteen years later the answer is still that it does not, and the resulting bug is still the first one everybody writes.


Mental Model

The right way to think about signalfd is “signals delivered by read() instead of by interrupt.” A classic signal handler is a callback the kernel invokes by hijacking your thread mid-execution — it runs on a borrowed stack, between two arbitrary instructions, and may not call anything that is not async-signal-safe (no malloc, no printf, almost nothing). signalfd inverts this: the signal does not interrupt you; it sits quietly in the pending queue and merely flips your descriptor to readable. You decide when to look — at the top of your event loop, in plain code, with the whole standard library available. The signal becomes data you pull at your convenience, not control flow forced upon you.

The one non-obvious prerequisite is blocking. A generated signal normally does one of three things: runs a handler, takes its default action (often: kill the process), or — if it is blocked — sits in a pending set doing nothing at all until it is unblocked or explicitly dequeued. signalfd reads from that third state. It does not change a signal’s disposition; it installs no handler and overrides nothing. A signal that is not blocked is delivered the old-fashioned way even while a signalfd is watching for it, and if its default action is “terminate”, the process dies with the descriptor sitting there unread.

flowchart LR
  SRC["signal generated<br/>kill / sigqueue / raise /<br/>pthread_kill / child exit"] --> GEN{"is this signal<br/>BLOCKED in the<br/>target thread's mask?"}
  GEN -->|"no"| DISP{"disposition?"}
  DISP -->|"handler installed"| HND["run the handler —<br/>async-signal-safe code only.<br/>signalfd never sees it."]
  DISP -->|"SIG_DFL = terminate"| KILL["process dies.<br/>signalfd never sees it."]
  DISP -->|"SIG_IGN"| DROP["discarded.<br/>signalfd never sees it."]
  GEN -->|"yes"| PEND["added to a PENDING set:<br/>task-&gt;pending if thread-directed,<br/>signal-&gt;shared_pending if process-directed"]
  PEND --> NOTIFY["signalfd_notify() wakes<br/>sighand-&gt;signalfd_wqh"]
  NOTIFY --> POLL["signalfd_poll() reports EPOLLIN<br/>if next_signal() finds a match<br/>in EITHER pending set"]
  POLL --> READ["read(fd, buf, n*128)<br/>dequeue_signal() REMOVES it<br/>and returns a signalfd_siginfo"]

The decision that decides everything. What it shows: blocking is not an optimisation or a nicety — it is the branch that determines whether the signal ever reaches the descriptor at all. Three of the five outcomes bypass signalfd entirely. The insight to take: signalfd() is a reader, not a catcher. It does not intercept signals; it drains the pending set that blocking causes signals to accumulate in. Creating a signalfd without first calling sigprocmask(SIG_BLOCK, …) / pthread_sigmask(SIG_BLOCK, …) produces a descriptor that is perfectly valid and permanently empty — right up until the unblocked signal kills you.

Two more consequences of “it is a reader, not a catcher” are worth fixing in mind immediately, because almost every signalfd surprise reduces to one of them:

  1. The descriptor has no queue of its own. struct signalfd_ctx contains exactly one field, a sigset_t. All the state lives in the task. So two signalfds watching SIGTERM compete for the same pending signal, a sigwaitinfo() call competes with both, and a single SIGTERM is read exactly once by exactly one of them.
  2. It reads the queues of whoever calls read(), not of whoever called signalfd(). The implementation dereferences current->pending and current->signal->shared_pending — current being the reading task. Pass the descriptor to another thread, another process over a Unix socket, or a fork()ed child, and it becomes a view onto that task’s signals. This is measured and demonstrated below, and it is the single most counter-intuitive property of the interface.
sequenceDiagram
    autonumber
    participant S as Sender (kill/sigqueue)
    participant KS as kernel/signal.c
    participant PQ as pending set<br/>(task or shared)
    participant WQ as sighand->signalfd_wqh
    participant R as Reader thread
    S->>KS: kill(pid, SIGTERM) -> __send_signal_locked()
    KS->>WQ: signalfd_notify(t, sig)
    Note right of KS: fires BEFORE the signal is<br/>added to the set, and does<br/>NOT filter by any fd's mask
    KS->>PQ: sigaddset(&pending->signal, sig)
    WQ-->>R: wake_up() -- every signalfd on the<br/>process shares this one wait queue
    R->>PQ: signalfd_poll(): next_signal(task pending)<br/>OR next_signal(shared_pending)
    PQ-->>R: EPOLLIN if either matches THIS fd's mask
    R->>PQ: read() -> signalfd_dequeue() -> dequeue_signal()
    Note right of R: tries task->pending FIRST,<br/>then shared_pending
    PQ-->>R: kernel_siginfo_t, signal REMOVED from the set
    R->>R: signalfd_copyinfo(): flatten into<br/>128-byte struct signalfd_siginfo

One signal’s journey from kill(2) to a read(2) return. What it shows: the wake-up and the readiness test are separate steps with different granularity — signalfd_notify() wakes every signalfd on the process without consulting any mask, and each woken poller then re-checks whether the new signal matches its own sigmask. The insight to take: this is a deliberately dumb broadcast. With N signalfds on a process, one signal wakes all N and N−1 of them go back to sleep. That is fine at the scale signalfd is used at (one or two descriptors per process), and it is why the wait queue lives on sighand — shared by the whole thread group — rather than on the file.


Mechanical Walk-through

Everything below is read from fs/signalfd.c at the Linux v6.12 tag. The whole file is 362 lines — one of the smallest self-contained subsystems in the kernel — which is itself the point: signalfd adds almost no machinery, it just exposes machinery that already existed.

The object

struct signalfd_ctx {
        sigset_t sigmask;
};

One field. That is the entire per-descriptor state (L41–L43). There is no pending queue, no buffer, no reference to the creating task. The descriptor is a filter, and all the data it filters lives in the task’s own signal structures.

Creation, update, and the two silent normalisations

Both system calls (signalfd and signalfd4, plus their compat variants) funnel into do_signalfd4(ufd, mask, flags) (L251). It opens with two BUILD_BUG_ONs asserting SFD_CLOEXEC == O_CLOEXEC and SFD_NONBLOCK == O_NONBLOCK — the uapi header defines them as literally the same constants, so the flags can be passed straight through to the file layer — then rejects any other bit with -EINVAL. Then two lines that both silently change what you asked for:

sigdelsetmask(mask, sigmask(SIGKILL) | sigmask(SIGSTOP));
signotset(mask);

The first strips SIGKILL and SIGSTOP. These two cannot be caught, blocked, or ignored by anything, so asking a signalfd to watch them is meaningless — and the kernel’s response is to quietly remove them rather than return an error, which the man page confirms (“Attempts to include SIGKILL and SIGSTOP in mask are silently ignored”). This is observable from userspace, as shown in the measurements below.

The second complements the mask. It is not a bug and not a subtlety about signals — it is an impedance match. The kernel’s dequeue_signal(mask, …) takes a set meaning “signals to exclude from this dequeue”, the inverse of the userspace signalfd() argument meaning “signals I want”. Storing the complement once at creation avoids inverting it on every read() and every poll(). The one visible consequence is in /proc/PID/fdinfo, where signalfd_show_fdinfo() has to invert it back before printing (L230–L238) — it makes a local copy, calls signotset() on that, and prints the result.

Creation then diverges on ufd:

  • ufd == -1 — kmalloc a signalfd_ctx, store the normalised mask, take an unused descriptor, and wrap the context in an anonymous inode file named "[signalfd]" via anon_inode_getfile(), with signalfd_fops attached. The file gets O_RDWR | (flags & O_NONBLOCK) and then FMODE_NOWAIT, which is what lets io_uring and preadv2(RWF_NOWAIT) poll it without blocking. Verified from userspace: readlink("/proc/self/fd/3") on a live signalfd returns anon_inode:[signalfd].
  • ufd >= 0 — look the descriptor up, verify it really is a signalfd by comparing f_op against &signalfd_fops (anything else is -EINVAL, a non-descriptor is -EBADF), then replace ctx->sigmask under sighand->siglock and wake_up(&current->sighand->signalfd_wqh). The lock matters because a concurrent signalfd_poll() on another thread is reading that same mask under the same lock; the wake-up matters because widening the mask can make an already-pending signal newly readable, and nothing else would notify the poller.

How a pending signal wakes the descriptor

The hook is in include/linux/signalfd.h and is deliberately tiny:

static inline void signalfd_notify(struct task_struct *tsk, int sig)
{
        if (unlikely(waitqueue_active(&tsk->sighand->signalfd_wqh)))
                wake_up(&tsk->sighand->signalfd_wqh);
}

Note what it does not do: it ignores sig entirely and does not consult any descriptor’s mask. It is a broadcast to one wait queue that lives on struct sighand_struct (include/linux/sched/signal.h):

struct sighand_struct {
        spinlock_t              siglock;
        refcount_t              count;
        wait_queue_head_t       signalfd_wqh;
        struct k_sigaction      action[_NSIG];
};

Because sighand is shared across a thread group (and, with CLONE_SIGHAND, potentially beyond it), every signalfd in the process shares one wait queue. There are exactly two call sites in kernel/signal.c — L1167 in __send_signal_locked() (the general path) and L2018 in send_sigqueue() (the POSIX-timer path). Both look like this:

out_set:
        signalfd_notify(t, sig);
        sigaddset(&pending->signal, sig);

The wake-up is issued before the signal is actually added to the pending set. That looks like a race — a woken poller could check the set and find nothing — and it is not one, because both the sender and signalfd_poll() hold sighand->siglock across their whole sequence. The woken reader cannot acquire the lock until the sender has released it, by which point sigaddset has run. The ordering is an artefact of where the label sits, not a guarantee anyone relies on.

Polling — the readiness rule

static __poll_t signalfd_poll(struct file *file, poll_table *wait)
{
        struct signalfd_ctx *ctx = file->private_data;
        __poll_t events = 0;
 
        poll_wait(file, &current->sighand->signalfd_wqh, wait);
 
        spin_lock_irq(&current->sighand->siglock);
        if (next_signal(&current->pending, &ctx->sigmask) ||
            next_signal(&current->signal->shared_pending, &ctx->sigmask))
                events |= EPOLLIN;
        spin_unlock_irq(&current->sighand->siglock);
 
        return events;
}

Three things are load-bearing here and each has a visible consequence.

current, not the creator. Every dereference is of the polling task. The wait queue registered with poll_wait() is current->sighand->signalfd_wqh. This is the mechanism behind the fork + epoll trap documented later: epoll_ctl(EPOLL_CTL_ADD) calls ->poll() once, in the registering task, and the resulting registration points at that task’s sighand. After a fork() the child has a different sighand, so the inherited epoll registration is attached to the parent’s wait queue and the child is never woken — even though a read() on the same descriptor works perfectly.

Both queues are checked. current->pending holds thread-directed signals (pthread_kill, tgkill, a synchronous fault); current->signal->shared_pending holds process-directed signals (kill(pid, …), SIGCHLD, terminal signals), which the kernel may deliver to any thread in the group that has them unblocked. A signalfd is readable if either matches, which is exactly what the man page means by “a thread reads … the signals that are directed to the thread itself and the signals that are directed to the process”.

The two queues are worth tabulating, because which one a signal lands in determines who can read it:

task->pendingtask->signal->shared_pending
Holdsthread-directed signalsprocess-directed signals
Produced bypthread_kill(3), tgkill(2), raise(3) on a threaded build, synchronous faultskill(2), sigqueue(3), SIGCHLD, terminal signals (SIGINT from Ctrl-C), killpg(2)
Visible toonly the target thread’s read()/poll()any thread in the group — consumed by whichever reads first
Scope of the block maskthe target thread’s own mask decidesdelivered to any thread that has not blocked it; only pends if every thread blocks it
dequeue_signal() prioritychecked firstchecked only if the thread queue had no match
Survives forkno — the child starts with an empty pending setno

The priority ordering in the last-but-one row is dequeue_signal() in kernel/signal.c L626: it calls __dequeue_signal(&tsk->pending, …) and only falls through to &tsk->signal->shared_pending if that returned nothing. A thread-directed signal therefore jumps the queue ahead of a process-directed one when both are pending and both match the mask.

EPOLLOUT is never returned. A signalfd is O_RDWR at the VFS level but has no .write_iter; writing gets -EINVAL. Registering it for EPOLLOUT in an epoll set is a silent no-op that will never fire.

Reading — dequeue and flatten

signalfd_read_iter() (L201) divides the user buffer by sizeof(struct signalfd_siginfo) to learn how many records fit. A buffer smaller than 128 bytes is -EINVAL — not a short read. Then:

nonblock = file->f_flags & O_NONBLOCK || iocb->ki_flags & IOCB_NOWAIT;
do {
        ret = signalfd_dequeue(ctx, &info, nonblock);
        if (unlikely(ret <= 0)) break;
        ret = signalfd_copyinfo(to, &info);
        if (ret < 0) break;
        total += ret;
        nonblock = 1;                 /* <-- only the FIRST record may block */
} while (--count);
 
return total ? total : ret;

The nonblock = 1 on the penultimate line is the whole batching policy in one assignment: only the first record of a read() may block. Subsequent iterations are forced non-blocking, so a single read() drains as many pending signals as currently fit and returns, rather than waiting for the buffer to fill. And return total ? total : ret means a partially successful read reports its bytes and swallows the -EAGAIN; you only see the error when nothing was read.

signalfd_dequeue() (L154) takes siglock and calls dequeue_signal(&ctx->sigmask, info, &type) — the same function sigwaitinfo() and the normal delivery path use. In v6.12 that function tries tsk->pending first and only then tsk->signal->shared_pending (kernel/signal.c L626), so a thread-directed signal wins over a process-directed one when both are pending and both match. If nothing matches and the descriptor is non-blocking, -EAGAIN; otherwise it sleeps on signalfd_wqh in TASK_INTERRUPTIBLE and re-checks in a loop, returning -ERESTARTSYS if the reading thread itself takes a signal (which is what makes a blocking signalfd read restartable rather than spuriously failing).

stateDiagram-v2
    [*] --> Generated: kill / sigqueue / raise /<br/>pthread_kill / child exit /<br/>timer expiry
    Generated --> Discarded: disposition is SIG_IGN<br/>and the signal is not blocked
    Generated --> Coalesced: standard signal (num &lt; SIGRTMIN)<br/>already pending —<br/>legacy_queue() drops it
    Generated --> Pending: blocked in the target's mask —<br/>signalfd_notify() then sigaddset()
    Generated --> Delivered: not blocked —<br/>handler runs, or default action
    Delivered --> [*]
    Discarded --> [*]
    Coalesced --> [*]
    Pending --> Readable: signalfd_poll() finds it<br/>via next_signal() in either<br/>task pending or shared_pending
    Readable --> Pending: another fd or sigwaitinfo<br/>has not taken it yet
    Readable --> Dequeued: read() -> signalfd_dequeue()<br/>-> dequeue_signal() REMOVES it
    Pending --> Delivered: the mask is later relaxed<br/>(sigprocmask SIG_UNBLOCK)
    Dequeued --> Copied: signalfd_copyinfo() flattens<br/>kernel_siginfo_t into the<br/>128-byte record
    Copied --> [*]
    note right of Pending
        This is the ONLY state
        signalfd can read from.
        Getting a signal into it
        is what blocking does.
    end note

One signal’s possible fates. What it shows: five terminal outcomes, of which exactly one — Dequeued → Copied — is the signalfd path, and it is reachable only through Pending. The insight to take: every signalfd bug is a signal that took one of the other four edges. Killed the process? It went Generated → Delivered because it was not blocked. Read fewer records than you sent? Some went Generated → Coalesced. Nothing ever arrives? Either SIG_IGN (Discarded) or the mask was relaxed somewhere and the signal escaped via Pending → Delivered.


struct signalfd_siginfo — the Flattened siginfo_t

The record a read() returns is not a siginfo_t. It is a separate, deliberately frozen ABI defined in include/uapi/linux/signalfd.h, and the header’s own comment explains why:

Pad structure to 128 bytes. Remember to update the pad size when you add new members. We use a fixed size structure to avoid compatibility problems with future versions, and we leave extra space for additional members. We use fixed size members because this structure comes out of a read(2) and we really don’t want to have a compat on read(2).

That is the entire design rationale in three sentences. siginfo_t is a union whose layout differs between 32- and 64-bit userspace, which is fine for a syscall (the kernel has a compat path) and impossible for read() (which has no idea what it is filling). So signalfd defines a flat struct of fixed-width integers, no unions, padded to exactly 128 bytes by __u8 __pad[28], and fs/signalfd.c asserts it: BUILD_BUG_ON(sizeof(struct signalfd_siginfo) != 128).

Measured on the test machine (x86-64, glibc 2.43) — every offset confirmed with offsetof():

OffsetTypeFieldsiginfo_t counterpartFilled for which signal layouts
0__u32ssi_signosi_signoalways
4__s32ssi_errnosi_errnoalways (usually 0 on Linux)
8__s32ssi_codesi_codealways — the discriminant that says which other fields are valid
12__u32ssi_pidsi_pidSIL_KILL, SIL_CHLD, SIL_RT
16__u32ssi_uidsi_uidSIL_KILL, SIL_CHLD, SIL_RT
20__s32ssi_fdsi_fdSIL_POLL (SIGIO/SIGPOLL)
24__u32ssi_tidsi_tidSIL_TIMER (POSIX timer id)
28__u32ssi_bandsi_bandSIL_POLL
32__u32ssi_overrunsi_overrunSIL_TIMER (timer overrun count)
36__u32ssi_trapnosi_trapnoSIL_FAULT_TRAPNO
40__s32ssi_statussi_statusSIL_CHLD (child exit status / stop signal)
44__s32ssi_intsi_intSIL_TIMER, SIL_RT (sigqueue() payload)
48__u64ssi_ptrsi_ptrSIL_TIMER, SIL_RT (sigqueue() payload)
56__u64ssi_utimesi_utimeSIL_CHLD (child user CPU time, in clock ticks)
64__u64ssi_stimesi_stimeSIL_CHLD (child system CPU time)
72__u64ssi_addrsi_addrSIL_FAULT* (faulting address)
80__u16ssi_addr_lsbsi_addr_lsbSIL_FAULT_MCEERR (log2 of the poisoned page size)
82__u16__pad2—padding
84__s32ssi_syscallsi_syscallSIL_SYS (seccomp SIGSYS)
88__u64ssi_call_addrsi_call_addrSIL_SYS
96__u32ssi_archsi_archSIL_SYS
100__u8[28]__pad—reserved to 128
packet-beta
0-3: "ssi_signo"
4-7: "ssi_errno"
8-11: "ssi_code"
12-15: "ssi_pid"
16-19: "ssi_uid"
20-23: "ssi_fd"
24-27: "ssi_tid"
28-31: "ssi_band"
32-35: "ssi_overrun"
36-39: "ssi_trapno"
40-43: "ssi_status"
44-47: "ssi_int"
48-55: "ssi_ptr"
56-63: "ssi_utime"
64-71: "ssi_stime"
72-79: "ssi_addr"
80-81: "addr_lsb"
82-83: "__pad2"
84-87: "ssi_syscall"
88-95: "ssi_call_addr"
96-99: "ssi_arch"
100-127: "__pad[28] — reserved, always zero"

The 128-byte struct signalfd_siginfo at byte accuracy, drawn with mermaid packet-beta. What it shows: a flat sequence of fixed-width fields with no unions and no architecture-dependent holes, ending in 28 reserved bytes. The insight to take: the trailing __pad is the whole compatibility strategy — new fields are carved out of it, so the record size never changes and read(2) never needs a compat translation layer. Contrast siginfo_t, which is a union whose interpretation depends on si_code and on the caller’s word size; signalfd pays for a mostly-empty 128 bytes per signal and buys a frozen ABI. Byte offsets were confirmed with offsetof() on x86-64.

The translation happens in signalfd_copyinfo() (L71–L152). It memsets the whole 128 bytes to zero, copies ssi_signo/ssi_errno/ssi_code unconditionally, and then switches on siginfo_layout(si_signo, si_code) — the kernel’s own classifier — filling only the fields that layout defines:

siginfo_layout() resultTypical sourceFields signalfd fills
SIL_KILLkill(2), raise(3), tgkill(2)ssi_pid, ssi_uid
SIL_TIMERPOSIX timer expiry (timer_create(2))ssi_tid, ssi_overrun, ssi_ptr, ssi_int
SIL_POLLSIGIO / SIGPOLLssi_band, ssi_fd
SIL_FAULTSIGSEGV, SIGBUS, SIGILL, SIGFPEssi_addr
SIL_FAULT_TRAPNOditto, on architectures with a trap numberssi_addr, ssi_trapno
SIL_FAULT_MCEERRmachine-check / hardware memory poisoningssi_addr, ssi_addr_lsb
SIL_FAULT_BNDERR / SIL_FAULT_PKUERR / SIL_FAULT_PERF_EVENTMPX bounds, protection keys, perffalls through to SIL_FAULT — only ssi_addr
SIL_CHLDSIGCHLDssi_pid, ssi_uid, ssi_status, ssi_utime, ssi_stime
SIL_RTsigqueue(3), realtime signalsssi_pid, ssi_uid, ssi_ptr, ssi_int
SIL_SYSseccomp SIGSYSssi_call_addr, ssi_syscall, ssi_arch

Two details in that table repay attention. The fault layouts are present at all only because a fault signal can be injected artificially (by kill/sigqueue with a forged si_code) — the source comment says so explicitly: “SIL_FAULT_BNDERR, SIL_FAULT_PKUERR, and SIL_FAULT_PERF_EVENT are only generated by faults that deliver them synchronously to userspace. In case someone injects one of these signals and signalfd catches it treat it as SIL_FAULT.” A genuine SIGSEGV from a bad pointer never reaches a signalfd; a forged one can, and the kernel degrades it to the simplest layout rather than trusting the forged fields.

And every field the layout does not name is zero, not stale. That is what the opening memset buys, and it means a reader can distinguish “this signal carries no pid” from “the pid happened to be 0” only by checking ssi_code — the discriminant, not the data.

Uncertain

ssi_utime and ssi_stime are documented as the child’s user and system CPU time for SIGCHLD, in clock ticks (sysconf(_SC_CLK_TCK)). The units were not re-verified experimentally here, and siginfo_t’s si_utime/si_stime have historically been a source of unit confusion between clock ticks and HZ. To resolve: run a child that burns a known amount of CPU and compare ssi_utime against times(2). uncertain


The Trap, Demonstrated

The single most common signalfd bug is not subtle and does not need a race to reproduce: create a signalfd, forget to block the signal, and the default disposition still runs. Two children, identical except for one sigprocmask call:

static void child_unblocked(void)          /* A: the bug */
{
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGUSR1);
    /* NOTE: NO sigprocmask() here. This is the bug. */
    int sfd = signalfd(-1, &mask, SFD_CLOEXEC);
    fprintf(stderr, "  [child A] signalfd created, fd=%d, SIGUSR1 NOT blocked\n", sfd);
    raise(SIGUSR1);
    fprintf(stderr, "  [child A] still alive after raise() -- never reached\n");
    _exit(0);
}
 
static void child_blocked(void)            /* B: correct */
{
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGUSR1);
    sigprocmask(SIG_BLOCK, &mask, NULL);   /* <-- the one line that matters */
    int sfd = signalfd(-1, &mask, SFD_CLOEXEC);
    raise(SIGUSR1);
    struct signalfd_siginfo si;
    ssize_t n = read(sfd, &si, sizeof si);
    fprintf(stderr, "  [child B] read() = %zd bytes, ssi_signo=%u ssi_code=%d "
                    "ssi_pid=%u ssi_uid=%u\n",
            n, si.ssi_signo, si.ssi_code, si.ssi_pid, si.ssi_uid);
    _exit(0);
}

Compiled with GCC 16.2.1 / glibc 2.43 on Fedora 44, kernel 7.1.13-200.fc44.x86_64:

A: signalfd WITHOUT blocking the signal
  [child A] signalfd created, fd=3, SIGUSR1 NOT blocked
  [child A] KILLED by signal 10 (User defined signal 1)
 
B: signalfd WITH the signal blocked first
  [child B] signalfd created, fd=3, SIGUSR1 IS blocked
  [child B] still alive after raise()
  [child B] read() = 128 bytes, ssi_signo=10 ssi_code=-6 ssi_pid=1419966 ssi_uid=1000
  [child B] exited normally, status 0

Child A never reaches its second fprintf. The signalfd() call succeeded, the descriptor was valid, and SIGUSR1’s default action — terminate — ran anyway, because nothing had blocked it. Child B survives and reads a full 128-byte record.

The record’s contents are themselves instructive. ssi_signo = 10 is SIGUSR1 on x86-64. ssi_code = -6 is SI_TKILL, not SI_USER (0) — because glibc implements raise(3) on a threaded build as tgkill(getpid(), gettid(), sig), a thread-directed signal, and the kernel stamps si_code accordingly (include/uapi/asm-generic/siginfo.h L181). ssi_pid and ssi_uid identify the sender, which here is the process itself.

Error semantics, measured

The same machine, probing every documented edge:

write(sfd, buf, 128)                   -> -1  Invalid argument
read(sfd, buf, 127)  [< 128]           -> -1  Invalid argument
read(sfd, buf, 128)  [nothing pending] -> -1  Resource temporarily unavailable
lseek(sfd, 0, SEEK_SET)                -> 0   ok
signalfd(1, ...)   [fd 1 is stdout]    -> -1  Invalid argument
signalfd(-1, ..., 1<<20) [bad flag]    -> -1  Invalid argument
signalfd(999, ...) [closed fd]         -> -1  Bad file descriptor
OperationResultWhy
write(sfd, …)-EINVALsignalfd_fops declares no .write_iter at all; the file is O_RDWR only so the flags plumbing is uniform
read(sfd, buf, n) with n < 128-EINVALcount /= sizeof(struct signalfd_siginfo) yields 0 and signalfd_read_iter() bails — not a short read
read() with nothing pending, SFD_NONBLOCK-EAGAINsignalfd_dequeue() with nonblock set
read() with nothing pending, blockingsleeps in TASK_INTERRUPTIBLEon signalfd_wqh; -ERESTARTSYS if the reader is itself signalled
lseek(sfd, …)succeeds, does nothing.llseek = noop_llseek
signalfd(fd, …) where fd is not a signalfd-EINVALf_op != &signalfd_fops check in do_signalfd4()
signalfd(fd, …) where fd is closed-EBADFfdget() returns nothing
signalfd(-1, …, unknown_flag)-EINVALflags & ~(SFD_CLOEXEC | SFD_NONBLOCK)
signalfd(…) with SIGKILL/SIGSTOP in the masksucceeds, silently strips themsigdelsetmask() — no error, no warning
raw signalfd4(…) with sizemask != sizeof(sigset_t)-EINVALthe glibc wrapper always supplies the right value, so this only bites raw syscall() users

write() is EINVAL because signalfd_fops has no .write_iter at all. A read buffer of 127 bytes is EINVAL, not a short read — count /= sizeof(struct signalfd_siginfo) yields 0 and the function bails. An empty non-blocking read is EAGAIN. lseek succeeds and does nothing, because the fops set .llseek = noop_llseek. Passing a descriptor that exists but is not a signalfd is EINVAL (the f_op != &signalfd_fops check); passing one that does not exist is EBADF.

SIGKILL/SIGSTOP stripping, and coalescing, observed from userspace

Asking a signalfd to watch SIGKILL (9), SIGSTOP (19), SIGINT (2) and SIGRTMIN (34), then reading /proc/self/fdinfo/<fd>:

signalfd() returned 3
sigmask:	0000000200000002
/proc/self/fd/3 -> anon_inode:[signalfd]

0x0000000200000002 has exactly two bits set: bit 1 and bit 33 — signal numbers 2 (SIGINT) and 34 (SIGRTMIN), since the mask is zero-based. SIGKILL would be bit 8 (0x100) and SIGSTOP bit 18 (0x40000); both are absent. The kernel accepted the call, returned a valid descriptor, and silently discarded half of what was asked for — exactly as sigdelsetmask() says it will.

Then the classic queuing difference. Sending SIGINT three times and SIGRTMIN three times (all via sigqueue() with distinct payloads 100, 101, 102) and draining:

record 1: ssi_signo=2  ssi_code=-1  ssi_pid=1420733 ssi_int=100
record 2: ssi_signo=34 ssi_code=-1  ssi_pid=1420733 ssi_int=100
record 3: ssi_signo=34 ssi_code=-1  ssi_pid=1420733 ssi_int=101
record 4: ssi_signo=34 ssi_code=-1  ssi_pid=1420733 ssi_int=102
drained 4 records (read now returns Resource temporarily unavailable)

Six signals sent, four records read. The three SIGINTs coalesced into one (payload 100 — the first wins, the later two are discarded outright), while all three SIGRTMINs queued and each produced its own record with its own payload. The rule is one predicate in kernel/signal.c L1068: legacy_queue() returns true when sig < SIGRTMIN && sigismember(&signals->signal, sig), and __send_signal_locked() then goto rets without queuing anything — the second instance of a standard signal that is already pending is simply dropped on the floor. ssi_code = -1 is SI_QUEUE, correctly identifying sigqueue() as the source in every case. This is not a signalfd behaviour — it is the standard-versus-realtime signal rule (signal(7)) showing through, because signalfd reads the same pending sets everything else does. If you need to count events, you need a realtime signal; if SIGCHLD tells you a child exited, you must still waitpid(-1, …, WNOHANG) in a loop because the third and fourth simultaneous exits produced no extra record.


Configuration and Code

The canonical pattern: block the signals, create the descriptor, drive it from an epoll loop. Comments are line-by-line on the load-bearing calls.

#define _GNU_SOURCE
#include <sys/signalfd.h>
#include <sys/epoll.h>
#include <signal.h>
#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
 
int main(void) {
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);     /* Ctrl-C */
    sigaddset(&mask, SIGTERM);    /* graceful shutdown */
    sigaddset(&mask, SIGCHLD);    /* child exited */
 
    /* STEP 1 — THE CRITICAL ONE. Block these signals so the kernel does NOT
     * run their handlers or default actions; it leaves them PENDING for
     * signalfd to dequeue. Without this, SIGINT/SIGTERM would kill the process
     * and SIGCHLD would be ignored, never reaching the fd. Use pthread_sigmask
     * in a multithreaded program (and do it before creating threads, so the
     * mask is inherited by all of them). */
    if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) { perror("sigprocmask"); exit(1); }
 
    /* STEP 2 — create the descriptor watching exactly that set.
     * SFD_CLOEXEC: don't leak across exec(); SFD_NONBLOCK: read() returns
     * EAGAIN rather than blocking — required for an epoll loop. */
    int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
    if (sfd == -1) { perror("signalfd"); exit(1); }
 
    int ep = epoll_create1(EPOLL_CLOEXEC);
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = sfd };
    epoll_ctl(ep, EPOLL_CTL_ADD, sfd, &ev);
 
    for (;;) {
        struct epoll_event out;
        epoll_wait(ep, &out, 1, -1);          /* also wait on sockets here */
 
        struct signalfd_siginfo si;
        /* Drain ALL pending signals: read() returns as many 128-byte records
         * as fit and are pending; loop until EAGAIN. */
        while (read(sfd, &si, sizeof(si)) == sizeof(si)) {
            switch (si.ssi_signo) {
            case SIGINT:
            case SIGTERM:
                printf("shutdown requested (sig %u from pid %u)\n",
                       si.ssi_signo, si.ssi_pid);
                goto done;                    /* clean exit, in NORMAL context */
            case SIGCHLD:
                /* ssi_pid/ssi_status name ONE child. SIGCHLD is a standard
                 * signal and coalesces, so several children can exit and
                 * produce a single record. Reap in a loop regardless. */
                printf("child %u exited, status %d\n", si.ssi_pid, si.ssi_status);
                { int st; pid_t w;
                  while ((w = waitpid(-1, &st, WNOHANG)) > 0)
                      printf("  reaped %d\n", (int)w); }
                break;
            }
        }
    }
done:
    close(sfd);
    close(ep);
    return 0;
}

Everything in that switch runs in ordinary code — printf, malloc, reaping logic, anything. That is the whole point: contrast it with a SIGCHLD handler, which could call only async-signal-safe functions and would have to communicate with the main loop through a volatile sig_atomic_t flag or a self-pipe.

Four details in that skeleton are the ones people get wrong.

SFD_NONBLOCK is not optional in an epoll loop. Without it the drain loop’s final read() — the one that finds nothing left — blocks the entire event loop until the next signal arrives. With it, that read returns EAGAIN and the loop goes back to epoll_wait.

The drain loop is mandatory, and epoll defaults make it look optional. In level-triggered mode (the default) epoll_wait will report the descriptor again if signals remain, so a single read() per wakeup happens to work. In edge-triggered mode (EPOLLET) it will not, and you will silently stall with signals pending. Draining until EAGAIN is correct under both.

One read() can return several records. The loop above reads one 128-byte record at a time for clarity; passing an array of, say, eight struct signalfd_siginfo lets the kernel fill as many as are pending in a single syscall (signalfd_read_iter forces every record after the first to be non-blocking, so it never waits to fill the buffer). Divide the return value by sizeof(struct signalfd_siginfo) to get the count.

Do not switch on ssi_signo alone when the payload matters. ssi_code is the discriminant that says which other fields are valid. SIGCHLD with ssi_code == CLD_EXITED means ssi_status is an exit status; with CLD_KILLED it is a signal number; with CLD_STOPPED or CLD_CONTINUED the child is still alive and there is nothing to reap.

To change the watched set on a live descriptor (e.g. stop watching SIGCHLD), pass the existing fd instead of -1:

sigdelset(&mask, SIGCHLD);
signalfd(sfd, &mask, 0);   /* same fd: replaces ctx->sigmask in place */

Two cautions. The update replaces the mask under sighand->siglock and then wakes signalfd_wqh, so a concurrent poller is correctly re-evaluated — but it does not touch the thread’s blocked set. Narrowing the signalfd’s mask while leaving the signal blocked means the signal now accumulates as pending with nothing consuming it: it is neither delivered nor readable, and it will fire the moment anything unblocks it. Keep the two masks in step, updating the blocked set with sigprocmask/pthread_sigmask alongside every signalfd(sfd, …) call.


Threads, fork, exec, and epoll — Measured Semantics

Every one of these follows from one sentence: the descriptor is a view onto the queues of whichever task calls read(). But “follows from” is not the same as “obvious”, so each was measured on the 7.1.13 test machine with the signals SIGUSR1 and SIGUSR2 blocked process-wide before any thread was created.

Thread-directed versus process-directed

[1] pthread_kill(main, SIGUSR1) -- thread-directed to MAIN
  [thread B, tid=1421669] read(sfd) -> Resource temporarily unavailable
  [main    , tid=1421668] read(sfd) -> record signo=10 code=-6
 
[2] kill(getpid(), SIGUSR2) -- process-directed (shared_pending)
  [thread B, tid=1421670] read(sfd) -> record
  [main    ] read(sfd) -> Resource temporarily unavailable

In case [1] the signal was aimed at the main thread with pthread_kill(), so it landed on main->pending. Thread B, reading the very same descriptor, got EAGAIN — it cannot see another thread’s private queue. Main then read it successfully.

In case [2] the signal was aimed at the process with kill(), so it landed on the thread group’s shared_pending. Thread B read first and consumed it; main, reading afterwards, got EAGAIN. Process-directed signals go to whoever reads first, exactly once.

flowchart TD
  K["kill(pid, SIGTERM)<br/>-- process-directed"] --> PICK{"does at least one thread<br/>have SIGTERM unblocked?"}
  PICK -->|"yes"| CHOOSE["complete_signal() picks ONE:<br/>the pid-suggested task if it<br/>wants_signal(), else round-robin<br/>from signal-&gt;curr_target"]
  CHOOSE --> ACT["that thread runs the handler,<br/>or the DEFAULT ACTION.<br/>SIGTERM default = kill the process."]
  ACT --> DEAD["everything dies, including the<br/>thread holding the signalfd"]
  PICK -->|"no -- every thread blocks it"| SHARED["queued on shared_pending"]
  SHARED --> READ["whichever thread read()s the<br/>signalfd first consumes it,<br/>exactly once"]

Why a process-directed signal needs the mask set in every thread. What it shows: the kernel only parks a process-directed signal in shared_pending when no thread in the group is eligible to take it — wants_signal() in kernel/signal.c rejects a thread only if it has the signal blocked, is exiting, or is stopped/traced. One thread with an unblocked SIGTERM is enough to route the signal away from the signalfd and into the default action. The insight to take: blocking the signal in the signalfd’s own thread is not sufficient and not even the point — the mask must be set process-wide, which is why the idiom is “block in main() before creating any threads” and let inheritance do the rest.

This is why the classic multithreaded bug is what it is. If one thread blocks SIGTERM and a sibling does not, a process-directed SIGTERM will be delivered to the sibling — which has no handler and takes the default action, killing the process — instead of accumulating in shared_pending for the signalfd. The fix is structural: block the set in the main thread before creating any threads, so every thread inherits the mask (pthread_sigmask semantics per pthread_sigmask(3)), and let exactly one thread own the signalfd.

fork(2): the descriptor is inherited, the pending signals are not

[3] fork(): pending SIGUSR1, then fork, child reads the SAME fd
  [child pid=1421671] read(inherited sfd) -> Resource temporarily unavailable
  [parent   ] read(sfd) -> record signo=10

A SIGUSR1 was made pending, then fork() was called. The child inherited the descriptor (same open file description, same signalfd_ctx, same mask) — and read nothing, because fork(2) specifies that “the child does not inherit its parent’s … set of pending signals”. The parent still had it and read it. The man page states the rule from the other direction: “After a fork(2), the child inherits a copy of the signalfd file descriptor. A read(2) from the file descriptor in the child will return information about signals queued to the child.”

The same is true across a Unix-domain socket: pass a signalfd to another process with SCM_RIGHTS (see Unix-Domain Sockets) and the receiver reads its own signals through it. The descriptor carries a filter, never a queue.

execve(2): the mask survives, the descriptor may not

[4] execve(): SigBlk survives, the SFD_CLOEXEC fd does not
SigBlk:	0000000000000a00
    ls: cannot access '/proc/self/fd/3': No such file or directory

0xa00 is bits 9 and 11 — signals 10 (SIGUSR1) and 12 (SIGUSR2), the two that were blocked. The blocked mask is preserved across execve, which is a genuine hazard rather than a convenience: a helper program you fork+exec inherits your blocked set and will not see the signals it expects. The man page is explicit about the mitigation and its limit — unblock the signals between fork() and execve(), but “this won’t be possible in the case of a helper program spawned behind the scenes by any library function that the program may call”, in which case you are pushed back to a classic handler plus a self-pipe.

The descriptor itself went away because it was created with SFD_CLOEXEC. Without that flag it survives exec, and — per the man page — “any signals that were available for reading before the execve(2) remain available to the newly loaded program”, which is consistent with blocked-and-pending signals surviving exec in general. Always pass SFD_CLOEXEC unless you specifically intend the new program to read your signals.

The epoll + fork trap

This one is documented in signalfd(2) and is still startling to watch:

parent: sfd=3 registered in epoll before fork
  child: epoll_wait -> 0 event(s) after 300 ms timeout
  child: read(sfd) -> 128 bytes, signo=10
  child: after re-creating signalfd+epoll, epoll_wait -> 1 event(s)

The child raised SIGUSR1 on itself, then called epoll_wait() on the epoll instance it inherited — and got nothing, for the full 300 ms timeout. A direct read() on the very same descriptor immediately returned the signal. Recreating both the signalfd and the epoll instance inside the child fixed it.

The mechanism is visible in signalfd_poll(): poll_wait(file, &current->sighand->signalfd_wqh, wait) registers against current’s wait queue, and epoll_ctl(EPOLL_CTL_ADD) calls ->poll() exactly once, at registration time, in the registering task. The parent’s registration therefore points at the parent’s sighand->signalfd_wqh. After fork() the child has its own sighand with its own wait queue, which nothing is registered on — so a signal to the child wakes a queue no epoll instance is watching. The readiness test would pass; it is never run, because the wake-up never arrives.

signalfd(2) gives the two workarounds and this run confirms the first: after fork(), the child closes the inherited signalfd (and epoll instance) and creates fresh ones; or the parent and child both defer creating their signalfds and adding them to epoll until after the fork().

flowchart TD
  subgraph PARENT["parent task"]
    PSH["parent sighand<br/>signalfd_wqh (P)"]
    PEP["epoll instance:<br/>registration points at (P)"]
    PSFD["sfd (open file description)"]
    PEP -->|"epoll_ctl(ADD) called ->poll()<br/>ONCE, as the parent"| PSH
    PSFD -.-> PEP
  end
  FORK["fork()"]
  PARENT --> FORK
  subgraph CHILD["child task"]
    CSH["child sighand<br/>signalfd_wqh (C) -- NOBODY REGISTERED"]
    CEP["inherited epoll instance:<br/>registration STILL points at (P)"]
    CSFD["same sfd, inherited"]
  end
  FORK --> CHILD
  SIG["signal to the child"] --> CSH
  CSH -.->|"wake_up (C): no waiters"| NOBODY["epoll_wait in the child<br/>never fires"]
  CSFD -->|"read() dereferences current-&gt;pending<br/>-- works fine"| OK["128-byte record returned"]
  CEP -.->|"watching the wrong queue"| PSH

Why epoll goes deaf after fork while read keeps working. What it shows: epoll registration is a one-time snapshot taken in the registering task, and what it snapshots is a wait queue hanging off that task’s sighand — an object fork() replaces. read(), by contrast, resolves current freshly on every call. The insight to take: the descriptor is not broken in the child; the readiness notification path is. Any design that forks after wiring up an event loop must re-create its signalfd and its epoll instance in the child, and the symptom if it does not — an event loop that hangs while the data is sitting right there — is maximally confusing.


Failure Modes and Common Misunderstandings

“My signal still killed the process / still ran my old handler.” You did not block it. signalfd changes no dispositions and installs no handlers; an unblocked signal is delivered normally and never reaches the descriptor. This is measured above: the child that skipped sigprocmask died on its own raise(). Use sigprocmask single-threaded, pthread_sigmask multithreaded, and do it before creating the descriptor — the window between signalfd() and sigprocmask() is a real race, and it is the one LWN readers objected to in 2007.

“In my threaded program some signals reach signalfd and some don’t, unpredictably.” A process-directed signal is delivered to some arbitrary thread in the group that does not have it blocked. If the signalfd thread blocks SIGTERM but a worker thread does not, the kernel may pick the worker, which runs the default action and kills everything. Block the set in the main thread before spawning any others so the mask is inherited by all of them.

“Two threads read the same signalfd and one always gets EAGAIN.” Correct and expected — measured above. A thread-directed signal is visible only to its target thread; a process-directed one is consumed by whichever thread reads first. Do not fan a signalfd out across a thread pool.

“I can’t catch SIGSEGV/SIGFPE/SIGBUS with signalfd.” Correct, by design. signalfd(2): “The signalfd mechanism can’t be used to receive signals that are synchronously generated, such as the SIGSEGV signal that results from accessing an invalid memory address… Such signals can be caught only via signal handler.” A synchronous fault is tied to the faulting instruction; blocking it and then triggering it is undefined behaviour (in practice the kernel force-unblocks and kills you), not a tidy queued record.

“SIGKILL/SIGSTOP in my mask did nothing.” They are silently stripped by sigdelsetmask() in do_signalfd4(). No error is returned. Confirmed above by reading /proc/self/fdinfo: the bits are simply absent from the stored mask.

“My epoll loop stopped seeing signals after I forked.” The documented trap, reproduced above. epoll registration captures the registering task’s sighand->signalfd_wqh; the child gets a new sighand. Recreate the signalfd and the epoll instance after fork().

“A read returned fewer signals than I sent.” Two separate causes. Standard (non-realtime) signals coalesce — a second SIGINT arriving while one is pending is discarded, as legacy_queue() shows — so N sends can yield one record. And a read() returns only what is pending at that moment and fits in the buffer; a signal arriving a microsecond later is a separate readiness event. Use realtime signals (SIGRTMIN..SIGRTMAX) when every instance must be counted.

“I sized my buffer at sizeof(siginfo_t) and it worked, so they’re the same.” They are both 128 bytes on x86-64 today, which is a coincidence of padding, not an ABI relationship. struct signalfd_siginfo is a flat struct of fixed-width fields deliberately decoupled from siginfo_t; size it with sizeof(struct signalfd_siginfo) and nothing else.

“A 64-byte read gave me half a record.” It gave you -EINVAL. signalfd_read_iter() computes count / 128 and returns -EINVAL when that is zero. There is no partial record and no short read below one record.

“write() to my signalfd hangs / does nothing.” It returns -EINVAL; signalfd_fops has no write operation. The file is opened O_RDWR only so the flags plumbing is uniform.

“I’m leaking the signalfd into helper programs.” Pass SFD_CLOEXEC. Without it the descriptor survives execve and — because the blocked mask also survives — the helper both inherits a censored signal environment and holds a descriptor that would let it eat those signals.

“I reap SIGCHLD from the record’s ssi_pid and children go unreaped.” SIGCHLD is a standard signal and coalesces. Three children exiting simultaneously may produce one record. ssi_pid names a child, not all of them; always loop waitpid(-1, &st, WNOHANG) until it returns 0 or -1.


Alternatives and When to Choose Them

Four mechanisms turn a signal into something a program can act on deliberately, plus one that is often proposed as a replacement and is not.

sigaction handlersigwaitinfo / sigtimedwaitself-pipe tricksignalfdpidfd
Requires blocking the signal firstno — it is the dispositionyesno (handler catches it)yes — and nothing reminds youn/a
Blocking semanticspreemptive: runs at an arbitrary instruction boundaryblocks the calling thread on signals onlypollable: the loop blocks on the pipepollable: joins select/poll/epoll with everything elsepollable: readable when the process exits
Composes with an event looponly via a flag or a self-pipenoyesyes, nativelyyes
Code contextasync-signal-safe functions onlynormalnormal (but the handler is still constrained)normal — malloc, printf, logging all legalnormal
SIGKILL / SIGSTOPcannot be caughtcannot be waited forcannot be caughtsilently stripped from the maskn/a — but pidfd notices a SIGKILLed process exiting
Thread-directed signalsdelivered to the target threaddequeued by the waiting thread if directed at ithandler runs on whichever thread takes itvisible only to the reading threadn/a
Process-directed signalsdelivered to an arbitrary unblocking threaddequeued by any waiting threadas for handlerconsumed by whichever thread reads firstn/a
Survives forkdispositions reset to default for handled signalsmask inherited; pending set clearedpipe inherited, handler inheritedfd inherited, pending set cleared; epoll registration silently brokenfd inherited, still refers to the same process
Survives exechandled signals reset to SIG_DFL; blocked mask survivesblocked mask survivesfds survive unless CLOEXEC; handler is gonefd survives unless SFD_CLOEXEC; blocked mask survivesfd survives unless PIDFD_CLOEXEC
fds consumed00211 per watched process
Full siginfoyes (SA_SIGINFO)yesno — you must smuggle it yourselfyes, as the flat 128-byte recordno — it reports exit, not signals
SinceancientPOSIX.1-2001ancient idiomLinux 2.6.22 (2007)Linux 5.3 (2019)

sigwaitinfo()/sigtimedwait() is signalfd’s closest sibling and the comparison is narrower than people expect: it dequeues from the same pending set, using the same dequeue_signal() call, and it has the same blocking prerequisite. The only real difference is composition. sigwaitinfo blocks a thread on signals and nothing else, so a program using it typically dedicates a thread to signal handling and communicates with the rest of the program by some other means. signalfd turns the same wait into a descriptor, so it can sit in the same epoll set as sockets, timerfds and eventfds. If a dedicated signal thread is what your architecture wants anyway, sigwaitinfo is simpler and portable (POSIX); if signals are one event source among many, signalfd wins. They interoperate — badly. Two consumers of one pending set race for each signal, so pick one.

A classic sigaction handler trades safety for immediacy. It is the only option for synchronously generated faults (SIGSEGV, SIGBUS, SIGFPE, SIGILL), which signalfd structurally cannot receive, and the only option when you must react now rather than at the next loop iteration. The price is async-signal-safety: no malloc, no printf, no locks, nothing that could already be executing on the interrupted stack.

The self-pipe trick — a minimal handler that write()s one byte to a pipe the loop polls — predates signalfd by decades and achieves the same “signal as pollable event”. It still requires you to write an async-signal-safe handler (though a one-byte write() is about as safe as they get), burns two descriptors, and loses the siginfo unless you encode it into the byte. Its one enduring advantage is portability: it works on every Unix, whereas signalfd is Linux-only ([signalfd(2) STANDARDS: “Linux”]). Choose it when you need to run on BSD or macOS too; otherwise signalfd does the same job with one descriptor, no handler, and full siginfo.

pidfd is not a signalfd replacement, and conflating them is a common error. pidfd_open(2) (Linux 5.3) returns a descriptor referring to a process; poll() on it becomes readable when that process exits (pidfd_open(2)). What it replaces is not signalfd in general — it replaces the specific use of SIGCHLD for child-exit notification, and it does so better, because a pidfd is per-child, does not coalesce, cannot be stolen by another thread, and is immune to PID reuse. PIDFD_THREAD (Linux 6.9) narrows it to a single thread; PIDFD_NONBLOCK (5.10) makes the read non-blocking. The complementary call pidfd_send_signal(2) sends a signal to a pidfd, closing the PID-reuse race on the sending side. A modern supervisor typically uses both: a pidfd per child for exit notification, and a signalfd for SIGTERM/SIGINT/SIGHUP control signals that have no pidfd equivalent.

eventfd and timerfd are not alternatives at all but siblings in the same family — the same “make it a descriptor” answer applied to application-defined wakeups and to timer expiry. The idiomatic Linux event loop runs all four side by side: sockets, a timerfd, an eventfd for cross-thread wakeups, and one signalfd for process control.


Production Notes

Graceful shutdown is the dominant use. Block SIGTERM/SIGINT/SIGHUP in main() before any thread exists, create one signalfd for them, add it to the event loop, and on readiness break the loop and run ordinary cleanup — flush buffers, drain in-flight requests, close connections, write a final log line. None of that would be legal in a handler. The shape is so standard that event-loop libraries expose it directly: libev and libevent both offer signalfd-backed signal watchers on Linux, and Rust’s tokio::signal::unix and Go’s os/signal are built on the same “signal becomes a channel/stream event” idea (Go’s runtime uses a handler plus an internal queue rather than signalfd, which is why signal.Notify has the same coalescing caveats).

SIGCHLD without the classic races. Reaping from a SIGCHLD handler is a textbook source of bugs: the handler can interrupt a waitpid elsewhere, it can be coalesced so you miss exits, and it must be async-signal-safe. Moving it into the loop removes the safety constraint entirely, and ssi_pid/ssi_status/ssi_utime/ssi_stime arrive pre-parsed in the record. The coalescing problem does not go away — it is a property of standard signals, not of the delivery mechanism — so the loop must still be while (waitpid(-1, &st, WNOHANG) > 0). For new code supervising a known set of children, a pidfd per child is strictly better than SIGCHLD for this specific job.

Debugging a “disappearing” signal via /proc. When a signal seems to vanish — no handler ran, no default action, nothing logged — two /proc files settle it in seconds. /proc/PID/status shows SigBlk, SigIgn, SigCgt and SigPnd as 64-bit hex masks; a signal in SigBlk is being deliberately held. Then /proc/PID/fdinfo/* shows sigmask: for each signalfd, so you can see which descriptor is claiming it. Both were used in the measurements above: SigBlk: 0000000000000a00 across an exec, and sigmask: 0000000200000002 showing SIGKILL/SIGSTOP stripped. Remember the masks are zero-based — bit n is signal n+1.

One descriptor, one owner. Multiple signalfds on one process are legal and the man page describes them as a feature (“the arrival of different signals will make different file descriptors ready”), but they draw from the same pool: “if a signal appears in the mask of more than one of the file descriptors, then occurrences of that signal can be read (once) from any one of the file descriptors.” Overlapping masks across descriptors, or across a descriptor and a sigwaitinfo() thread, produce a race with no winner. Give each signal exactly one consumer.

The wait queue has a task-tied lifetime, and that was a real vulnerability class. sighand->signalfd_wqh is unusual: almost every ->poll() implementation uses a wait queue whose lifetime matches the struct file being polled, but signalfd’s is tied to the task. A task can exit — freeing its sighand — while an epoll instance still has a registration pointing into that queue, which is a use-after-free. Linux 5.15.8 shipped Eric Biggers’ fix for this class: wait: add wake_up_pollfree(), together with signalfd: use wake_up_pollfree(), binder: use wake_up_pollfree(), and aio: fix use-after-free due to missing POLLFREE handling (LWN, Linux 5.15.8 changelog). The resulting v6.12 code is one line:

void signalfd_cleanup(struct sighand_struct *sighand)
{
        wake_up_pollfree(&sighand->signalfd_wqh);
}

called from __cleanup_sighand() when the last reference to the signal-handler table goes away. It forcibly detaches every waiter before the queue is freed. The lesson for anyone building a similar fd-as-event-source: if your ->poll() waits on something whose lifetime is not the file’s, you owe the polling infrastructure an explicit teardown notification.

Point-in-time. The mechanism described here is stable and has changed very little since 2007 — which is unusual for kernel interfaces and is the main reason signalfd is safe to rely on. The code was read at v6.12 (this vault’s pin, and one of several longterm kernels; per kernel.org/releases.json on 2026-09-11 the longterm list also includes 6.18, with mainline at 7.3-rc2). All measurements in this note were taken on Linux 7.1.13-200.fc44.x86_64 with GCC 16.2.1 and glibc 2.43 on Fedora 44, and every behaviour observed there matches the v6.12 source exactly.


See Also