What is Signal-Driven I/O?
In normal blocking I/O your program stops and waits until data is ready. In non-blocking I/O your program keeps polling in a loop asking “is data ready yet?” Signal-driven I/O is a smarter approach — your program tells the kernel “Hey, signal me when data is ready” and then goes off doing other work. When data actually arrives the kernel sends a SIGIO signal to your process. Your signal handler runs, reads the data, and your main loop is never blocked.
This is especially powerful when you want your program to be responsive to input without burning CPU in a polling loop.
Three actors are involved: your program, the kernel, and the file descriptor (e.g. stdin, socket).
| Step | Who Does It | What Happens | System Call / Flag |
|---|---|---|---|
| 1 | Your Program | Install SIGIO signal handler | sigaction(SIGIO, ...) |
| 2 | Your Program | Tell kernel which process to signal | fcntl(fd, F_SETOWN, getpid()) |
| 3 | Your Program | Enable async signaling on fd + make non-blocking | fcntl(fd, F_SETFL, O_ASYNC | O_NONBLOCK) |
| 4 | Kernel | Monitors the fd for I/O events internally | (automatic) |
| 5 | Kernel | When I/O event happens, sends SIGIO to your process | (automatic) |
| 6 | Your Program | SIGIO handler runs, reads available data | read() inside handler or sets a flag |
You must install the SIGIO handler before enabling signal-driven I/O on the file descriptor. Here is why:
| Order | What Happens | Risk |
|---|---|---|
| Handler first, then O_ASYNC | SIGIO always has a handler ready | Safe ✓ |
| O_ASYNC first, then handler | Time window with no handler. Default SIGIO action = terminate process | Dangerous ✗ |
Note: On some UNIX systems, the default action for SIGIO is to ignore it (not terminate). But on Linux the default is termination, so always set the handler first.
This example demonstrates signal-driven I/O on standard input (terminal). The main loop does heavy computation, but whenever a key is pressed the SIGIO handler sets a flag. The main loop checks the flag and reads the character. Pressing # exits the program.
The terminal is placed in cbreak mode so each character is available immediately without waiting for Enter to be pressed.
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <fcntl.h>
#include <unistd.h>
#include <termios.h>
#include <errno.h>
/* Global flag set by SIGIO handler */
static volatile sig_atomic_t gotSigio = 0;
/* SIGIO signal handler — just sets a flag, does no real work */
static void sigioHandler(int sig)
{
gotSigio = 1;
}
int main(void)
{
struct sigaction sa;
int flags;
char ch;
int done, cnt, j;
struct termios origTermios;
/* ---- STEP 1: Install SIGIO handler FIRST ---- */
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; /* restart syscalls if interrupted */
sa.sa_handler = sigioHandler;
if (sigaction(SIGIO, &sa, NULL) == -1) {
perror("sigaction"); exit(EXIT_FAILURE);
}
/* ---- STEP 2: Tell kernel to signal THIS process ---- */
if (fcntl(STDIN_FILENO, F_SETOWN, getpid()) == -1) {
perror("fcntl F_SETOWN"); exit(EXIT_FAILURE);
}
/* ---- STEP 3: Enable O_ASYNC + O_NONBLOCK on stdin ---- */
flags = fcntl(STDIN_FILENO, F_GETFL);
if (flags == -1) { perror("fcntl F_GETFL"); exit(EXIT_FAILURE); }
if (fcntl(STDIN_FILENO, F_SETFL, flags | O_ASYNC | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL"); exit(EXIT_FAILURE);
}
/* ---- STEP 4: Place terminal in cbreak mode ----
In cbreak mode each key press is delivered immediately.
origTermios saves the original terminal settings for restore later. */
/* ttySetCbreak() is a helper from the TLPI library —
see Chapter 62 for its implementation */
if (ttySetCbreak(STDIN_FILENO, &origTermios) == -1) {
perror("ttySetCbreak"); exit(EXIT_FAILURE);
}
/* ---- STEP 5: Main loop — does "work" and checks flag ---- */
for (done = 0, cnt = 0; !done; cnt++) {
/* Simulate some CPU work */
for (j = 0; j < 100000000; j++)
continue;
/* Check flag set by SIGIO handler */
if (gotSigio) {
/*
* Read ALL available input characters.
* O_NONBLOCK makes read() return EAGAIN when no more data.
* We stop when: EAGAIN (no more data), EOF, or '#' seen.
*/
while (read(STDIN_FILENO, &ch, 1) > 0 && !done) {
printf("cnt=%d; read '%c'\n", cnt, ch);
done = (ch == '#'); /* '#' is our exit character */
}
gotSigio = 0; /* reset flag */
}
}
/* ---- STEP 6: Restore original terminal settings ---- */
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &origTermios) == -1) {
perror("tcsetattr"); exit(EXIT_FAILURE);
}
printf("Exiting after %d loop iterations\n", cnt);
exit(EXIT_SUCCESS);
}
| Code / Call | Purpose | Why It Matters |
|---|---|---|
volatile sig_atomic_t gotSigio |
Flag shared between handler and main loop | sig_atomic_t guarantees atomic read/write across signal boundary |
SA_RESTART |
Auto-restart interrupted system calls | Without this, calls like read() return EINTR when SIGIO fires |
F_SETOWN, getpid() |
Set this process as the owner of the fd | Kernel needs to know which process to send SIGIO to |
O_ASYNC |
Enable signal generation on I/O events | Without this, no SIGIO is ever sent |
O_NONBLOCK |
Make read() non-blocking | Inside handler we drain all data; EAGAIN tells us no more data waiting |
ttySetCbreak() |
Put terminal in single-character mode | Without cbreak, terminal line-buffers input — SIGIO fires only after Enter |
gotSigio = 0 after reading |
Reset the flag | Prevents re-reading on next loop iteration when no new input arrived |
tcsetattr(TCSAFLUSH) |
Restore terminal to original settings | Must always restore terminal on exit or the shell becomes unusable |
while (1) {
ret = read(fd, buf, 1);
if (ret == -1 && errno == EAGAIN)
continue; /* wasted CPU */
if (ret > 0)
process(buf);
}
Wastes 100% CPU in a tight loop asking kernel repeatedly if data is ready.
/* In signal handler */
void handler(int sig) {
gotSigio = 1;
}
/* Main loop does real work */
while (!done) {
do_useful_work();
if (gotSigio) {
read_data(); /* only when needed */
gotSigio = 0;
}
}
CPU is free for real work. Kernel interrupts you only when data is actually ready.
read() and checks for EAGAIN. In signal-driven I/O, the kernel sends SIGIO to your process when data is ready. Your program does not poll at all — it gets notified only when there is something to read. This makes signal-driven I/O more CPU-efficient than polling.sigaction().2. Set the fd owner using
fcntl(fd, F_SETOWN, getpid()).3. Enable
O_ASYNC (and usually O_NONBLOCK) on the fd using fcntl(fd, F_SETFL, ...).gotSigio flag declared as volatile sig_atomic_t?sig_atomic_t guarantees the flag can be read and written atomically — the compiler and hardware cannot split the operation. volatile tells the compiler not to cache the value in a register; the main loop must always re-read it from memory because it could change at any time (when the signal handler fires).O_ASYNC causes the kernel to send SIGIO when I/O is possible. O_NONBLOCK makes read() return immediately with EAGAIN when no more data is available. Without O_NONBLOCK, draining all available data inside the signal handler would block on the last read() call waiting for more input that may never come.SA_RESTART tells the kernel to automatically restart slow system calls (like read(), write(), select()) that were interrupted by a signal delivery. Without it, those calls return -1 with errno set to EINTR, and your code must manually check for EINTR and retry — adding boilerplate everywhere.gotSigio = 0 after reading data?Learn about F_SETOWN, F_GETOWN, process groups, and the glibc limitation with small group IDs.
