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. */
}
}
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.
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;
}
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:
#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
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() |
