Self-Pipe Trick, pselect() & ppoll()

 

Self-Pipe Trick, pselect() & ppoll()
Chapter 63 — Part 6: Safely Mixing Signals with I/O Multiplexing

The Problem — Signals and select() Do Not Mix Safely

Imagine your program is using select() to monitor some sockets, and it also needs to handle SIGCHLD (child process exited). You want to notice SIGCHLD while waiting in select(). This sounds simple but has a nasty race condition that can cause your program to miss signals entirely.

This part explains exactly what the race condition is and shows two solutions: the classic self-pipe trick, and the POSIX function pselect().

The Race Condition — Why It Happens

Here is the buggy code pattern that almost every programmer writes first:

/* BUGGY CODE — has a race condition */

static volatile sig_atomic_t child_died = 0;

void sigchld_handler(int sig) { child_died = 1; }

int main(void)
{
    /* Install signal handler */
    signal(SIGCHLD, sigchld_handler);

    while (1) {
        if (child_died) {
            child_died = 0;
            /* Handle child death */
        }

        /* THE RACE IS HERE: signal could arrive between the
           check above and select() below */
        select(nfds, &read_fds, NULL, NULL, NULL);  /* Block */
        /* If signal arrived after the if() check but before
           select() starts — select() might block forever!
           The SIGCHLD was missed. */
    }
}

Race Condition Timeline:
T1
Main loop checks child_died → 0, so no action taken
T2
SIGCHLD arrives → child_died = 1
T3
select() starts blocking… nobody to wake it up now
T4
select() blocks forever — SIGCHLD was missed!

Solution 1 — The Self-Pipe Trick

The self-pipe trick is a classic UNIX technique. The idea is simple: create a pipe. When a signal arrives, the signal handler writes a byte to the write end of the pipe. The read end of the pipe is added to your select()/poll()/epoll interest set. When the byte arrives in the pipe, select() wakes up and you handle the signal.

This works because pipes are file descriptors — they are natively understood by select/poll/epoll, and writing to a pipe from a signal handler is async-signal-safe.

Self-Pipe Architecture
Signal Handler
write(pipe_write_fd, “x”, 1)
Pipe Buffer
[write end] ←→ [read end]
select()/poll()/epoll
Monitoring pipe_read_fd
← Wakes up when byte arrives

Complete Self-Pipe Trick Implementation

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/select.h>
#include <sys/wait.h>
#include <errno.h>

/* The self-pipe: [0] = read end, [1] = write end */
static int pipe_fds[2];

/* Signal handler — only writes one byte to the pipe */
/* write() is async-signal-safe */
static void signal_handler(int sig)
{
    char byte = (char)sig;  /* Optionally encode which signal */
    /* Ignore return value — we are in a signal handler */
    /* Use write() not printf() — printf is NOT signal-safe */
    while (write(pipe_fds[1], &byte, 1) == -1 && errno == EINTR)
        ;
}

/* Set fd to nonblocking mode */
static void set_nonblocking(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(void)
{
    fd_set read_fds, master_fds;
    int maxfd;
    char buf[256];
    ssize_t n;

    /* Create the pipe */
    if (pipe(pipe_fds) == -1) { perror("pipe"); exit(1); }

    /* Make write end nonblocking to prevent signal handler from blocking */
    set_nonblocking(pipe_fds[1]);

    /* Install signal handlers */
    struct sigaction sa;
    sa.sa_handler = signal_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;

    if (sigaction(SIGCHLD, &sa, NULL) == -1) { perror("sigaction SIGCHLD"); exit(1); }
    if (sigaction(SIGTERM, &sa, NULL) == -1) { perror("sigaction SIGTERM"); exit(1); }
    if (sigaction(SIGHUP,  &sa, NULL) == -1) { perror("sigaction SIGHUP");  exit(1); }

    /* Build the fd set */
    FD_ZERO(&master_fds);
    FD_SET(STDIN_FILENO, &master_fds);    /* Watch stdin */
    FD_SET(pipe_fds[0], &master_fds);    /* Watch pipe read end */
    maxfd = pipe_fds[0];

    printf("Waiting for stdin input or signals (SIGTERM, SIGCHLD, SIGHUP)...\n");

    while (1) {
        read_fds = master_fds;

        int ret = select(maxfd + 1, &read_fds, NULL, NULL, NULL);
        if (ret == -1) {
            if (errno == EINTR) continue;  /* Signal interrupted select — retry */
            perror("select"); break;
        }

        /* Check if pipe is readable — a signal arrived */
        if (FD_ISSET(pipe_fds[0], &read_fds)) {
            /* Step 1: Drain the pipe FIRST, then handle the signal */
            /* (If you handle first and then drain, you might lose signals
                that arrived between handling and draining) */
            char sig_bytes[64];
            n = read(pipe_fds[0], sig_bytes, sizeof(sig_bytes));

            /* Step 2: Handle the signals */
            for (int i = 0; i < n; i++) {
                int sig = (int)(unsigned char)sig_bytes[i];
                switch (sig) {
                    case SIGCHLD:
                        /* Reap child processes */
                        while (waitpid(-1, NULL, WNOHANG) > 0)
                            ;
                        printf("Signal: SIGCHLD — child exited\n");
                        break;
                    case SIGTERM:
                        printf("Signal: SIGTERM — shutting down\n");
                        close(pipe_fds[0]);
                        close(pipe_fds[1]);
                        exit(0);
                    case SIGHUP:
                        printf("Signal: SIGHUP — reloading config\n");
                        break;
                }
            }
        }

        /* Check if stdin has input */
        if (FD_ISSET(STDIN_FILENO, &read_fds)) {
            n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
            if (n > 0) {
                buf[n] = '\0';
                printf("stdin: %s", buf);
            } else if (n == 0) {
                printf("stdin EOF\n");
                break;
            }
        }
    }

    close(pipe_fds[0]);
    close(pipe_fds[1]);
    return 0;
}

Important Order: Always drain the pipe first, then handle the signals. If you handle the signal first and then drain, you risk losing a signal that arrived between the two steps. The book explicitly covers this ordering question as an exercise (Exercise 63-4).

Solution 2 — pselect()

pselect() is a POSIX solution to the same problem. It is similar to select() but adds a sigmask parameter. While pselect() is blocked, the kernel atomically replaces the process signal mask with the one you provide. When pselect() returns, the original mask is restored.

This atomicity eliminates the race condition — you cannot miss a signal between checking a flag and entering the blocking call.

pselect() Signature

#include <sys/select.h>

int pselect(int nfds,
            fd_set *readfds,
            fd_set *writefds,
            fd_set *exceptfds,
            const struct timespec *timeout,   /* Nanosecond precision, not microsecond */
            const sigset_t *sigmask);          /* Signal mask to apply while waiting */

/* Returns: same as select() */

/* Key differences from select():
   1. timeout is struct timespec (nanoseconds) not struct timeval (microseconds)
   2. sigmask: the signal mask to use WHILE blocked in pselect()
      When pselect() returns, the original signal mask is restored
      This switch is ATOMIC — no race window */

Using pselect() to Handle Signals Safely

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/select.h>
#include <unistd.h>
#include <errno.h>

static volatile sig_atomic_t got_sigint = 0;

static void sigint_handler(int sig) { got_sigint = 1; }

int main(void)
{
    fd_set read_fds;
    sigset_t block_mask, orig_mask;

    /* Install SIGINT handler */
    struct sigaction sa;
    sa.sa_handler = sigint_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sigaction(SIGINT, &sa, NULL);

    /* Block SIGINT normally — only allow it during pselect() */
    sigemptyset(&block_mask);
    sigaddset(&block_mask, SIGINT);
    sigprocmask(SIG_BLOCK, &block_mask, &orig_mask);

    /* orig_mask has SIGINT unblocked — use it in pselect() */
    /* This means SIGINT can only be delivered WHILE we are inside pselect() */
    /* The atomic swap eliminates the race condition */

    while (1) {
        if (got_sigint) {
            got_sigint = 0;
            printf("Handling SIGINT\n");
            /* No race: if SIGINT arrives here, it is blocked, 
               and will be delivered when pselect() unblocks it */
        }

        FD_ZERO(&read_fds);
        FD_SET(STDIN_FILENO, &read_fds);

        struct timespec timeout = { .tv_sec = 5, .tv_nsec = 0 };

        /* pselect() atomically:
           1. Unblocks SIGINT (applies orig_mask which has SIGINT unblocked)
           2. Starts waiting
           3. On return, restores our block_mask (SIGINT blocked again) */
        int ret = pselect(STDIN_FILENO + 1, &read_fds, NULL, NULL,
                          &timeout, &orig_mask);

        if (ret == -1) {
            if (errno == EINTR) {
                /* Signal interrupted pselect — signal was handled */
                continue;
            }
            perror("pselect"); break;
        }

        if (ret == 0) {
            printf("Timeout\n");
            continue;
        }

        if (FD_ISSET(STDIN_FILENO, &read_fds)) {
            char buf[256];
            ssize_t n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
            if (n > 0) {
                buf[n] = '\0';
                printf("Read: %s", buf);
            }
        }
    }

    return 0;
}

ppoll() and epoll_pwait() — Linux Extensions

Linux provides analogous functions for poll() and epoll:

ppoll()
Like pselect() but for poll(). Not in POSIX (Linux extension). Adds a sigmask parameter to poll() for atomic signal mask handling.
epoll_pwait()
Like epoll_wait() but with an additional sigmask parameter. Atomically swaps the signal mask while blocked, then restores it on return.
#include <poll.h>
int ppoll(struct pollfd fds[], nfds_t nfds,
          const struct timespec *tmo_p,
          const sigset_t *sigmask);

#include <sys/epoll.h>
int epoll_pwait(int epfd,
                struct epoll_event *events,
                int maxevents,
                int timeout,
                const sigset_t *sigmask);

/* Usage is same as their counterparts but with the sigmask for safe signal handling */

Self-Pipe Trick vs pselect() — Which to Use?

Aspect Self-Pipe Trick pselect()
POSIX standard? No — but portable (any UNIX) Yes — SUSv3
Works with poll/epoll? Yes — any I/O multiplexer select() only (use ppoll/epoll_pwait for others)
Complexity More code to set up Cleaner API
Performance Small overhead (pipe I/O) Slightly faster (no pipe I/O)
Which signal? Can encode signal in byte written to pipe Which signal interrupted is implicit — check flags

Interview Questions

Q1: What is the self-pipe trick and why is it needed?
The self-pipe trick solves the race condition that occurs when you try to monitor both signals and file descriptors in the same select()/poll() call. You create a pipe, add the read end to your select() fd set, and in each signal handler write one byte to the write end. When a signal arrives, select() wakes up because the pipe’s read end becomes readable. This lets you handle signals and I/O events in the same event loop without any race conditions.
Q2: Describe the race condition that pselect() was designed to fix.
The race exists between checking a “signal received” flag and calling select(). If the signal arrives after you check the flag (and see it is 0) but before select() starts blocking, select() blocks without knowing a signal came. pselect() fixes this by atomically swapping the process signal mask when it starts blocking. The signal is blocked in your code, you check the flag, then pselect() unblocks the signal atomically when it starts. If the signal arrives at any other point, it is queued until pselect() unblocks it.
Q3: Why should the write end of the self-pipe be set to nonblocking?
The signal handler writes to the pipe. If the pipe’s write end is blocking and the pipe buffer is full (this can happen if signals arrive faster than you drain the pipe), the write() in the signal handler would block. Blocking inside a signal handler is dangerous — it stalls the entire process. Setting the write end to O_NONBLOCK makes write() fail with EAGAIN instead of blocking, which is safe to ignore in a signal handler since the pipe already has a byte indicating a signal occurred.
Q4: What is the correct order — drain the pipe first or handle the signal first?
Always drain the pipe first, then handle the signals. If you handle first and then drain, consider this scenario: a signal arrives after your handler code runs but before you drain the pipe. When you drain, you remove the byte for that new signal. Now you have lost that event — it was never handled. Draining first then handling ensures you process all signals that arrived up to the drain point, and any signal arriving after the drain will leave a new byte in the pipe for the next iteration.
Q5: How does pselect() differ from select() structurally?
pselect() has two structural differences from select(). First, the timeout is a struct timespec with nanosecond precision instead of struct timeval with microsecond precision. Second and most importantly, pselect() takes a sigmask parameter. While pselect() is blocked, the kernel atomically replaces the process signal mask with this sigmask, then restores the original mask when pselect() returns. This atomic mask swap is the key feature that eliminates the signal race condition.
Q6: Is pselect() available on all UNIX systems?
pselect() is specified in POSIX (SUSv3) but was not present in many older UNIX implementations. Linux implemented it starting from kernel 2.6.16 (before that, glibc emulated it without the atomicity guarantee, which defeated its purpose). ppoll() and epoll_pwait() are Linux-only and not in POSIX. The self-pipe trick is the most portable solution since it only requires standard pipe() and write() which work everywhere.

Chapter 63 Summary — Everything in One View

Feature select() poll() Signal I/O epoll
POSIX ~ Linux only
fd limit 1024 Unlimited Unlimited Unlimited
Performance at scale Poor Medium Good Best
Trigger model Level Level ~Edge LT + ET
Safe with signals Via pselect() Via ppoll() N/A Via epoll_pwait()

Leave a Reply

Your email address will not be published. Required fields are marked *