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.
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");
}
/* ... */
}
This is the gap between sigaction and select
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.
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.
(SIGUSR1)
(no handler called)
readable
select() returns it
signal details
| 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 |
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;
}
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;
}
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
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.
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);
Fixes the race condition. Works with select()-based code. Available everywhere (POSIX).
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.
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.
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).
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().
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.
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.
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.
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.
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.
