Why This Matters
If you are using epoll, this is the concept you absolutely must understand. Getting it wrong causes subtle bugs where data silently sits unread in a buffer forever — and your server appears to hang for certain clients.
The terms come from electronics: level-triggered and edge-triggered describe when a circuit responds to a signal — based on the signal’s current level, or based on the transition (edge) in the signal.
The Core Difference — Simple Analogy
Visualizing the Difference — Timeline
Scenario: 100 bytes arrive. You read 50 bytes. 50 bytes remain unread.
| Event | Level-Triggered (LT) | Edge-Triggered (ET) |
|---|---|---|
| 100 bytes arrive on socket | ✓ Notified | ✓ Notified |
| You read 50 bytes (50 left) | — | — |
| Next epoll_wait() call | ✓ Notified again (50 bytes still there) | ✗ NOT notified (no new data arrived) |
| New 10 bytes arrive | ✓ Notified (60 bytes now) | ✓ Notified (triggered by new arrival) |
Level-Triggered epoll — The Safe Default
/* Level-triggered is the DEFAULT in epoll — just don't add EPOLLET */
struct epoll_event ev;
ev.events = EPOLLIN; /* No EPOLLET — this is level-triggered */
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
/* In event loop — with LT you can read partial data safely */
if (events[i].events & EPOLLIN) {
char buf[512];
ssize_t n = read(fd, buf, sizeof(buf)); /* Read what we can */
if (n > 0) {
process(buf, n);
/* If 2000 bytes were available but we only read 512,
next epoll_wait() will notify us again — safe */
}
}
Edge-Triggered epoll — The Fast But Tricky Mode
/* Edge-triggered: add EPOLLET flag */
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; /* Edge-triggered */
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
/* RULE: With ET, you MUST read until EAGAIN every single time */
if (events[i].events & EPOLLIN) {
char buf[512];
ssize_t n;
/* Keep reading until no more data (EAGAIN/EWOULDBLOCK) */
while (1) {
n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
/* All data consumed — stop reading */
break;
}
/* Real error */
perror("read");
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
break;
}
if (n == 0) {
/* EOF — client disconnected */
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
break;
}
/* Process data */
process(buf, n);
/* Loop continues to drain ALL available data */
}
}
Setting Nonblocking — Required for Edge-Triggered
#include <fcntl.h>
void set_nonblocking(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL");
return;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL");
}
}
/* Usage in accept() loop: */
int conn_fd = accept(listen_fd, NULL, NULL);
set_nonblocking(conn_fd); /* Must do this BEFORE */
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev); /* Adding to epoll */
The Starvation Problem with Edge-Triggered
Imagine fd A has 1MB of data and fd B has 100 bytes. With edge-triggered and a loop “read until EAGAIN”, you will spend a long time draining fd A’s 1MB before you even look at fd B. fd B’s client is waiting.
This is the starvation problem. One solution is to maintain a list of fds that have been notified but not fully drained, and round-robin through them.
When to Use Level-Triggered vs Edge-Triggered
| Situation | Use | Reason |
|---|---|---|
| Simple server, easier to reason about | Level-Triggered | Forgiving — partial reads are safe |
| Maximum performance, experienced developer | Edge-Triggered | Fewer kernel-user transitions |
| Multi-threaded server | ET + EPOLLONESHOT | Prevents concurrent event delivery per fd |
| Porting from select()/poll() | Level-Triggered | Behavior matches select()/poll() semantics |
