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.
Key Terms in This Tutorial
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.
Program flow summary:
- 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.
Why use 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.
Why do only minimal work inside the signal handler?Signal handlers interrupt the main program at unpredictable points. Inside a signal handler, you can only safely call async-signal-safe functions (a restricted subset of libc). Functions like 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.
Why is O_NONBLOCK mandatory with signal-driven I/O?Because SIGIO is edge-triggered, you must drain the fd in a loop. If the fd is blocking, your drain loop might call 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.
Can SIGIO be sent to a whole process group?Yes. If you pass a negative value to 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.Q1. What is signal-driven I/O and how is it different from I/O multiplexing?In I/O multiplexing, the process calls select() or poll() and blocks until a file descriptor becomes ready. In signal-driven I/O, the process registers its interest once (via O_ASYNC and F_SETOWN), then continues doing other work. The kernel delivers a SIGIO signal to the process when I/O becomes possible. The process never blocks waiting — it reacts to signals instead of actively polling.
Q2. List the steps required to enable signal-driven I/O on a file descriptor.Step 1: Install a handler for SIGIO using sigaction(). Step 2: Set the fd owner using fcntl(fd, F_SETOWN, getpid()). Step 3: Enable nonblocking mode with O_NONBLOCK. Step 4: Enable signal-driven I/O with O_ASYNC using fcntl() F_SETFL. Step 5: Do other work. Step 6: When SIGIO fires, drain the fd in a loop until EAGAIN.
Q3. Why must you use O_NONBLOCK together with O_ASYNC for signal-driven I/O?Because signal-driven I/O is edge-triggered — the signal fires only once when the fd transitions to ready. You must drain all available data in a loop after the signal. If the fd is blocking, the drain loop will block on the last read() call when there is no more data, causing the program to hang. With O_NONBLOCK, the last read() returns EAGAIN immediately, signalling that the drain is complete.
Q4. Why must only minimal work be done inside the SIGIO handler?Signal handlers can interrupt the main program at any point, including inside non-reentrant functions like malloc() or printf(). Only async-signal-safe functions are safe to call inside a signal handler. The standard safe practice is to set a volatile sig_atomic_t flag inside the handler and do all actual work in the main loop after checking the flag. This avoids reentrancy issues and keeps the handler predictable.
Q5. What does F_SETOWN do and what happens if you skip it?F_SETOWN sets the process or process group that will receive the SIGIO signal when I/O becomes possible on the fd. If you skip it, the kernel does not know where to send the signal and no SIGIO will be delivered even after you set O_ASYNC. The step is mandatory — the signal has nowhere to go without an owner.
Q6. What is the difference between O_ASYNC and POSIX AIO?O_ASYNC enables signal-driven I/O — the kernel notifies the process when a fd is ready for I/O, but the process still performs the actual read/write itself. POSIX AIO (aio_read, aio_write) is true asynchronous I/O — the process requests an I/O operation and the kernel carries out the entire read or write in the background, notifying the process only when the operation is complete. The names are historically confusing because signal-driven I/O was once called asynchronous I/O.
Q7. What is FASYNC and how does it relate to O_ASYNC?FASYNC is an older name for the same flag that enables signal-driven I/O. Several older UNIX implementations define FASYNC instead of O_ASYNC. On Linux, glibc defines FASYNC as a synonym for O_ASYNC, so both names compile to the same bit value. If you need to write portable code for older systems, you may need to use FASYNC as a fallback.
Q8. Why is signal-driven I/O not widely used for high-performance servers even though it solves the select()/poll() scaling problem?Several practical problems make it difficult to use. When monitoring multiple file descriptors, all of them share a single SIGIO signal and you cannot tell which fd triggered it — you must check all of them. Signal handling is complex and restricted to async-signal-safe functions. Traditional SIGIO is not queued, so signals can be lost under high event rates. For these reasons, epoll is generally preferred on Linux — it offers the same O(1) event scaling without the complications of signal handling, and it tells you exactly which fd became ready.
Q9. What does volatile sig_atomic_t mean and why is it used for the gotSigio flag?sig_atomic_t is a data type guaranteed to be read and written atomically on the target platform — a write to it cannot be interrupted by a signal halfway through. This prevents a race where the handler writes a value while the main loop reads a partial state. The volatile keyword tells the compiler that this variable can change outside normal program flow (from a signal handler) and must be re-read from memory on every access, preventing the compiler from optimizing away reads in the main loop.
