Waiting for Signals and File Descriptors Together

 

Waiting for Signals and File Descriptors Together
Chapter 63 · Race Condition with select() · signalfd Solution · EmbeddedPathashala
Race
Condition with select()
signalfd
Linux 2.6.27+ solution
EINTR
The signal interruption errno

There is a classic problem in Linux systems programming: what if your process needs to wait for both I/O events on file descriptors and the delivery of a signal, at the same time? For example, a server that listens for incoming data but also needs to react to SIGCHLD (child process exited) or SIGUSR1 (admin reload signal).

The obvious approach — install a signal handler and then call select() — contains a subtle race condition that can cause your process to block indefinitely. This tutorial explains why the race exists and how Linux solves it cleanly using signalfd.

Key Concepts
sigaction() select() EINTR Race Condition signalfd sig_atomic_t Signal Handler epoll_wait() SFD_NONBLOCK

The Problem: Monitoring FDs and Signals Simultaneously

Imagine you have a server that monitors several client sockets using select(). You also want to handle a SIGUSR1 signal — maybe an admin sends it to trigger a configuration reload. The instinct is to set a global flag in the signal handler and check it after select() returns:

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

/* Global flag set by signal handler */
volatile sig_atomic_t gotSig = 0;

void handler(int sig)
{
    gotSig = 1;   /* Just set a flag — signal handlers must be simple */
}

int main(void)
{
    struct sigaction sa;
    fd_set readfds;
    int ready;

    /* Install signal handler for SIGUSR1 */
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sigaction(SIGUSR1, &sa, NULL);

    /*
     * THE RACE CONDITION IS RIGHT HERE.
     *
     * If SIGUSR1 is delivered AFTER sigaction() returns
     * but BEFORE select() is called:
     *   - handler() runs, sets gotSig = 1
     *   - select() is then called with no fds ready
     *   - select() BLOCKS, because the signal already arrived
     *     and won't arrive again
     *   - gotSig == 1 but nobody checks it until select() unblocks
     *     (which may be never, or only when an fd becomes ready)
     */

    FD_ZERO(&readfds);
    /* ... add fds to readfds ... */

    ready = select(nfds, &readfds, NULL, NULL, NULL);

    if (ready > 0) {
        printf("Some file descriptors are ready\n");
    } else if (ready == -1 && errno == EINTR) {
        /* select() was interrupted by a signal */
        if (gotSig)
            printf("Got SIGUSR1\n");
    }
    /* ... */
}
The bug: There is a gap between installing the signal handler and calling select(). If the signal arrives in that gap, the handler fires and sets gotSig=1, but select() then blocks with no one to interrupt it. The process hangs until a file descriptor becomes ready.

Visualizing the Race Condition

Timeline: Signal Arrives in the Gap

T=0
sigaction(SIGUSR1, …) — handler installed ✓
T=1
SIGUSR1 arrives here — handler() runs, sets gotSig=1
This is the gap between sigaction and select
T=2
select() is called — but signal already consumed
select() BLOCKS indefinitely
gotSig==1 but nobody checks it. Server appears frozen.

The normal “fix” people try — setting a timeout in select() so it periodically wakes up — works but is ugly. You are forced to choose between responsiveness (short timeout, high CPU burn) and efficiency (long timeout, delayed signal response).

A better attempt is the pselect() system call which atomically unblocks signals and enters the wait. But since Linux 2.6.27, there is an even cleaner solution: signalfd.

signalfd — Receive Signals as File Descriptors

The signalfd mechanism (available from Linux 2.6.27 onwards) lets you receive signals as readable data on a file descriptor. Instead of a signal handler being called asynchronously, the signal is queued and you read it like any other data from an fd.

Core idea: Block the signal using sigprocmask() so it doesn’t trigger an asynchronous handler. Then create a signalfd for that signal. Now you can monitor the signalfd with epoll/select/poll just like any other socket. When the signal arrives, the signalfd becomes readable — and you read it to get the signal information.

signalfd: Signals Become Readable Data
Signal arrives
(SIGUSR1)
Kernel queues it
(no handler called)
signalfd becomes
readable
epoll_wait() or
select() returns it
read() gives you
signal details
No race condition — the signal is queued by the kernel and sits in the signalfd until you read it. No gap between “install handler” and “enter wait.”

Why signalfd Eliminates the Race Condition

Aspect Signal Handler + select() signalfd + epoll/select()
Signal delivery Asynchronous — fires the handler at any time Queued — converted to readable data, no handler
Race condition Yes — signal can arrive between handler install and select() None — signalfd is readable until you read it
Signal lost? Possible if select() is not in blocking state No — queued in kernel
Signal info Limited — signal number only in basic handler Full siginfo — sender PID, UID, signal code, etc.
Complexity Tricky — need pselect or careful masking Clean — treat signal like any other fd

Code Example 1 — The Broken Approach (Race Condition)

This is the pattern from the PDF — shown here for learning purposes so you can recognize the bug in real code:

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

volatile sig_atomic_t gotSig = 0;

void handler(int sig)
{
    (void)sig;
    gotSig = 1;
    /*
     * Signal handlers must only call async-signal-safe functions.
     * Setting a volatile flag is safe here.
     * DO NOT call printf(), malloc(), etc. from signal handlers.
     */
}

int main(void)
{
    struct sigaction sa;
    fd_set readfds;
    int ready;
    int nfds = 1;  /* monitor stdin (fd=0) */

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

    /*
     * RACE WINDOW: signal could arrive here.
     * If it does, gotSig will be 1 before select() runs.
     * select() will then block — missing the signal event.
     */

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

    ready = select(nfds, &readfds, NULL, NULL, NULL);

    if (ready == -1 && errno == EINTR) {
        /*
         * If signal arrived WHILE inside select(), select() returns -1
         * with errno=EINTR. This case works correctly.
         * The bug is when signal arrives BEFORE select().
         */
        if (gotSig)
            printf("Signal received (inside select)\n");
    } else if (ready > 0) {
        printf("stdin is readable\n");
    }

    return 0;
}

Code Example 2 — The Correct Approach Using signalfd

This example shows the complete correct approach: block the signal from normal delivery and receive it via a signalfd that you monitor with epoll (or select).

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/signalfd.h>
#include <sys/epoll.h>
#include <errno.h>
#include <string.h>

int main(void)
{
    sigset_t mask;
    int sfd, epfd, nfds;
    struct epoll_event ev, events[4];
    struct signalfd_siginfo siginfo;

    /*
     * Step 1: Block SIGUSR1 from normal asynchronous delivery.
     * This prevents the signal from calling any handler.
     * Instead, the kernel queues it for signalfd to read.
     */
    sigemptyset(&mask);
    sigaddset(&mask, SIGUSR1);
    sigaddset(&mask, SIGTERM);   /* Also handle SIGTERM cleanly */

    if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
        perror("sigprocmask");
        exit(1);
    }

    /*
     * Step 2: Create a signalfd for the blocked signals.
     * This fd becomes readable when SIGUSR1 or SIGTERM is pending.
     * SFD_NONBLOCK | SFD_CLOEXEC are good practice flags.
     */
    sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
    if (sfd == -1) {
        perror("signalfd");
        exit(1);
    }

    /*
     * Step 3: Create epoll and register:
     *   - signalfd  (to receive signals as data)
     *   - stdin     (to receive keyboard input)
     */
    epfd = epoll_create1(EPOLL_CLOEXEC);

    ev.events  = EPOLLIN;
    ev.data.fd = sfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);

    ev.events  = EPOLLIN;
    ev.data.fd = STDIN_FILENO;
    epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);

    printf("Monitoring stdin and SIGUSR1/SIGTERM via epoll+signalfd.\n");
    printf("Send signal with: kill -USR1 %d\n", getpid());

    /*
     * Step 4: Main event loop.
     * Signals and I/O events are handled uniformly — no race.
     */
    for (;;) {
        nfds = epoll_wait(epfd, events, 4, -1);
        if (nfds == -1) {
            if (errno == EINTR) continue;  /* Interrupted by non-signalfd signal */
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;

            if (fd == sfd) {
                /*
                 * Signal arrived — read it to get full info.
                 * signalfd_siginfo gives us signal number, sender PID, etc.
                 */
                ssize_t s = read(sfd, &siginfo, sizeof(siginfo));
                if (s != sizeof(siginfo)) {
                    perror("read signalfd");
                    continue;
                }

                printf("Received signal: %u (from PID %u)\n",
                       siginfo.ssi_signo, siginfo.ssi_pid);

                if (siginfo.ssi_signo == SIGUSR1) {
                    printf("Action: SIGUSR1 received — reloading config\n");
                } else if (siginfo.ssi_signo == SIGTERM) {
                    printf("Action: SIGTERM received — shutting down\n");
                    close(sfd);
                    close(epfd);
                    return 0;
                }

            } else if (fd == STDIN_FILENO) {
                /* Regular I/O event */
                char buf[128];
                ssize_t n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
                if (n > 0) {
                    buf[n] = '\0';
                    printf("stdin input: %s", buf);
                } else if (n == 0) {
                    printf("stdin closed (EOF)\n");
                    goto done;
                }
            }
        }
    }

done:
    close(sfd);
    close(epfd);
    return 0;
}
Why there is no race condition here: The signal is blocked by sigprocmask() before the signalfd is created and before epoll_wait() is called. If SIGUSR1 arrives at any point, the kernel queues it — the signalfd becomes readable and stays readable until you call read() on it. There is no gap where the signal can be lost.
Compile and test:

gcc signalfd_demo.c -o signalfd_demo
./signalfd_demo &
kill -USR1 $!      # Send SIGUSR1 to the background process
kill -TERM $!      # Send SIGTERM to shut it down

What signalfd Gives You — The signalfd_siginfo Structure

When you read from a signalfd, you get a struct signalfd_siginfo. This is much richer than the basic signal number you would get from a simple signal handler:

struct signalfd_siginfo {
    uint32_t ssi_signo;    /* Signal number (e.g., SIGUSR1 = 10) */
    int32_t  ssi_errno;    /* Error number (usually 0) */
    int32_t  ssi_code;     /* Signal code (SI_USER, SI_KERNEL, etc.) */
    uint32_t ssi_pid;      /* PID of the process that sent the signal */
    uint32_t ssi_uid;      /* UID of the process that sent the signal */
    int32_t  ssi_fd;       /* File descriptor (for SIGIO) */
    uint32_t ssi_tid;      /* Timer ID (for timer signals) */
    uint32_t ssi_band;     /* Band event (for SIGIO) */
    uint32_t ssi_overrun;  /* Timer overrun count */
    uint32_t ssi_trapno;   /* Trap number (on hardware fault) */
    int32_t  ssi_status;   /* Exit status or signal (for SIGCHLD) */
    int32_t  ssi_int;      /* Integer sent via sigqueue() */
    uint64_t ssi_ptr;      /* Pointer sent via sigqueue() */
    uint64_t ssi_utime;    /* User CPU time consumed (for SIGCHLD) */
    uint64_t ssi_stime;    /* System CPU time consumed (for SIGCHLD) */
    uint64_t ssi_addr;     /* Faulting address (for SIGBUS, SIGSEGV) */
    /* ... padding ... */
};

For example, when handling SIGCHLD this way, ssi_pid tells you which child process exited and ssi_status gives the exit code — information that would require an additional waitpid() call when using a signal handler.

Alternative: pselect() — A Partial Fix

Before signalfd, the standard way to fix the race was pselect(). It is a variant of select() that atomically unblocks a set of signals and enters the wait in a single operation — no gap.

#include <sys/select.h>
#include <signal.h>

/*
 * pselect() signature:
 *
 * int pselect(int nfds,
 *             fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
 *             const struct timespec *timeout,
 *             const sigset_t *sigmask);  <-- atomically applied mask
 *
 * While pselect() is blocked, the process's signal mask is
 * temporarily REPLACED by sigmask. This means you can unblock
 * SIGUSR1 only during the pselect() call itself.
 */

sigset_t origmask, emptymask;

/* Block SIGUSR1 normally */
sigaddset(&origmask, SIGUSR1);
sigprocmask(SIG_BLOCK, &origmask, NULL);

sigemptyset(&emptymask);  /* Unblock all signals during pselect */

/* pselect() atomically: unblocks signals + waits */
/* No race window between unblocking and waiting */
int ready = pselect(nfds, &readfds, NULL, NULL, NULL, &emptymask);
pselect() advantage

Fixes the race condition. Works with select()-based code. Available everywhere (POSIX).

signalfd advantage

Cleaner — signals are just another fd in your event loop. Works with epoll. No need for the mask dance. Gives full signal info. Easier to integrate into existing epoll servers.

Interview Questions
Q1: What is the race condition that occurs when combining signal handlers with select()?

Answer: The race occurs because there is a gap between installing the signal handler (sigaction) and calling select(). If the signal arrives in that gap, the handler fires and sets a flag, but then select() is called and blocks — because the signal has already been consumed and won’t arrive again. The process hangs until a file descriptor becomes ready, potentially indefinitely. The signal event is effectively missed.

Q2: What does errno == EINTR mean when select() or epoll_wait() returns -1?

Answer: EINTR means the system call was interrupted by a signal while it was blocked. In that case, the signal handler was called and the system call returned early with -1. You should check your signal flags and then restart the call. Note: EINTR only fixes the case where the signal arrives while select() is already blocking — not the case where the signal arrives before select() is called (the race condition).

Q3: What is signalfd and what problem does it solve?

Answer: signalfd (available from Linux 2.6.27) is a mechanism that lets a process receive signals as readable data on a file descriptor, rather than via asynchronous signal handlers. The process first blocks the signals using sigprocmask(), then creates a signalfd for those signals. When a blocked signal arrives, the signalfd becomes readable. The process can then monitor it alongside other fds using select()/poll()/epoll() with no race condition, because the signal is queued by the kernel and stays readable until explicitly consumed by read().

Q4: Why must you call sigprocmask() before using signalfd?

Answer: If you don’t block the signal first, it might still be delivered asynchronously and trigger the default handler (or a previously installed handler) before your signalfd is ready. By blocking the signal via sigprocmask() first, you ensure that all deliveries of that signal go into the pending queue, where signalfd can pick them up via read(). The signal is never “consumed” by an asynchronous handler — it stays queued for your event loop.

Q5: What information does signalfd provide that a regular signal handler cannot easily provide?

Answer: When you read from a signalfd, you get a struct signalfd_siginfo which includes: the signal number, the PID and UID of the sender, the signal code (SI_USER, SI_QUEUE, etc.), exit status and CPU usage for SIGCHLD, the faulting address for SIGSEGV/SIGBUS, and values passed via sigqueue(). A basic signal handler only reliably receives the signal number. Getting the sender’s PID in a signal handler requires using SA_SIGINFO and a three-argument handler, which is more complex and still subject to the race.

Q6: How does pselect() differ from select() in solving the signal race?

Answer: pselect() takes an extra sigmask argument. When pselect() is called, it atomically replaces the process’s signal mask with the provided mask and enters the wait — with no gap between the two operations. This means if you block SIGUSR1 normally but pass an empty mask to pselect(), SIGUSR1 will only be deliverable while pselect() is blocking, eliminating the race. The original mask is restored atomically when pselect() returns. However, signalfd is generally cleaner for epoll-based servers since it integrates signals into the same fd-based event loop.

Q7: Can you use signalfd with epoll in edge-triggered mode?

Answer: Yes. signalfd is a regular file descriptor and can be added to an epoll instance with EPOLLET just like any other fd. When using ET mode with signalfd, you should loop reading signalfd_siginfo structures until you get EAGAIN, to drain all pending signals in one go. This avoids re-entering epoll_wait() with pending signals still queued. Level-triggered mode is simpler here since you read one signal per notification and the kernel reminds you if more are queued.

Chapter 63 Tutorial Series Complete!

You have now covered epoll performance, level vs edge-triggered notification, the ET programming framework with starvation prevention, and the signal+fd waiting problem with signalfd. Explore more Linux Systems Programming tutorials on EmbeddedPathashala.

Back to Part 1 All Tutorials →

Leave a Reply

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