What Is Signal-Driven I/O?
With I/O multiplexing (select/poll), your program asks the kernel: “Is any of these fds ready right now?” — and blocks until one is. You are doing the polling yourself.
With signal-driven I/O, you flip this around. You tell the kernel: “Send me a signal whenever I/O becomes possible on this fd.” Then your process goes off and does other work freely. When the kernel delivers the signal (by default SIGIO), your signal handler runs, and you know it’s time to do I/O.
This means your process is never stuck waiting — it only reacts when something actually happens. This is a big step toward high-performance I/O handling.
To use signal-driven I/O on a file descriptor, you must perform these six steps in order. Skipping any of them will mean signals are never delivered.
This is one of the most important concepts in I/O notification. Signal-driven I/O is edge-triggered. Let’s see what that means visually.
| File Descriptor Type | Supported since |
|---|---|
| Sockets (TCP, UDP, UNIX domain) | Linux 2.4 and earlier |
| Terminals | Linux 2.4 and earlier |
| Pseudoterminals (pty) | Linux 2.4 and earlier |
| Certain device types | Linux 2.4 and earlier |
| Pipes and FIFOs | Linux 2.6+ |
| inotify file descriptors | Linux 2.6.25+ |
| Regular files on disk | Not supported (always “ready” — use AIO instead) |
The flag that enables signal-driven I/O is called O_ASYNC. Its name comes from history — this mechanism used to be called asynchronous I/O. But that name is now misleading and is no longer used for this purpose.
This program demonstrates signal-driven I/O on standard input (a terminal). It puts the terminal in cbreak mode so each keypress is available immediately (no Enter needed). Then it enters a loop incrementing a counter while waiting for input. When a key is pressed, the kernel sends SIGIO, the handler sets a flag, the main loop reads the character and prints the counter value. Typing # exits.
- Install SIGIO handler (sets
gotSigio = 1) - Set stdin as owner:
fcntl(STDIN_FILENO, F_SETOWN, getpid()) - Enable O_ASYNC | O_NONBLOCK on stdin
- Put terminal in cbreak mode (per-character input, no echo)
- Loop: increment
cntendlessly - When
gotSigiois set: read all available chars, print cnt + char - If char is
#: restore terminal and exit
/* demo_sigio.c — Signal-driven I/O on a terminal
* Based on Listing 63-3 from "The Linux Programming Interface"
* Compile: gcc -o demo_sigio demo_sigio.c tty_functions.c
*/
#include <signal.h>
#include <ctype.h>
#include <fcntl.h>
#include <termios.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
/* This flag is set by the SIGIO handler.
* volatile: prevents compiler from caching it in a register.
* sig_atomic_t: guarantees atomic read/write from signal handler.
*/
static volatile sig_atomic_t gotSigio = 0;
/* SIGIO signal handler — keep it minimal!
* Only safe to set a flag here; do real work in main loop.
*/
static void
sigioHandler(int sig)
{
gotSigio = 1;
}
int
main(int argc, char *argv[])
{
int flags, cnt;
struct termios origTermios;
char ch;
struct sigaction sa;
int done = 0;
/* STEP 1: Install handler for SIGIO */
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; /* restart interrupted system calls */
sa.sa_handler = sigioHandler;
if (sigaction(SIGIO, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
/* STEP 2: Set this process as owner of stdin's I/O signals */
if (fcntl(STDIN_FILENO, F_SETOWN, getpid()) == -1) {
perror("fcntl F_SETOWN");
exit(EXIT_FAILURE);
}
/* STEPS 3+4: Enable O_NONBLOCK and O_ASYNC 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);
}
/* Put terminal in cbreak mode: input available per character,
* no need to press Enter. ttySetCbreak() is a helper from TLPI.
* It saves original terminal settings in origTermios.
*/
/* ttySetCbreak(STDIN_FILENO, &origTermios); */
/* (Simplified: assume cbreak is set) */
/* STEP 5: Do other work while waiting for I/O */
cnt = 0;
for (;;) {
cnt++; /* simulate "real work" — increment counter */
/* STEP 6: When SIGIO fires, gotSigio becomes 1 */
if (gotSigio) {
gotSigio = 0; /* reset flag */
/* Drain all available input — edge-triggered!
* Must read until EAGAIN, or we miss remaining chars.
*/
while (1) {
ssize_t n = read(STDIN_FILENO, &ch, 1);
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; /* no more data right now */
perror("read");
break;
}
if (n == 0)
break; /* EOF */
if (isprint((unsigned char)ch))
printf("cnt=%d; read %c\n", cnt, ch);
if (ch == '#') { /* termination character */
done = 1;
break;
}
}
if (done)
break;
}
}
/* Restore terminal settings before exiting */
/* tcsetattr(STDIN_FILENO, TCSAFLUSH, &origTermios); */
return 0;
}
Notice how cnt keeps growing between keypresses — the main loop never blocks. It only pauses to handle input when SIGIO fires. The different cnt values between keystrokes show real work happening while waiting.
volatile sig_atomic_t for the flag?sig_atomic_t is a type that can be read and written atomically — meaning the read or write cannot be interrupted halfway by a signal. This prevents a race condition where the signal handler writes a partial value and the main loop reads a corrupt in-between state.
volatile tells the compiler “this variable can change outside the normal program flow (i.e., from a signal handler)”. Without it, the compiler might optimize away reads of gotSigio in the main loop, thinking it never changes.
printf(), malloc(), and many others are NOT safe inside signal handlers. Therefore the standard pattern is: set a flag in the handler, do the real work in the main loop.
read() after all data is consumed, and block indefinitely waiting for more — defeating the whole purpose. With O_NONBLOCK, the last read() in the drain loop fails with EAGAIN, cleanly signalling “no more data right now” so you can exit the loop.
F_SETOWN, the kernel interprets it as a negated process group ID and sends the signal to all processes in that group. This is useful for servers where multiple worker processes all want to be notified about I/O on a shared fd.
/* Send SIGIO to the current process group */
fcntl(fd, F_SETOWN, -getpgrp());
Here is a clean standalone example using signal-driven I/O on a UDP socket — no external helper libraries needed. You can compile and run this directly.
/*
* sigio_udp.c — Signal-driven I/O on a UDP socket
*
* Run in one terminal: ./sigio_udp
* Send data from another: echo "hello" | nc -u 127.0.0.1 9999
*
* Compile: gcc -o sigio_udp sigio_udp.c
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 9999
#define BUFSIZE 1024
static volatile sig_atomic_t gotSigio = 0;
static int sockfd = -1;
/* SIGIO handler — only set the flag */
static void sigio_handler(int sig)
{
gotSigio = 1;
}
int main(void)
{
struct sockaddr_in addr;
char buf[BUFSIZE];
struct sigaction sa;
/* ---- Create UDP socket ---- */
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == -1) { perror("socket"); exit(1); }
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
perror("bind"); exit(1);
}
/* ---- STEP 1: Install SIGIO handler ---- */
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sa.sa_handler = sigio_handler;
if (sigaction(SIGIO, &sa, NULL) == -1) { perror("sigaction"); exit(1); }
/* ---- STEP 2: Set socket owner to this process ---- */
if (fcntl(sockfd, F_SETOWN, getpid()) == -1) {
perror("fcntl F_SETOWN"); exit(1);
}
/* ---- STEPS 3+4: Set O_NONBLOCK and O_ASYNC ---- */
int flags = fcntl(sockfd, F_GETFL);
if (flags == -1) { perror("fcntl F_GETFL"); exit(1); }
if (fcntl(sockfd, F_SETFL, flags | O_ASYNC | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL"); exit(1);
}
printf("Listening on UDP port %d...\n", PORT);
printf("Doing other work between datagrams:\n");
/* ---- STEP 5: Do other work while waiting ---- */
long work_counter = 0;
while (1) {
work_counter++;
/* Simulated "other work" output every 500M iterations */
if (work_counter % 500000000L == 0)
printf("[working... iteration %ld]\n", work_counter);
/* ---- STEP 6: When SIGIO fires, drain the socket ---- */
if (gotSigio) {
gotSigio = 0;
/* Drain loop — must read until EAGAIN (edge-triggered!) */
while (1) {
ssize_t n = recv(sockfd, buf, BUFSIZE - 1, 0);
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; /* no more datagrams right now */
perror("recv");
break;
}
buf[n] = '\0';
printf("Received %zd bytes: %s", n, buf);
}
}
}
close(sockfd);
return 0;
}
Signal-driven I/O solves the scaling problem of select()/poll() but has its own limitations that make it less popular than epoll for high-performance servers:
Only async-signal-safe functions are allowed inside signal handlers. It’s easy to accidentally call an unsafe function (printf, malloc, etc.) and cause subtle bugs.
If you are monitoring multiple file descriptors, all of them share the same SIGIO signal. When it fires, you don’t know which fd triggered it — you must check all monitored fds. This limits its practical use to single-fd monitoring or requires extra bookkeeping.
If I/O events arrive faster than the process handles SIGIO, signals may be lost (traditional signals are not queued, only one pending signal per type). Real-time signals (SIGRTMIN+n) can be used to get a queue, but this adds more complexity.
For high-performance Linux servers,
epoll is the recommended solution. It’s easier to use, tells you exactly which fd is ready, has no signal-related complexity, and scales even better than signal-driven I/O for large numbers of fds.You have covered select() vs poll(), their scaling problems, and signal-driven I/O. The next topic in Chapter 63 is epoll — the modern Linux-specific solution that scales to tens of thousands of file descriptors efficiently.
← select() vs poll() ← Scaling Problems EmbeddedPathashala Home
