Level-Triggered vs Edge-Triggered | Nonblocking I/O Alternative I/O Models

 

Alternative I/O Models – Part 1
Chapter 63 | Level-Triggered vs Edge-Triggered | Nonblocking I/O
63.1
Section
2
Models
TLPI
Source

What You Will Learn

When Linux gives you a notification that a file descriptor is “ready”, there are two completely different ways it can behave. Understanding this difference is the foundation of all advanced I/O programming — epoll, select, poll, and signal-driven I/O all depend on it. This tutorial explains both models clearly with diagrams and code.

Key Terms

Level-Triggered Edge-Triggered File Descriptor Readiness O_NONBLOCK EAGAIN EWOULDBLOCK epoll select() poll() Signal-Driven I/O

1. The Two Notification Models — Simple Explanation

Imagine you are waiting for a parcel to arrive. There are two ways a delivery notification system can work:

  • Level-triggered: The system keeps ringing your doorbell as long as the parcel is outside. You can answer it now or later — the bell will keep ringing until you collect the parcel.
  • Edge-triggered: The system rings your doorbell only once when the parcel arrives. If you miss it, there is no second ring until another parcel shows up.

This is exactly how Linux I/O readiness notifications work for file descriptors.

Diagram: Level-Triggered vs Edge-Triggered — Signal Over Time
Data arrives in buffer
No data
Data in buffer (unread)
Level-Triggered notification
Keeps notifying as long as data is present
Edge-Triggered notification
Only notifies once when data first arrives

2. Level-Triggered Notification — How It Works

Definition: A file descriptor is considered ready if it is possible to perform an I/O system call on it without blocking. This means every time you check, the kernel looks at the current state of the fd and tells you if there is data or not.

Which APIs use it: select(), poll(), and epoll (when not using EPOLLET flag)

Key advantage: You do not need to drain all data in one shot. You can read some data, go do other things, and come back later — the fd will still be reported as ready if data remains.

Typical usage pattern:

// Level-triggered pattern (select/poll/default epoll)
while (1) {
    // 1. Check which fds are ready
    select(nfds, &readfds, NULL, NULL, NULL);

    // 2. Handle ready fds — no need to read ALL data at once
    if (FD_ISSET(fd, &readfds)) {
        read(fd, buf, sizeof(buf));  // read some data
        // It's OK — next select() will report fd ready again
        // if more data is still in the buffer
    }

    // 3. Reinitialize fd sets (select modifies them!)
    FD_ZERO(&readfds);
    FD_SET(fd, &readfds);
}

3. Edge-Triggered Notification — How It Works

Definition: Notification fires only when there is new I/O activity on the fd since it was last monitored. If data was already there before monitoring started, you may never get notified.

Which APIs use it: Signal-driven I/O, epoll with EPOLLET flag

Critical rule: After you receive an edge-triggered notification, you must drain the fd completely — read all available data until you get EAGAIN or EWOULDBLOCK. Otherwise you will never know there is leftover data until new data arrives.

Two mandatory rules for edge-triggered programs:

  1. After notification, read/write as much as possible — loop until EAGAIN
  2. Always set the file descriptor to nonblocking mode (O_NONBLOCK)

Why nonblocking is mandatory for edge-triggered:

If you loop reading until the buffer is empty, and the fd is in blocking mode, the last read() call will block forever because there is nothing left to read. By using nonblocking mode, the read() returns immediately with EAGAIN, telling you to stop.

Edge-triggered usage pattern (with nonblocking):

// Edge-triggered pattern — MUST drain all data
void handle_edge_triggered_read(int fd) {
    char buf[4096];
    ssize_t n;

    // Keep reading until EAGAIN or EWOULDBLOCK
    while (1) {
        n = read(fd, buf, sizeof(buf));
        if (n == -1) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // No more data — stop looping
                break;
            } else {
                perror("read error");
                break;
            }
        }
        if (n == 0) {
            // EOF — connection closed
            break;
        }
        // Process n bytes of data
        process_data(buf, n);
    }
}

Diagram: Edge-Triggered Starvation Risk
FD A gets notification
10MB of data arrives
Drain FD A completely
This takes a long time…
FD B, C, D starved!
They wait while A is drained
Solution: Read a reasonable chunk from each fd per iteration, not unlimited

4. Side-by-Side Comparison
Feature Level-Triggered Edge-Triggered
When notified? Whenever fd is ready (state) Only on new I/O activity (event)
APIs select(), poll(), epoll (default) Signal-driven I/O, epoll+EPOLLET
Drain required? No — partial reads are fine Yes — must drain until EAGAIN
O_NONBLOCK needed? Often recommended Mandatory
Risk of missing events? No — fd keeps reporting ready Yes — if you don’t drain fully
Complexity Simpler More complex — careful design needed
Best for General use, simpler servers High-performance servers (like nginx)

5. Nonblocking I/O with Alternative I/O Models (Section 63.1.2)

The O_NONBLOCK flag makes a file descriptor nonblocking. When there is no data, instead of waiting (blocking), the system call returns immediately with errno = EAGAIN. This is heavily used with all alternative I/O models.

Four reasons to use nonblocking I/O:

Reason 1
Required by edge-triggered models — must loop until EAGAIN to fully drain the fd
Reason 2
Shared fds — multiple threads/processes can change fd state between notification and your read call
Reason 3
Even select()/poll() can’t guarantee write won’t block for large data blocks on stream sockets
Reason 4
Spurious readiness — select()/poll() can occasionally falsely report an fd as ready

How to set a file descriptor to nonblocking mode:

#include <fcntl.h>

int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl F_GETFL");
        return -1;
    }
    flags |= O_NONBLOCK;
    if (fcntl(fd, F_SETFL, flags) == -1) {
        perror("fcntl F_SETFL");
        return -1;
    }
    return 0;
}

Spurious readiness — real-world example:

A client connects to a server and immediately sends a TCP RST (reset). The server calls select() between these two events. select() says the listening socket is readable. But when the server calls accept(), the connection is gone — accept() blocks or returns an error. With nonblocking mode, accept() returns EAGAIN instead of blocking forever.

// Handling spurious readiness with nonblocking accept
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen ...
set_nonblocking(listen_fd);

// In event loop:
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd == -1) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        // Spurious readiness — no real connection, just ignore
        continue;
    }
    perror("accept");
}

6. Complete Working Example — Nonblocking Read Loop

This shows a proper nonblocking read loop that can be used in both level-triggered and edge-triggered contexts:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

/* Set fd to nonblocking mode */
int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

/* Read all available data from fd (for edge-triggered use)
   Returns: total bytes read, 0 on EOF, -1 on error */
ssize_t drain_fd(int fd) {
    char buf[4096];
    ssize_t total = 0;
    ssize_t n;

    while (1) {
        n = read(fd, buf, sizeof(buf));
        if (n > 0) {
            total += n;
            /* process buf[0..n-1] here */
            printf("Read %zd bytes: %.*s\n", n, (int)n, buf);
        } else if (n == 0) {
            /* EOF */
            printf("Connection closed\n");
            return total;
        } else {
            /* n == -1 */
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                /* No more data — done draining */
                break;
            }
            perror("read");
            return -1;
        }
    }
    return total;
}

int main(void) {
    /* Use stdin as example fd */
    int fd = STDIN_FILENO;

    if (set_nonblocking(fd) == -1) {
        perror("set_nonblocking");
        exit(EXIT_FAILURE);
    }

    printf("fd is now nonblocking. Reading...\n");
    ssize_t bytes = drain_fd(fd);
    printf("Total bytes read: %zd\n", bytes);

    return 0;
}

Interview Questions & Answers
Q1: What is the difference between level-triggered and edge-triggered notification?
Level-triggered fires whenever the fd is in a ready state — it keeps notifying you as long as data is available. Edge-triggered fires only once when I/O activity happens (a state change). select() and poll() are always level-triggered. epoll can be either. Signal-driven I/O is always edge-triggered.
Q2: Why must you use O_NONBLOCK with edge-triggered I/O?
Because you must drain all data in a loop until EAGAIN. If the fd is blocking, the final read() call — when no more data exists — will block indefinitely. O_NONBLOCK makes that last read() return immediately with EAGAIN instead of blocking, so your loop knows when to stop.
Q3: Can you use blocking mode with level-triggered APIs like select()?
Technically yes, but it is still risky. Four situations can cause blocking: 1) Multiple threads sharing the same fd — another thread may read the data before yours does. 2) A large write to a stream socket can block even after select() says it’s writable. 3) Spurious readiness notifications. 4) Race conditions. Best practice is to always use O_NONBLOCK even with level-triggered APIs.
Q4: What is a spurious readiness notification?
It is when select() or poll() reports an fd as ready but a subsequent I/O call on it blocks. This can happen due to kernel bugs or race conditions. A classic example is a TCP client that connects then immediately sends RST — select() reports the listening socket readable, but accept() then blocks because the connection was torn down. This is why O_NONBLOCK is recommended even with level-triggered APIs.
Q5: What happens if an edge-triggered program does not drain the fd fully after notification?
The leftover data stays in the buffer, but you will not get another notification for it. The next notification only comes when new data arrives. This can cause a permanent “stuck” state — the program stops processing because it thinks there is no data, but old data is sitting in the buffer waiting to be read. This leads to data loss or program stalls.
Q6: What is the fd starvation problem in edge-triggered mode?
If one fd has a huge amount of data and you drain it completely before moving to other fds, those other fds get no attention while you drain the busy one. The solution is to read a limited amount from each fd per event loop iteration rather than draining completely in one go, or use worker threads to handle per-fd draining.
Q7: Which I/O APIs support edge-triggered and which support level-triggered?
select() and poll() — level-triggered only. Signal-driven I/O — edge-triggered only. epoll — supports both. By default epoll is level-triggered. Adding the EPOLLET flag switches it to edge-triggered. This flexibility is one reason epoll is preferred for high-performance servers.

Next: I/O Multiplexing with select()

Now that you understand notification models, see how select() implements level-triggered multiplexing

Part 2: select() & poll() → EmbeddedPathashala Home

Leave a Reply

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