Level-Triggered vs Edge-Triggered Notification

 

Level-Triggered vs Edge-Triggered Notification
Chapter 63 · epoll Notification Modes · EmbeddedPathashala
LT
Level-Triggered (default)
ET
Edge-Triggered (EPOLLET)
1 flag
EPOLLET to switch modes

epoll supports two completely different ways of telling your application that I/O is available: level-triggered (LT) and edge-triggered (ET). This is one of the most important distinctions in Linux I/O programming. Choosing the wrong mode — or not fully understanding how ET works — leads to bugs where your server silently stops reading data and hangs forever.

This tutorial explains both modes clearly, step by step, with real code showing exactly what happens in each mode.

Key Concepts
Level-Triggered Edge-Triggered EPOLLET EPOLLIN epoll_wait() Signal-Driven I/O EAGAIN Nonblocking I/O EWOULDBLOCK

Level-Triggered Notification (Default)

Level-triggered is the default mode for epoll. It is also how poll() and select() work. The rule is simple:

Level-Triggered Rule: epoll_wait() reports a file descriptor as ready as long as data is available to read (or space is available to write). Every call to epoll_wait() will keep telling you the fd is ready until you drain the data.

Think of it like a water sensor. As long as there is water in the tank, the sensor stays ON. It doesn’t matter when the water arrived — if water is still there, the sensor triggers.

Level-Triggered Timeline

Event ① Data arrives on socket ② epoll_wait() #1 called ③ epoll_wait() #2 called (no new data)
LT result Data in buffer Returns fd as READY ✓ Returns fd as READY again ✓
As long as data sits unread in the buffer, every epoll_wait() keeps returning that fd as ready.

Benefit: You can use blocking file descriptors and read partial data. The kernel keeps reminding you until you finish reading. This is safe and easier to program.

Edge-Triggered Notification (EPOLLET)

Edge-triggered mode works very differently. The rule here is:

Edge-Triggered Rule: epoll_wait() reports a file descriptor as ready only when a new I/O event occurs since the previous epoll_wait(). If data arrived and you did not read it all, epoll_wait() will NOT notify you again — unless more data arrives.

Think of it like a doorbell. The bell rings when someone presses the button. If you ignore it and no one presses again, the bell won’t ring again on its own — even if the person is still standing at the door.

Edge-Triggered Timeline
Event ① Data arrives on socket ② epoll_wait() #1 called ③ epoll_wait() #2 called (no new data)
ET result New I/O event recorded Returns fd as READY ✓ BLOCKS — no new data ✗
If you read only part of the data on the first ready notification, the rest is silently stuck in the buffer with no more notifications.

This means with edge-triggered mode, when you get a ready notification, you must read all available data immediately — keep reading until the system call returns EAGAIN or EWOULDBLOCK. That’s the signal that the buffer is empty.

This is why edge-triggered epoll requires nonblocking file descriptors. If you use a blocking fd and try to read past the available data, your process would block forever.

LT vs ET — Side by Side

Level-Triggered (LT) — Default
✓ Simpler to program
✓ Works with blocking fds
✓ Partial reads are safe — kernel reminds you
✓ Same semantics as poll()/select()
Best for: most server applications, general use

Edge-Triggered (ET) — EPOLLET
✓ Slightly more efficient (fewer epoll_wait returns)
✓ Similar to signal-driven I/O semantics
⚠ Requires nonblocking file descriptors
⚠ Must drain all data on each notification
Best for: high-performance servers (nginx, etc.)

How to Enable Edge-Triggered Mode

To switch a specific fd to edge-triggered mode, just add the EPOLLET flag when calling epoll_ctl(). You can mix LT and ET file descriptors in the same epoll instance.

#include <sys/epoll.h>

struct epoll_event ev;

/* Register fd in EDGE-TRIGGERED mode */
ev.data.fd = fd;
ev.events  = EPOLLIN | EPOLLET;   /* <-- EPOLLET makes it edge-triggered */

if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1) {
    perror("epoll_ctl");
    exit(1);
}

/* For LEVEL-TRIGGERED (default), simply omit EPOLLET: */
ev.events = EPOLLIN;              /* level-triggered */
Important: The EPOLLET flag is per file descriptor, not global. You can have some fds in LT mode and others in ET mode in the same epoll instance. The listening socket is often kept in LT mode while client sockets use ET mode.

Step-by-Step Scenario: The Critical Difference

Let’s trace exactly what happens in both modes with a concrete scenario: 100 bytes arrive on a socket, but you only read 50 bytes on the first notification.

Scenario: 100 bytes arrive, you read only 50
Step 1: 100 bytes arrive on socket
→ Buffer now has 100 bytes
Step 2: epoll_wait() #1
Both LT and ET: fd returned as READY
Step 3: You read 50 bytes
→ Buffer still has 50 bytes remaining
Step 4: epoll_wait() #2
LT: fd returned as READY again ✓
50 bytes still in buffer — level triggered
ET: BLOCKS — no new I/O event ✗
50 bytes stuck — no notification until new data arrives

This is the silent bug in edge-triggered mode. If you partially read data and go back to epoll_wait(), your server will appear to work (epoll_wait returns) but the 50 remaining bytes are never processed — until the client sends more data, which triggers a new edge. This can cause subtle protocol bugs.

ET epoll vs Signal-Driven I/O

The behavior of edge-triggered epoll is described as “semantically similar to signal-driven I/O.” Both tell you about I/O events, not I/O state. But there is one important difference:

ET epoll behavior

If multiple I/O events happen before your next epoll_wait(), epoll coalesces them into a single notification. You get one wakeup and then drain everything.

Signal-driven I/O behavior

If multiple I/O events happen, multiple signals may be generated — one per event. Your signal queue can overflow (signals are lost) under heavy load.

This makes ET epoll more reliable than signal-driven I/O for high-throughput scenarios, since coalescing prevents notification overflow.

Code Example — LT vs ET Read Loop

The key difference in application code is the read loop. With LT you can read once per notification; with ET you must loop until EAGAIN.

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/epoll.h>

/* Make a file descriptor nonblocking — required for ET mode */
void set_nonblocking(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

/* ===== LEVEL-TRIGGERED read handler ===== */
/* Safe to read just some data; epoll_wait() will notify again if more remains */
void lt_read_handler(int fd)
{
    char buf[512];
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        /* Process buf[0..n-1] */
        printf("[LT] Read %zd bytes\n", n);
        /* If there's more data, epoll_wait() will fire again automatically */
    } else if (n == 0) {
        printf("[LT] Connection closed by peer\n");
    } else {
        perror("[LT] read error");
    }
}

/* ===== EDGE-TRIGGERED read handler ===== */
/* MUST drain all data until EAGAIN, otherwise data is stuck silently */
void et_read_handler(int fd)
{
    char buf[512];
    ssize_t n;

    /* Keep reading until the kernel says "buffer empty" (EAGAIN/EWOULDBLOCK) */
    while (1) {
        n = read(fd, buf, sizeof(buf));
        if (n > 0) {
            /* Process buf[0..n-1] */
            printf("[ET] Read %zd bytes\n", n);
            /* Continue loop — there may be more data */
        } else if (n == 0) {
            printf("[ET] Connection closed by peer\n");
            break;
        } else {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                /* Buffer is fully drained — safe to go back to epoll_wait() */
                printf("[ET] Buffer drained (EAGAIN), returning to epoll_wait\n");
                break;
            } else {
                perror("[ET] read error");
                break;
            }
        }
    }
}

int main(void)
{
    int epfd = epoll_create1(0);
    struct epoll_event ev, events[10];

    int fd = STDIN_FILENO;  /* Use stdin for this demo */

    /* For ET: fd MUST be nonblocking */
    set_nonblocking(fd);

    /* Register with EPOLLET (edge-triggered) */
    ev.data.fd = fd;
    ev.events  = EPOLLIN | EPOLLET;
    epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

    printf("Waiting for input (edge-triggered mode)...\n");

    int nfds = epoll_wait(epfd, events, 10, -1);
    for (int i = 0; i < nfds; i++) {
        if (events[i].events & EPOLLIN) {
            et_read_handler(events[i].data.fd);
        }
    }

    close(epfd);
    return 0;
}
Rule of thumb: In ET mode, always wrap your read/write calls in a while(1) loop and break only when you get EAGAIN or EWOULDBLOCK. Any other exit from the loop (like reading a fixed number of bytes) risks leaving data in the buffer with no notification.

Interview Questions
Q1: What is the difference between level-triggered and edge-triggered notification in epoll?

Answer: In level-triggered (LT) mode, epoll_wait() returns a file descriptor as ready whenever data is available in its buffer — it keeps notifying you every call until you drain the data. In edge-triggered (ET) mode, epoll_wait() notifies you only when a new I/O event occurs (like new data arriving). If you don’t drain all the data during that notification, no further notification is sent until more data arrives. ET mode requires nonblocking I/O and draining the buffer until EAGAIN.

Q2: Why does edge-triggered epoll require nonblocking file descriptors?

Answer: In ET mode, you must read until the buffer is empty (EAGAIN). If the file descriptor is blocking and you try to read more data than what’s in the buffer, the read() call will block indefinitely — hanging your server. With nonblocking mode, read() immediately returns EAGAIN when the buffer is empty, letting you know it’s safe to stop and go back to epoll_wait().

Q3: Which epoll flag enables edge-triggered mode and how do you set it?

Answer: The EPOLLET flag enables edge-triggered mode. You set it in the ev.events field when calling epoll_ctl(): ev.events = EPOLLIN | EPOLLET;. It is per file descriptor — other fds in the same epoll instance are not affected and remain level-triggered by default.

Q4: How is edge-triggered epoll similar to signal-driven I/O, and how is it different?

Answer: Both notify about I/O events (not I/O state), meaning you are told when something changes, not whether data is present. The key difference is notification coalescing: if multiple I/O events happen before your application responds, epoll gives a single notification for all of them (coalesced). Signal-driven I/O can generate multiple signals, one per event, and under heavy load the signal queue can overflow causing notifications to be lost. ET epoll is therefore more reliable.

Q5: Is it possible to use both LT and ET descriptors in the same epoll instance?

Answer: Yes. The EPOLLET flag is set per file descriptor at registration time via epoll_ctl(). You can have some file descriptors registered with EPOLLET (ET mode) and others without it (LT mode, the default) in the same epoll instance. A common pattern is to keep the listening server socket in LT mode and register accepted client sockets in ET mode.

Q6: What happens if you use edge-triggered epoll but forget to read until EAGAIN?

Answer: The unread data stays in the kernel receive buffer, but epoll_wait() will NOT notify you again unless new data arrives. This leads to a subtle bug: if the client sends data and then waits for a response, your server will never process the remaining data and will never send the response. Both sides will block indefinitely. This is sometimes called a “data starvation” bug and is one of the most common mistakes when first using EPOLLET.

Next: Edge-Triggered Programming Framework & FD Starvation

Learn the correct programming pattern for ET mode, how to prevent fd starvation, and how to handle the case of a slow fd with huge amounts of data.

Next Tutorial → ← Previous

Leave a Reply

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