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 the signalfd. 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 SIGUSR1 as 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() and wake_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-hook integrates with mio (the I/O reactor behind tokio) via signal-hook-mio. Tokio also provides built-in tokio::signal that uses signalfd on Linux internally. You simply await a signal future. The ecosystem has converged on treating signals as just another async event.

Comparison: Signal Handling Approaches

ApproachPortabilityComplexityEvent Loop Integration
signal() / sigaction() handlerPOSIXLowPoor
Self-pipe trickPOSIXMediumGood
signalfd()Linux onlyLowExcellent
signal-hook (Rust)Cross-platformLowExcellent

When to Use What

  • Simple CLI tools: sigaction() with a volatile sig_atomic_t flag. Nothing more needed.

  • Event-driven servers on Linux: signalfd() with epoll(). Clean, efficient, no race conditions.

  • Portable servers: Self-pipe trick. Works everywhere, adds one extra fd.

  • Rust programs: signal-hook crate. It picks the right backend automatically.

Try It: Write a program that uses signalfd and timerfd together in one poll() loop. The timer fires every second and prints a count. SIGUSR1 resets the count to zero. SIGINT exits cleanly.

Knowledge Check

  1. Why must you block signals with sigprocmask() before creating a signalfd?

  2. What advantage do real-time signals have over standard signals?

  3. 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 sigaction handler for the same signal, behavior is undefined. Pick one.

  • Ignoring timerfd expirations count: read() on a timerfd returns a uint64_t with 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.