Advanced Signals: signalfd and the Self-Pipe Trick
Standard signal handlers are awkward. They interrupt your code at arbitrary points, restrict you to a tiny set of safe functions, and make shared state management painful. This chapter covers techniques that convert signals from asynchronous interrupts into synchronous events you can handle in a normal event loop -- alongside sockets, timers, and other file descriptors.
The Problem with Async Signal Handlers
Consider a server using select() or epoll() to multiplex I/O. A signal
handler fires between iterations, setting a flag. But select() is blocking --
it will not check the flag until a file descriptor event wakes it up, which
might not happen for seconds or minutes.
Event loop:
while (running) {
n = select(...) <-- blocks here
handle_fd_events()
check_signal_flag() <-- too late if no fd events
}
Signal arrives during select():
- Handler sets flag
- select() returns EINTR (if no SA_RESTART)
- OR select() keeps sleeping (with SA_RESTART)
We need signals to appear as file descriptor events.
Real-Time Signals (SIGRTMIN to SIGRTMAX)
Standard signals (1-31) have a critical limitation: they are not queued. If two SIGCHLD signals arrive before the handler runs, you get one delivery.
Real-time signals (SIGRTMIN through SIGRTMAX, typically 34-64) fix this:
- They are queued: each send results in one delivery.
- They carry data (an integer or pointer via
sigqueue()). - They are delivered in order (lowest signal number first).
/* rt_signal.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/wait.h>
static void handler(int sig, siginfo_t *info, void *ctx)
{
(void)ctx;
char buf[128];
int len = snprintf(buf, sizeof(buf),
"RT signal %d, value=%d, from PID %d\n",
sig, info->si_value.sival_int, info->si_pid);
write(STDOUT_FILENO, buf, len);
}
int main(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_SIGINFO;
/* Install handler for SIGRTMIN */
sigaction(SIGRTMIN, &sa, NULL);
printf("PID %d: installing handler for signal %d (SIGRTMIN)\n",
getpid(), SIGRTMIN);
printf("RT signal range: %d - %d\n", SIGRTMIN, SIGRTMAX);
pid_t pid = fork();
if (pid == 0) {
/* Child sends 3 queued signals with different values */
union sigval val;
for (int i = 1; i <= 3; i++) {
val.sival_int = i * 100;
sigqueue(getppid(), SIGRTMIN, val);
}
_exit(0);
}
/* Parent: block briefly to let all signals queue up */
sigset_t block;
sigemptyset(&block);
sigaddset(&block, SIGRTMIN);
sigprocmask(SIG_BLOCK, &block, NULL);
waitpid(pid, NULL, 0);
sleep(1);
/* Unblock: all 3 queued signals should now deliver */
printf("Unblocking RT signal...\n");
sigprocmask(SIG_UNBLOCK, &block, NULL);
sleep(1);
return 0;
}
$ ./rt_signal
PID 5000: installing handler for signal 34 (SIGRTMIN)
RT signal range: 34 - 64
Unblocking RT signal...
RT signal 34, value=100, from PID 5001
RT signal 34, value=200, from PID 5001
RT signal 34, value=300, from PID 5001
All three deliveries happen. With a standard signal, only one would.
signalfd(): Signals as File Descriptor Events
Linux provides signalfd(), which creates a file descriptor that becomes
readable when a signal is pending. This is the cleanest integration with
event loops.
/* signalfd_demo.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/signalfd.h>
#include <string.h>
#include <stdlib.h>
#include <poll.h>
int main(void)
{
/* Block SIGINT and SIGTERM so they go to signalfd */
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL);
/* Create signalfd */
int sfd = signalfd(-1, &mask, 0);
if (sfd < 0) {
perror("signalfd");
return 1;
}
printf("PID %d: Press Ctrl+C or send SIGTERM\n", getpid());
/* Event loop using poll */
struct pollfd pfd = { .fd = sfd, .events = POLLIN };
for (;;) {
int ret = poll(&pfd, 1, 5000); /* 5 second timeout */
if (ret < 0) {
perror("poll");
break;
}
if (ret == 0) {
printf("Tick (no signals)...\n");
continue;
}
/* Read the signal info */
struct signalfd_siginfo si;
ssize_t n = read(sfd, &si, sizeof(si));
if (n != sizeof(si)) {
perror("read signalfd");
break;
}
printf("Received signal %d from PID %u\n",
si.ssi_signo, si.ssi_pid);
if (si.ssi_signo == SIGINT || si.ssi_signo == SIGTERM) {
printf("Shutting down.\n");
break;
}
}
close(sfd);
return 0;
}
The flow:
1. Block signals with sigprocmask()
2. Create signalfd() with same mask
3. Signals arrive -> kernel queues them on the fd
4. poll()/epoll() reports fd as readable
5. read() from fd returns struct signalfd_siginfo
6. Handle signal synchronously in your event loop
+----------+ +----------+ +-----------+
| Kernel | --> | signalfd | --> | poll/epoll|
| signal | | (fd) | | event |
| delivery | | | | loop |
+----------+ +----------+ +-----------+
Caution: You must block the signals with
sigprocmask()before creating thesignalfd. If a default handler or custom handler is installed, the signal gets delivered to the handler instead of the fd.
The Self-Pipe Trick
Before signalfd() existed (or on non-Linux systems), the self-pipe trick
was the standard solution. Create a pipe. In the signal handler, write one byte
to the pipe. In the event loop, include the pipe's read end in your
select()/poll() set.
/* self_pipe.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <poll.h>
#include <errno.h>
static int pipe_fds[2];
static void handler(int sig)
{
/* Write one byte -- the signal number */
unsigned char s = (unsigned char)sig;
write(pipe_fds[1], &s, 1);
}
static void make_nonblocking(int fd)
{
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void)
{
if (pipe(pipe_fds) < 0) {
perror("pipe");
return 1;
}
make_nonblocking(pipe_fds[0]);
make_nonblocking(pipe_fds[1]);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
printf("PID %d: Press Ctrl+C or send SIGTERM\n", getpid());
struct pollfd pfds[2] = {
{ .fd = STDIN_FILENO, .events = POLLIN },
{ .fd = pipe_fds[0], .events = POLLIN },
};
for (;;) {
int ret = poll(pfds, 2, 5000);
if (ret < 0 && errno == EINTR)
continue;
if (ret < 0) {
perror("poll");
break;
}
if (ret == 0) {
printf("Tick...\n");
continue;
}
/* Check for stdin input */
if (pfds[0].revents & POLLIN) {
char buf[256];
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Input: %s", buf);
}
}
/* Check for signal via pipe */
if (pfds[1].revents & POLLIN) {
unsigned char sig;
while (read(pipe_fds[0], &sig, 1) > 0) {
printf("Signal %d via self-pipe\n", sig);
if (sig == SIGINT || sig == SIGTERM) {
printf("Shutting down.\n");
close(pipe_fds[0]);
close(pipe_fds[1]);
return 0;
}
}
}
}
close(pipe_fds[0]);
close(pipe_fds[1]);
return 0;
}
Why make the pipe non-blocking? If the signal fires rapidly, the write end
could fill up. A blocking write in a signal handler would deadlock the process.
With O_NONBLOCK, the write simply fails silently if the pipe is full -- which
is fine because we only need to wake the event loop.
Try It: Modify the self-pipe program to handle
SIGUSR1as a "reload configuration" trigger. When received, print "Reloading config..." in the event loop (not in the handler).
timerfd: Timers as File Descriptors
While not a signal mechanism, timerfd solves the same integration problem for
timers. Instead of SIGALRM, you get a readable file descriptor.
/* timerfd_demo.c */
#include <stdio.h>
#include <unistd.h>
#include <sys/timerfd.h>
#include <stdint.h>
#include <poll.h>
int main(void)
{
int tfd = timerfd_create(CLOCK_MONOTONIC, 0);
if (tfd < 0) {
perror("timerfd_create");
return 1;
}
/* Fire every 2 seconds, first fire in 1 second */
struct itimerspec ts = {
.it_interval = { .tv_sec = 2, .tv_nsec = 0 },
.it_value = { .tv_sec = 1, .tv_nsec = 0 },
};
timerfd_settime(tfd, 0, &ts, NULL);
printf("Timer started. Reading 5 ticks...\n");
for (int i = 0; i < 5; i++) {
uint64_t expirations;
ssize_t n = read(tfd, &expirations, sizeof(expirations));
if (n != sizeof(expirations)) {
perror("read timerfd");
break;
}
printf("Timer tick %d (expirations: %lu)\n", i, expirations);
}
close(tfd);
return 0;
}
Integrating Everything: An Event-Driven Server Skeleton
Here is a skeleton that combines signalfd, timerfd, and socket I/O in one event loop:
/* event_server.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/signalfd.h>
#include <sys/timerfd.h>
#include <poll.h>
#include <string.h>
#include <stdint.h>
int main(void)
{
/* 1. Set up signalfd for SIGINT, SIGTERM */
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL);
int sig_fd = signalfd(-1, &mask, 0);
/* 2. Set up timerfd for periodic work */
int tmr_fd = timerfd_create(CLOCK_MONOTONIC, 0);
struct itimerspec ts = {
.it_interval = { .tv_sec = 3, .tv_nsec = 0 },
.it_value = { .tv_sec = 3, .tv_nsec = 0 },
};
timerfd_settime(tmr_fd, 0, &ts, NULL);
/* 3. Event loop */
enum { FD_SIGNAL, FD_TIMER, FD_COUNT };
struct pollfd pfds[FD_COUNT] = {
[FD_SIGNAL] = { .fd = sig_fd, .events = POLLIN },
[FD_TIMER] = { .fd = tmr_fd, .events = POLLIN },
};
printf("PID %d: event server running\n", getpid());
int running = 1;
while (running) {
int n = poll(pfds, FD_COUNT, -1);
if (n < 0) { perror("poll"); break; }
/* Signal event */
if (pfds[FD_SIGNAL].revents & POLLIN) {
struct signalfd_siginfo si;
read(sig_fd, &si, sizeof(si));
printf("Signal %d received. Shutting down.\n", si.ssi_signo);
running = 0;
}
/* Timer event */
if (pfds[FD_TIMER].revents & POLLIN) {
uint64_t exp;
read(tmr_fd, &exp, sizeof(exp));
printf("Timer tick (expirations: %lu)\n", exp);
}
}
close(sig_fd);
close(tmr_fd);
return 0;
}
Event Loop Architecture:
+----------+ +----------+ +----------+
| signalfd | | timerfd | | socket |
| (signals)| | (timers) | | (network)|
+----+-----+ +----+-----+ +----+-----+
| | |
v v v
+--------------------------------------+
| poll() / epoll_wait() |
+--------------------------------------+
|
v
Handle event synchronously
(no async-signal-safety concerns)
Driver Prep: Kernel drivers use
wait_event()andwake_up()for event notification, not signalfd or timerfd. But user-space driver frameworks (like DPDK, SPDK, or UIO helpers) often build event loops with these exact Linux fd types. The pattern of multiplexing heterogeneous event sources into one loop translates directly.
Rust: signalfd via nix
// signalfd_nix.rs // Cargo.toml: nix = { version = "0.29", features = ["signal", "poll"] } use nix::sys::signal::SigSet; use nix::sys::signal::Signal; use nix::sys::signalfd::{SignalFd, SfdFlags}; use nix::sys::signal::SigmaskHow; use nix::sys::signal; use nix::poll::{PollFd, PollFlags, poll, PollTimeout}; use std::os::unix::io::AsFd; fn main() { // Block signals let mut mask = SigSet::empty(); mask.add(Signal::SIGINT); mask.add(Signal::SIGTERM); signal::sigprocmask(SigmaskHow::SIG_BLOCK, Some(&mask)) .expect("sigprocmask"); // Create signalfd let mut sfd = SignalFd::with_flags(&mask, SfdFlags::empty()) .expect("signalfd"); println!("PID {}: Press Ctrl+C or send SIGTERM", std::process::id()); loop { let poll_fd = PollFd::new(sfd.as_fd(), PollFlags::POLLIN); let ret = poll(&mut [poll_fd], PollTimeout::from(5000u16)) .expect("poll"); if ret == 0 { println!("Tick..."); continue; } if let Some(info) = sfd.read_signal().expect("read_signal") { println!("Received signal {} from PID {}", info.ssi_signo, info.ssi_pid); let sig = info.ssi_signo as i32; if sig == Signal::SIGINT as i32 || sig == Signal::SIGTERM as i32 { println!("Shutting down."); break; } } } }
Rust Note: For production async Rust,
signal-hookintegrates withmio(the I/O reactor behind tokio) viasignal-hook-mio. Tokio also provides built-intokio::signalthat uses signalfd on Linux internally. You simplyawaita signal future. The ecosystem has converged on treating signals as just another async event.
Comparison: Signal Handling Approaches
| Approach | Portability | Complexity | Event Loop Integration |
|---|---|---|---|
signal() / sigaction() handler | POSIX | Low | Poor |
| Self-pipe trick | POSIX | Medium | Good |
signalfd() | Linux only | Low | Excellent |
signal-hook (Rust) | Cross-platform | Low | Excellent |
When to Use What
-
Simple CLI tools:
sigaction()with avolatile sig_atomic_tflag. Nothing more needed. -
Event-driven servers on Linux:
signalfd()withepoll(). Clean, efficient, no race conditions. -
Portable servers: Self-pipe trick. Works everywhere, adds one extra fd.
-
Rust programs:
signal-hookcrate. It picks the right backend automatically.
Try It: Write a program that uses
signalfdandtimerfdtogether in onepoll()loop. The timer fires every second and prints a count. SIGUSR1 resets the count to zero. SIGINT exits cleanly.
Knowledge Check
-
Why must you block signals with
sigprocmask()before creating asignalfd? -
What advantage do real-time signals have over standard signals?
-
In the self-pipe trick, why must both ends of the pipe be set to non-blocking mode?
Common Pitfalls
-
Forgetting to block signals before signalfd: The signal gets delivered to the default handler instead of the fd. The fd never becomes readable.
-
Not draining the self-pipe: If you only read one byte but multiple signals arrived, the pipe stays readable. Always read in a loop until
EAGAIN. -
Blocking write in signal handler: If the self-pipe fills up and the write end is blocking, the handler blocks forever. Always use
O_NONBLOCK. -
Mixing signalfd and handlers: If you have both a signalfd and a
sigactionhandler for the same signal, behavior is undefined. Pick one. -
Ignoring timerfd expirations count:
read()on a timerfd returns auint64_twith the number of expirations since last read. If your process was delayed, this count can be greater than 1. -
Using signalfd in multithreaded programs: Signal masks are per-thread. Block the signals in all threads, then read the signalfd from one thread only.