Signal-Driven I/O SIGIO · O_ASYNC

 

Signal-Driven I/O
Chapter 63 · SIGIO · O_ASYNC · F_SETOWN · Edge-Triggered Notification · Linux Programming Interface
📡 SIGIO
⚡ O_ASYNC
🔔 Edge-Triggered
💻 Full Code
❓ Interview Q&A

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

SIGIO O_ASYNC FASYNC O_NONBLOCK F_SETOWN F_SETFL F_GETFL fcntl() sigaction() sig_atomic_t edge-triggered EAGAIN EWOULDBLOCK cbreak mode POSIX AIO

1. I/O Multiplexing vs Signal-Driven I/O — The Key Difference

I/O Multiplexing (select/poll)
Process calls select()/poll()
Process blocks inside kernel waiting
Kernel returns when fd ready
Process scans results, does I/O
Repeat call (re-passes full fd list)
⚠ Process cannot do any work while blocked in select()/poll()

Signal-Driven I/O
Register SIGIO handler + set O_ASYNC once
Process runs freely, doing other work
Kernel sends SIGIO when fd becomes ready
SIGIO handler runs; sets a flag
Main loop sees flag; drains fd with read loop
✅ Process does real work while waiting — signal interrupts only when needed

2. How to Enable Signal-Driven I/O — The 6 Steps

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.

1
Establish a signal handler for SIGIO
By default, the kernel sends SIGIO when I/O is possible. You must install a handler for it using sigaction(). Without this, SIGIO’s default action (terminate the process!) will run instead.

struct sigaction sa;
sa.sa_handler = mySignalHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGIO, &sa, NULL);

2
Set the owner of the file descriptor (F_SETOWN)
You must tell the kernel which process or process group should receive the signal. Use fcntl() with F_SETOWN. Typically this is getpid() to receive the signal in the calling process.

/* Make this process the owner — signals go to us */
fcntl(fd, F_SETOWN, getpid());
⚠ A negative value for the third argument means a process group ID (e.g., -pgrp sends to all processes in the group).

3
Enable nonblocking I/O (O_NONBLOCK)
Signal-driven I/O provides edge-triggered notification (explained in Step 6). This means when SIGIO fires, you must drain the fd completely. If the fd is blocking, your read/write calls could block mid-drain. So you must make the fd nonblocking first.

int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

4
Enable signal-driven I/O (O_ASYNC)
Set the O_ASYNC flag on the fd using fcntl() F_SETFL. This is what actually arms the signal mechanism. You can combine Steps 3 and 4 into one fcntl() call.

/* Combine steps 3 and 4 in one call */
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);
📝 On older UNIX systems, O_ASYNC may not be defined. In that case, use FASYNC — glibc defines it as a synonym for O_ASYNC.

5
Do your other work — the kernel handles the rest
After Steps 1–4, your process is free to do anything. Compute, sleep, serve other fds, run a loop — whatever you need. When I/O becomes possible on the monitored fd, the kernel interrupts the process and delivers the SIGIO signal, invoking your handler automatically.

6
In the handler — drain the fd completely (edge-triggered!)
Signal-driven I/O is edge-triggered: the kernel sends one signal per I/O transition (idle → data available). It does NOT keep sending a signal for every byte that arrives. So when your handler runs, you must read/write as much as possible in a loop until you get EAGAIN or EWOULDBLOCK. If you read only a few bytes and stop, the remaining data sits unread and no new signal will arrive for it.

/* In the SIGIO handler or after detecting the flag */
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK)
            break;   /* no more data right now — done */
        else
            /* real error */
            break;
    }
    if (n == 0)
        break;   /* EOF */
    /* process n bytes in buf */
}

3. Edge-Triggered vs Level-Triggered — What It Means

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.

Edge-Triggered vs Level-Triggered Notification

Type
When is notification delivered?
Risk if you don’t drain fully?

Level-Triggered
(select, poll)
As long as data is available. Every call to select()/poll() will return “ready” until you read all the data.
Low risk — next call to select()/poll() will still report readiness.

Edge-Triggered
(SIGIO, epoll ET)
Only once at the moment state changes from “not ready” to “ready”. One signal per transition, regardless of how much data is waiting.
High risk — if you don’t read all data, no new signal arrives for the leftover data. Your program stalls waiting for a signal that never comes.

Timeline: 100 bytes arrive, you read only 40 bytes
t=0: 100 bytes arrive
SIGIO fired once
You read 40 bytes
60 bytes still in buffer
No new SIGIO comes
State didn’t change from “ready” to “not ready” to “ready” again — edge never fired again
Program hangs
60 bytes sit unread forever. Waiting for SIGIO that will never come.
✅ Fix: When SIGIO fires, always loop reading until EAGAIN — drain the entire buffer.

4. Which File Descriptor Types Support Signal-Driven I/O?
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)

5. O_ASYNC, FASYNC, and the “Asynchronous I/O” Name Confusion

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.

O_ASYNC
The standard flag name in Linux. Set with fcntl() F_SETFL to enable SIGIO notification. Was specified in POSIX.1g but later removed from SUSv3 because the spec was too vague.
FASYNC
An older name for the same flag, found in older UNIX systems and some Linux kernel headers. glibc defines FASYNC as a synonym for O_ASYNC, so both work on Linux.
POSIX AIO (different thing!)
Today “asynchronous I/O” means POSIX AIO (aio_read(), aio_write()). In POSIX AIO the kernel performs the entire I/O operation in the background and notifies you when it is complete. Signal-driven I/O only notifies you that I/O is possible — you still do the I/O yourself.

Signal-Driven I/O vs POSIX AIO — At a Glance
Aspect Signal-Driven I/O POSIX AIO
What triggers notification fd becomes ready for I/O I/O operation is complete
Who does the actual read/write Your code Kernel (background)
Notification type Signal (SIGIO) Signal or callback
Flag/API O_ASYNC + fcntl() aio_read(), aio_write()

6. Complete Example — demo_sigio.c (Listing 63-3, TLPI)

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:

  1. Install SIGIO handler (sets gotSigio = 1)
  2. Set stdin as owner: fcntl(STDIN_FILENO, F_SETOWN, getpid())
  3. Enable O_ASYNC | O_NONBLOCK on stdin
  4. Put terminal in cbreak mode (per-character input, no echo)
  5. Loop: increment cnt endlessly
  6. When gotSigio is set: read all available chars, print cnt + char
  7. 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;
}

Terminal output when running ./demo_sigio (type x several times then #)
$ ./demo_sigio
cnt=37; read x
cnt=100; read x
cnt=159; read x
cnt=223; read x
cnt=288; read x
cnt=333; read #

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.

7. Important Details to Remember

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());

8. Minimal Complete Example — Signal-Driven I/O on a Socket

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;
}

9. Limitations of Signal-Driven I/O

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:

⚠️
Signal handling is complex and error-prone
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.
⚠️
One SIGIO per process — hard to tell which fd fired
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.
⚠️
Signal queue overflow
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.
epoll is generally preferred
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.

Interview Questions & Answers
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.

Chapter 63 — Complete

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

Leave a Reply

Your email address will not be published. Required fields are marked *