The Self-Pipe Trick
Portable Signal + I/O Multiplexing — Chapter 63 | TLPI Series
Portable
Works on all UNIX
select / poll / epoll
All I/O models
Why Do We Need the Self-Pipe Trick?
The problem: you are using select() (or poll/epoll) to wait for I/O, and at the same time you need to respond to signals. But signals and select() don’t mix safely — a signal can arrive between your preparation and the actual select() call, causing the signal to be missed.
The ideal solution is pselect(), but pselect() is not available on all UNIX platforms. The self-pipe trick is the classic portable workaround that solves this problem using nothing more than a pipe and a signal handler.
The core idea is simple: instead of having the signal handler set a flag and hoping select() will notice, the signal handler writes a byte into a pipe, and the read end of that pipe is watched by select(). When a signal arrives, select() wakes up — because the pipe became readable!
Key Concepts in This Tutorial
self-pipe trick signal handler pipe() O_NONBLOCK fcntl() async-signal-safe write() in handler EAGAIN race condition EINTR select() poll() epoll()
How the Self-Pipe Trick Works — Big Picture
Self-Pipe Trick — Data Flow
Signal Arrives
→
OS calls signal handler
Signal Handler
→
write(“x”) to the write end of pipe (pfd[1])
Pipe Buffer
→
Pipe read end (pfd[0]) becomes readable
select() wakes
→
select() returns — pfd[0] is set in readfds
Main Loop
→
Check FD_ISSET(pfd[0]), drain pipe, take action
The key insight is: select() can only watch file descriptors, not signals. The self-pipe trick converts a signal event into an I/O event by routing it through a pipe. Now select() can watch for it!
Step-by-Step Setup — 6 Steps
The 6 Steps of Self-Pipe Trick
1
Create a pipe + set both ends to non-blocking
Use pipe() to create pfd[0] (read end) and pfd[1] (write end). Mark both as O_NONBLOCK using fcntl(). Non-blocking on write end prevents the signal handler from blocking if the pipe fills up. Non-blocking on read end lets you drain it with a loop.
2
Add the read end of the pipe to the readfds set for select()
FD_SET(pfd[0], &readfds). Also update nfds if pfd[0] is the highest fd. Now select() watches the pipe read end along with your other file descriptors.
3
Install signal handler — write one byte to pfd[1] inside it
The signal handler does only one thing: write() a single byte “x” to pfd[1]. write() is async-signal-safe so this is safe inside a signal handler. If the write fails with EAGAIN (pipe full), ignore it — earlier writes already signaled the arrival.
4
Call select() in a loop — restart on EINTR
If select() is interrupted by the signal handler, it returns -1 with errno=EINTR. Loop back and call select() again. This way you can check for the signal by inspecting readfds instead of checking errno.
5
After select() returns — check if pfd[0] is set
Use FD_ISSET(pfd[0], &readfds). If it is set, at least one signal has arrived since the last time you checked. The bytes in the pipe are just notifications — each byte means “a signal came in”.
6
Drain all bytes from the pipe — handle the signal
Loop calling read(pfd[0], &ch, 1) until it fails with EAGAIN. Multiple signals may have arrived — drain all of them. Then take whatever action your program needs to do in response to the signal.
Why Must Both Pipe Ends Be Non-Blocking?
This is a common interview topic. Both ends must be O_NONBLOCK for different reasons:
Write End (pfd[1]) — Why Non-Blocking?
Signals can arrive very rapidly in bursts. If many signals arrive quickly, the signal handler’s write() calls fill up the pipe buffer. Without O_NONBLOCK, the signal handler’s write() would
block — which is very dangerous inside a signal handler because it can deadlock the whole process.
With O_NONBLOCK, if the pipe is full, write() just fails with EAGAIN and the handler returns safely. It doesn’t matter — earlier writes already told us signals arrived.
Read End (pfd[0]) — Why Non-Blocking?
When draining the pipe after select() wakes up, we loop calling read() until no more bytes are available. Without O_NONBLOCK, the last read() call would
block forever waiting for more data that never comes.
With O_NONBLOCK, when the pipe is empty, read() returns -1 with EAGAIN, and the drain loop knows to stop.
Why Is write() Safe Inside a Signal Handler?
Signal handlers have strict restrictions. You cannot call most standard library functions inside a signal handler because they may not be async-signal-safe — they might use global state, locks, or buffers that could be in an inconsistent state when the signal interrupted the main program.
For example: printf() uses internal locks and buffers — NOT safe in a signal handler. malloc() uses heap locks — NOT safe. But write() is a raw system call with no internal buffering or locks — it IS async-signal-safe and is explicitly listed in the POSIX table of safe functions.
NOT safe in signal handler:
printf(), malloc(), free(), fopen(), strtok(), any stdio function
SAFE in signal handler:
write(), read(), open(), close(), kill(), _exit(), sig_atomic_t reads/writes
Important: The signal handler must also save and restore errno before and after the write() call. This is because write() can change errno, and if the signal arrived while the main program was in the middle of a system call that was setting errno, the main program’s errno would be corrupted.
Saving and Restoring errno in the Signal Handler
This is a subtle but important detail. Any system call inside a signal handler can change the global errno. If the signal interrupted the main program while it was in the middle of checking errno, the value would be overwritten. The fix is simple — save and restore errno.
static void
handler(int sig)
{
int savedErrno; /* Save errno before doing anything */
savedErrno = errno;
/* This write() might change errno to EAGAIN if pipe is full */
if (write(pfd[1], "x", 1) == -1 && errno != EAGAIN)
/* Real error — but we can't easily handle it here */
_exit(EXIT_FAILURE); /* Use _exit(), not exit() — _exit is signal-safe */
errno = savedErrno; /* Restore errno so main program is not affected */
}
Complete Working Example: Self-Pipe Trick
This is a complete, compilable example demonstrating the self-pipe trick. It monitors stdin for input and catches SIGINT (Ctrl+C), handling both through a single select() call.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/select.h>
/* Global pipe file descriptors — used by both handler and main */
static int pfd[2];
/* -------------------------------------------------------
* Signal handler — only writes ONE byte to the pipe.
* This is the entire self-pipe trick on the signal side.
* ------------------------------------------------------- */
static void
handler(int sig)
{
int savedErrno;
savedErrno = errno; /* Save errno — write() may change it */
/* write() is async-signal-safe — safe to call here.
* If pipe is full (EAGAIN), ignore — earlier write already notified. */
if (write(pfd[1], "x", 1) == -1 && errno != EAGAIN) {
/* Unrecoverable error — use _exit (not exit) */
_exit(EXIT_FAILURE);
}
errno = savedErrno; /* Restore errno */
}
/* Helper: make a file descriptor non-blocking */
static void
make_nonblocking(int fd)
{
int flags;
flags = fcntl(fd, F_GETFL);
if (flags == -1) {
perror("fcntl F_GETFL");
exit(EXIT_FAILURE);
}
flags |= O_NONBLOCK;
if (fcntl(fd, F_SETFL, flags) == -1) {
perror("fcntl F_SETFL");
exit(EXIT_FAILURE);
}
}
int main(void)
{
fd_set readfds;
struct sigaction sa;
struct timeval timeout;
int ready, nfds;
char ch;
/* ---- Step 1: Create the pipe ---- */
if (pipe(pfd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
/* Make BOTH ends non-blocking */
make_nonblocking(pfd[0]); /* read end — so drain loop can detect empty */
make_nonblocking(pfd[1]); /* write end — so handler never blocks */
/* ---- Step 2: Set up the fd_set ---- */
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds); /* Watch stdin */
FD_SET(pfd[0], &readfds); /* Watch pipe read end */
/* nfds must be highest fd + 1 */
nfds = pfd[0] + 1; /* Assuming pfd[0] > STDIN_FILENO */
if (STDIN_FILENO + 1 > nfds)
nfds = STDIN_FILENO + 1;
/* ---- Step 3: Install signal handler for SIGINT ---- */
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; /* Restart system calls interrupted by signal */
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
printf("Waiting for stdin input or SIGINT (Ctrl+C)...\n");
printf("PID = %d\n", (int)getpid());
/* ---- Step 4: select() in a loop — restart on EINTR ---- */
for (;;) {
/* IMPORTANT: Must reset fd_set on each iteration!
* select() modifies the sets to show which fds are ready. */
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds);
FD_SET(pfd[0], &readfds);
/* Timeout: wait up to 30 seconds */
timeout.tv_sec = 30;
timeout.tv_usec = 0;
ready = select(nfds, &readfds, NULL, NULL, &timeout);
if (ready == -1) {
if (errno == EINTR) {
/* Signal interrupted select() — restart the loop.
* The handler already wrote to the pipe, so on the
* next iteration select() will return immediately with pfd[0] set. */
printf("[select() was interrupted by a signal — restarting]\n");
continue;
}
perror("select");
exit(EXIT_FAILURE);
}
if (ready == 0) {
printf("Timeout — nothing happened for 30 seconds.\n");
break;
}
/* ---- Step 5: Check if signal arrived via pipe ---- */
if (FD_ISSET(pfd[0], &readfds)) {
printf("A signal was caught via self-pipe!\n");
/* ---- Step 6: Drain ALL bytes from pipe ---- */
/* Loop until read() returns EAGAIN (pipe empty) */
int signal_count = 0;
for (;;) {
if (read(pfd[0], &ch, 1) == -1) {
if (errno == EAGAIN)
break; /* Pipe is empty — done draining */
perror("read");
exit(EXIT_FAILURE);
}
signal_count++;
}
printf("Number of signals delivered: %d\n", signal_count);
printf("Taking action in response to signal...\n");
/* In a real program, take appropriate action here.
* For SIGINT, we could initiate a graceful shutdown. */
printf("Received SIGINT — initiating graceful shutdown.\n");
break;
}
/* Check if stdin has data */
if (FD_ISSET(STDIN_FILENO, &readfds)) {
char buf[256];
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Read from stdin: %s", buf);
} else if (n == 0) {
printf("stdin closed (EOF)\n");
break;
}
}
}
/* Clean up */
close(pfd[0]);
close(pfd[1]);
printf("Program exiting.\n");
return 0;
}
Compile and test:
gcc -Wall -o self_pipe self_pipe.c
./self_pipe
# Type something and press Enter — stdin input detected
# Press Ctrl+C — signal caught via self-pipe
Common Mistakes to Avoid
❌ Mistake 1: Not resetting fd_set before each select() call
select() modifies the fd_set to show only the ready fds. If you call select() in a loop without rebuilding the fd_set, on the second call it will only watch fds that were ready on the previous call — all others are silently removed.
/* WRONG — fd_set is modified by select() */
for (;;) {
ready = select(nfds, &readfds, NULL, NULL, NULL);
/* readfds is now modified! Next iteration is wrong. */
}
/* CORRECT — rebuild fd_set each time */
for (;;) {
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds);
FD_SET(pfd[0], &readfds);
ready = select(nfds, &readfds, NULL, NULL, NULL);
}
❌ Mistake 2: Calling printf() or other non-safe functions in signal handler
printf() uses internal stdio buffers and locks. If the signal arrives while the main program is inside printf(), calling printf() again in the handler causes undefined behavior. Only use async-signal-safe functions in signal handlers.
/* WRONG */
static void handler(int sig) {
printf("Signal received!\n"); /* NOT async-signal-safe! */
}
/* CORRECT */
static void handler(int sig) {
int savedErrno = errno;
write(pfd[1], "x", 1); /* write() is async-signal-safe */
errno = savedErrno;
}
❌ Mistake 3: Forgetting to drain all bytes from the pipe
If multiple signals arrive before you check, there will be multiple bytes in the pipe. If you only read one byte, the pipe still has data, and select() will keep returning immediately on every call — creating a busy loop.
/* WRONG — reads only one byte */
if (FD_ISSET(pfd[0], &readfds)) {
read(pfd[0], &ch, 1); /* One byte only — may miss others */
handle_signal();
}
/* CORRECT — drain all bytes */
if (FD_ISSET(pfd[0], &readfds)) {
for (;;) {
if (read(pfd[0], &ch, 1) == -1) {
if (errno == EAGAIN) break; /* Empty — done */
perror("read"); exit(1);
}
}
handle_signal();
}
❌ Mistake 4: Not setting the pipe write end as non-blocking
If signals arrive in a burst and fill the 64KB pipe buffer, the next write() in the signal handler will block. Blocking inside a signal handler is extremely dangerous — the whole process freezes. Always set the write end non-blocking.
Using Self-Pipe with poll() and epoll_wait()
The self-pipe trick is not limited to select(). The same technique works equally well with poll() and epoll_wait(). The signal handler and pipe setup are identical — only the I/O monitoring call changes.
Self-Pipe with poll()
#include <poll.h>
/* Setup: same pipe creation + handler as before */
struct pollfd fds[2];
fds[0].fd = STDIN_FILENO;
fds[0].events = POLLIN;
fds[1].fd = pfd[0]; /* Pipe read end */
fds[1].events = POLLIN;
int ready = poll(fds, 2, 30000); /* 30 second timeout in ms */
if (ready > 0) {
if (fds[1].revents & POLLIN) {
/* Signal arrived via pipe — drain it */
char ch;
while (read(pfd[0], &ch, 1) == 1)
; /* Drain all bytes */
handle_signal();
}
if (fds[0].revents & POLLIN) {
/* stdin has data */
}
}
Self-Pipe with epoll_wait()
#include <sys/epoll.h>
/* Setup: same pipe creation + handler as before */
int epfd = epoll_create1(0);
struct epoll_event ev;
/* Add stdin */
ev.events = EPOLLIN;
ev.data.fd = STDIN_FILENO;
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);
/* Add pipe read end */
ev.events = EPOLLIN;
ev.data.fd = pfd[0];
epoll_ctl(epfd, EPOLL_CTL_ADD, pfd[0], &ev);
struct epoll_event events[10];
int ready = epoll_wait(epfd, events, 10, 30000); /* 30s timeout */
for (int i = 0; i < ready; i++) {
if (events[i].data.fd == pfd[0]) {
/* Signal via pipe */
char ch;
while (read(pfd[0], &ch, 1) == 1)
; /* Drain */
handle_signal();
}
if (events[i].data.fd == STDIN_FILENO) {
/* stdin ready */
}
}
Self-Pipe Trick vs pselect() — When to Use Which?
Aspect
pselect()
Self-Pipe Trick
Portability
Linux 2.6.16+, SUSv3 (not all UNIX)
Any UNIX with pipe() — very portable
Works with poll/epoll?
No (need ppoll/epoll_pwait separately)
Yes — same trick works with all three
Complexity
Low — one extra argument to pselect()
Medium — need to create pipe + handler + drain logic
Overhead
Lower — no extra fd, no pipe syscalls
Slightly higher — pipe write + pipe read per signal
Use when
Targeting Linux 2.6.16+ and only using select()
Need portability or using poll()/epoll() on older kernels
Interview Questions and Answers
Q1. What is the self-pipe trick and why is it used?
The self-pipe trick is a technique to safely combine signal handling with I/O multiplexing (select/poll/epoll). It works by creating a pipe, adding the read end to the monitored fd set, and writing a byte to the write end inside the signal handler. When a signal arrives, it causes the pipe to become readable, waking up select(). This converts a signal event into an I/O event that select() can detect, eliminating race conditions.
Q2. Why must both ends of the pipe be non-blocking in the self-pipe trick?
The write end (pfd[1]) must be non-blocking so that if signals arrive in a rapid burst and fill the pipe buffer, the signal handler’s write() call fails with EAGAIN instead of blocking — blocking inside a signal handler can deadlock the process. The read end (pfd[0]) must be non-blocking so that when draining the pipe after select() returns, the drain loop’s final read() fails with EAGAIN (pipe empty) instead of blocking forever.
Q3. Why must you save and restore errno in the signal handler?
The signal handler calls write(), which can modify the global errno (for example, setting it to EAGAIN if the pipe is full). If the signal interrupted the main program while it was in the middle of a system call that sets errno, the signal handler overwriting errno would corrupt the main program’s errno value. By saving errno at the start and restoring it at the end of the handler, we prevent this interference.
Q4. Why use write() in the signal handler instead of setting a global flag?
A global flag (volatile sig_atomic_t) could work, but it requires the main loop to periodically check the flag — which still has a race window if the signal arrives just before select() is called. With write() to a pipe, the signal turns into an I/O event that select() detects immediately when it returns. No polling of a flag is needed. The main loop learns about the signal through the normal select() mechanism.
Q5. What happens if you read only one byte from the pipe after select() returns, but multiple signals arrived?
If multiple signals arrived, multiple bytes were written to the pipe. If you only read one byte, the pipe still has data. On the next call to select(), it will immediately return again because the pipe is still readable — creating a busy loop that consumes 100% CPU. The correct approach is to drain ALL bytes from the pipe in a loop until read() returns EAGAIN (non-blocking empty).
Q6. Can the self-pipe trick be used with poll() and epoll_wait()?
Yes. The self-pipe trick works identically with poll() and epoll_wait(). The pipe setup and signal handler are the same. Instead of adding pfd[0] to fd_set for select(), you add pfd[0] to the struct pollfd array for poll(), or use epoll_ctl() to register pfd[0] with the epoll instance. The drain logic after the I/O call returns is also the same.
Q7. Why must you rebuild the fd_set before each call to select() in the self-pipe loop?
select() is destructive — it modifies the fd_set arguments to contain only the fds that are currently ready. If you pass the same fd_set to a second select() call without rebuilding it, only the fds that were ready last time will be watched. All other fds will be silently removed from monitoring. The fd_set must be rebuilt (FD_ZERO + FD_SET) on every iteration of the loop.
Q8. What is the difference between the self-pipe trick and pselect()?
Both solve the same race condition between signals and I/O multiplexing. pselect() is a cleaner kernel-level solution available since Linux 2.6.16 and SUSv3, requiring minimal extra code. The self-pipe trick is more portable (works on any UNIX), works with poll and epoll (not just select), but requires more boilerplate — pipe creation, non-blocking setup, and a drain loop. Modern Linux code can prefer pselect()/ppoll()/epoll_pwait(); portable or older code uses the self-pipe trick.
Chapter 63 Complete
You now understand two key techniques for combining signals with I/O multiplexing — pselect() for clean kernel-level atomicity and the self-pipe trick for maximum portability. Both are fundamental skills for writing robust Linux server applications.
EmbeddedPathashala.com ← Back: pselect()