signalfd
signalfdturns 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 withsignalfd(-1, &mask, flags)that becomes readable whenever any signal inmaskis pending for the caller; aread()then returns one or morestruct signalfd_siginforecords, 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 withsigprocmask(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 singleepollloop. 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, perkernel.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; thesiginfo-returningread(), thepoll()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,execandepoll. The signal model it rests on — dispositions, blocking,sigset_tmanipulation, 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->pending if thread-directed,<br/>signal->shared_pending if process-directed"] PEND --> NOTIFY["signalfd_notify() wakes<br/>sighand->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:
- The descriptor has no queue of its own.
struct signalfd_ctxcontains exactly one field, asigset_t. All the state lives in the task. So two signalfds watchingSIGTERMcompete for the same pending signal, asigwaitinfo()call competes with both, and a singleSIGTERMis read exactly once by exactly one of them. - It reads the queues of whoever calls
read(), not of whoever calledsignalfd(). The implementation dereferencescurrent->pendingandcurrent->signal->shared_pending—currentbeing the reading task. Pass the descriptor to another thread, another process over a Unix socket, or afork()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—kmallocasignalfd_ctx, store the normalised mask, take an unused descriptor, and wrap the context in an anonymous inode file named"[signalfd]"viaanon_inode_getfile(), withsignalfd_fopsattached. The file getsO_RDWR | (flags & O_NONBLOCK)and thenFMODE_NOWAIT, which is what letsio_uringandpreadv2(RWF_NOWAIT)poll it without blocking. Verified from userspace:readlink("/proc/self/fd/3")on a live signalfd returnsanon_inode:[signalfd].ufd >= 0— look the descriptor up, verify it really is a signalfd by comparingf_opagainst&signalfd_fops(anything else is-EINVAL, a non-descriptor is-EBADF), then replacectx->sigmaskundersighand->siglockandwake_up(¤t->sighand->signalfd_wqh). The lock matters because a concurrentsignalfd_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, ¤t->sighand->signalfd_wqh, wait);
spin_lock_irq(¤t->sighand->siglock);
if (next_signal(¤t->pending, &ctx->sigmask) ||
next_signal(¤t->signal->shared_pending, &ctx->sigmask))
events |= EPOLLIN;
spin_unlock_irq(¤t->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->pending | task->signal->shared_pending | |
|---|---|---|
| Holds | thread-directed signals | process-directed signals |
| Produced by | pthread_kill(3), tgkill(2), raise(3) on a threaded build, synchronous faults | kill(2), sigqueue(3), SIGCHLD, terminal signals (SIGINT from Ctrl-C), killpg(2) |
| Visible to | only the target thread’s read()/poll() | any thread in the group — consumed by whichever reads first |
| Scope of the block mask | the target thread’s own mask decides | delivered to any thread that has not blocked it; only pends if every thread blocks it |
dequeue_signal() priority | checked first | checked only if the thread queue had no match |
Survives fork | no — the child starts with an empty pending set | no |
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 < 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 onread(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():
| Offset | Type | Field | siginfo_t counterpart | Filled for which signal layouts |
|---|---|---|---|---|
| 0 | __u32 | ssi_signo | si_signo | always |
| 4 | __s32 | ssi_errno | si_errno | always (usually 0 on Linux) |
| 8 | __s32 | ssi_code | si_code | always — the discriminant that says which other fields are valid |
| 12 | __u32 | ssi_pid | si_pid | SIL_KILL, SIL_CHLD, SIL_RT |
| 16 | __u32 | ssi_uid | si_uid | SIL_KILL, SIL_CHLD, SIL_RT |
| 20 | __s32 | ssi_fd | si_fd | SIL_POLL (SIGIO/SIGPOLL) |
| 24 | __u32 | ssi_tid | si_tid | SIL_TIMER (POSIX timer id) |
| 28 | __u32 | ssi_band | si_band | SIL_POLL |
| 32 | __u32 | ssi_overrun | si_overrun | SIL_TIMER (timer overrun count) |
| 36 | __u32 | ssi_trapno | si_trapno | SIL_FAULT_TRAPNO |
| 40 | __s32 | ssi_status | si_status | SIL_CHLD (child exit status / stop signal) |
| 44 | __s32 | ssi_int | si_int | SIL_TIMER, SIL_RT (sigqueue() payload) |
| 48 | __u64 | ssi_ptr | si_ptr | SIL_TIMER, SIL_RT (sigqueue() payload) |
| 56 | __u64 | ssi_utime | si_utime | SIL_CHLD (child user CPU time, in clock ticks) |
| 64 | __u64 | ssi_stime | si_stime | SIL_CHLD (child system CPU time) |
| 72 | __u64 | ssi_addr | si_addr | SIL_FAULT* (faulting address) |
| 80 | __u16 | ssi_addr_lsb | si_addr_lsb | SIL_FAULT_MCEERR (log2 of the poisoned page size) |
| 82 | __u16 | __pad2 | — | padding |
| 84 | __s32 | ssi_syscall | si_syscall | SIL_SYS (seccomp SIGSYS) |
| 88 | __u64 | ssi_call_addr | si_call_addr | SIL_SYS |
| 96 | __u32 | ssi_arch | si_arch | SIL_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() result | Typical source | Fields signalfd fills |
|---|---|---|
SIL_KILL | kill(2), raise(3), tgkill(2) | ssi_pid, ssi_uid |
SIL_TIMER | POSIX timer expiry (timer_create(2)) | ssi_tid, ssi_overrun, ssi_ptr, ssi_int |
SIL_POLL | SIGIO / SIGPOLL | ssi_band, ssi_fd |
SIL_FAULT | SIGSEGV, SIGBUS, SIGILL, SIGFPE | ssi_addr |
SIL_FAULT_TRAPNO | ditto, on architectures with a trap number | ssi_addr, ssi_trapno |
SIL_FAULT_MCEERR | machine-check / hardware memory poisoning | ssi_addr, ssi_addr_lsb |
SIL_FAULT_BNDERR / SIL_FAULT_PKUERR / SIL_FAULT_PERF_EVENT | MPX bounds, protection keys, perf | falls through to SIL_FAULT — only ssi_addr |
SIL_CHLD | SIGCHLD | ssi_pid, ssi_uid, ssi_status, ssi_utime, ssi_stime |
SIL_RT | sigqueue(3), realtime signals | ssi_pid, ssi_uid, ssi_ptr, ssi_int |
SIL_SYS | seccomp SIGSYS | ssi_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_utimeandssi_stimeare documented as the child’s user and system CPU time forSIGCHLD, in clock ticks (sysconf(_SC_CLK_TCK)). The units were not re-verified experimentally here, andsiginfo_t’ssi_utime/si_stimehave historically been a source of unit confusion between clock ticks andHZ. To resolve: run a child that burns a known amount of CPU and comparessi_utimeagainsttimes(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 0Child 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| Operation | Result | Why |
|---|---|---|
write(sfd, …) | -EINVAL | signalfd_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 | -EINVAL | count /= sizeof(struct signalfd_siginfo) yields 0 and signalfd_read_iter() bails — not a short read |
read() with nothing pending, SFD_NONBLOCK | -EAGAIN | signalfd_dequeue() with nonblock set |
read() with nothing pending, blocking | sleeps in TASK_INTERRUPTIBLE | on 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 | -EINVAL | f_op != &signalfd_fops check in do_signalfd4() |
signalfd(fd, …) where fd is closed | -EBADF | fdget() returns nothing |
signalfd(-1, …, unknown_flag) | -EINVAL | flags & ~(SFD_CLOEXEC | SFD_NONBLOCK) |
signalfd(…) with SIGKILL/SIGSTOP in the mask | succeeds, silently strips them | sigdelsetmask() — no error, no warning |
raw signalfd4(…) with sizemask != sizeof(sigset_t) | -EINVAL | the 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 unavailableIn 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->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=10A 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 directory0xa00 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, ¤t->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->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 handler | sigwaitinfo / sigtimedwait | self-pipe trick | signalfd | pidfd | |
|---|---|---|---|---|---|
| Requires blocking the signal first | no — it is the disposition | yes | no (handler catches it) | yes — and nothing reminds you | n/a |
| Blocking semantics | preemptive: runs at an arbitrary instruction boundary | blocks the calling thread on signals only | pollable: the loop blocks on the pipe | pollable: joins select/poll/epoll with everything else | pollable: readable when the process exits |
| Composes with an event loop | only via a flag or a self-pipe | no | yes | yes, natively | yes |
| Code context | async-signal-safe functions only | normal | normal (but the handler is still constrained) | normal — malloc, printf, logging all legal | normal |
SIGKILL / SIGSTOP | cannot be caught | cannot be waited for | cannot be caught | silently stripped from the mask | n/a — but pidfd notices a SIGKILLed process exiting |
| Thread-directed signals | delivered to the target thread | dequeued by the waiting thread if directed at it | handler runs on whichever thread takes it | visible only to the reading thread | n/a |
| Process-directed signals | delivered to an arbitrary unblocking thread | dequeued by any waiting thread | as for handler | consumed by whichever thread reads first | n/a |
Survives fork | dispositions reset to default for handled signals | mask inherited; pending set cleared | pipe inherited, handler inherited | fd inherited, pending set cleared; epoll registration silently broken | fd inherited, still refers to the same process |
Survives exec | handled signals reset to SIG_DFL; blocked mask survives | blocked mask survives | fds survive unless CLOEXEC; handler is gone | fd survives unless SFD_CLOEXEC; blocked mask survives | fd survives unless PIDFD_CLOEXEC |
| fds consumed | 0 | 0 | 2 | 1 | 1 per watched process |
Full siginfo | yes (SA_SIGINFO) | yes | no — you must smuggle it yourself | yes, as the flat 128-byte record | no — it reports exit, not signals |
| Since | ancient | POSIX.1-2001 | ancient idiom | Linux 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
- Signal Sets and Masking —
sigset_t,sigprocmask/pthread_sigmask, and the pending/blocked model that signalfd reads out of; the prerequisite this note depends on and does not re-derive - Async-Signal-Safety and Reentrant Handlers — the handler-context constraints signalfd exists to escape, and the only thing that still works for synchronous faults
- eventfd — sibling fd-event primitive for application-defined wakeups; the usual partner in the same event loop
- timerfd — sibling fd-event primitive for timer expiry; proposed in the same 2007 patch series as signalfd
- The epoll Readiness Model and IPC — the readiness model that makes signalfd’s pollability worth having
- epoll and Scalable Readiness Notification — how
epoll_ctl’s one-time->poll()registration works, which is what makes theforktrap above behave as it does - Unix-Domain Sockets —
SCM_RIGHTSdescriptor passing, and why a passed signalfd reads the receiver’s signals - pidfd Process File Descriptors — the other “make it a descriptor” answer in the signal area; replaces
SIGCHLDfor child-exit notification without the coalescing problem - Linux Signals Overview — the umbrella note: generation, delivery, dispositions, and where signalfd sits among them
- Standard Signals and Their Default Actions — why an unblocked
SIGUSR1kills a process, which is exactly what the demonstration above shows - Realtime Signals — why
SIGRTMINqueued three records above whileSIGINTcoalesced into one - Signal Dispositions Inheritance Across fork and exec — the inheritance rules behind the measured
fork/execbehaviour in this note - sigaction and Signal Handlers — the alternative signalfd is usually chosen over, and the only option for synchronous faults
- Signal Delivery and the Return to Userspace — what the kernel does at the delivery point that signalfd’s blocking short-circuits
- EINTR and Interrupted System Calls — why a blocking signalfd
read()returns-ERESTARTSYSinternally when the reading thread is itself signalled - Linux IPC MOC — parent map; §8 File-Descriptor Event Primitives
- Linux System Call Interface MOC — parent map; signalfd as one of the “make it a file descriptor” syscalls