Linux epoll Interest and Ready Lists
epoll is Linux’s mechanism for waiting on many file descriptors—kernel handles for sockets, pipes, files, and similar I/O objects—without checking each one continuously. It keeps a set of descriptors you are interested in and reports the ones that can make progress without blocking.
That basic idea has two important parts: an interest list, which records what to watch, and a ready list, which records what currently appears ready. Understanding those lists explains why level-triggered and edge-triggered epoll loops behave differently.
The problem epoll solves
A straightforward event-driven server might repeatedly ask every connection whether it can be read or written. With many mostly idle connections, that means spending time inspecting thousands of descriptors just to discover that almost none changed.
Older interfaces such as select and poll improved on blocking one connection at a time, but the application still supplied a large set of descriptors on every wait. The kernel had to inspect that set repeatedly, and the application had to scan the returned results.
With epoll, the application registers descriptors once, then waits for the kernel to report descriptors whose state changed or remains usable. The application can therefore focus work on the small set that is relevant now.
Two lists inside an epoll instance
Calling epoll_create1 creates an epoll instance, represented to user space by another file descriptor. Calls to epoll_ctl add, modify, or remove monitored descriptors.
Conceptually, registration puts an entry in the epoll instance’s interest list. Each entry contains the target descriptor and an event mask, such as EPOLLIN for readable input or EPOLLOUT for writable output. It can also contain a user value, commonly a pointer or connection identifier, returned with the event.
The interest list answers:
Which descriptors should this epoll instance monitor, and for which conditions?
The kernel also maintains a ready list. An entry reaches this list when the associated object reports a condition matching the registered mask. For a socket, that might mean received bytes, a completed connection waiting to be accepted, a closed peer, or an error. “Readable” means a read operation will not block; it does not necessarily mean that useful application data remains.
The ready list answers:
Which of the monitored descriptors can be acted on now?
When the application calls epoll_wait, the kernel returns events from the ready list, up to the requested maximum. The application then performs operations such as accept, read, or write on those descriptors.
Internally, Linux arranges for monitored objects to notify epoll when their state changes. The implementation uses kernel data structures for both registered entries and pending ready entries; the exact structures are an implementation detail. The important model is that registration and readiness are separate: being registered does not mean being ready, and a descriptor can remain registered after its current readiness has been handled.
Level-triggered epoll: readiness remains visible
Level-triggered mode is the default. It treats readiness like a level in a signal:
As long as the condition remains true, epoll may report it again.
Suppose a nonblocking socket has 8 KB available. An epoll_wait call reports it, and the program reads only 1 KB. The socket is still readable, so a later epoll_wait can report it again. The program is allowed to process a bounded amount of work per notification and return to its event loop.
A typical level-triggered loop can read once and defer the rest:
struct epoll_event events[64];
int n = epoll_wait(epoll_fd, events, 64, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
char buf[4096];
ssize_t count = read(events[i].data.fd, buf, sizeof buf);
if (count > 0) {
handle_bytes(buf, count);
}
}
}
This is simpler, but it can repeatedly wake the loop for a descriptor that the program is not draining. A slow connection with a continuously readable socket can consume a large share of event-loop attention.
Edge-triggered epoll: react to transitions
Registering a descriptor with EPOLLET selects edge-triggered behavior. An edge is a transition in readiness, such as a socket changing from “no data available” to “data available.” epoll reports the transition rather than repeatedly reporting the continuing condition.
This changes the application’s obligation. When an edge-triggered event arrives, the program must generally keep operating on the descriptor until the operation would block. For a nonblocking socket, that means reading repeatedly until read returns -1 with errno equal to EAGAIN or EWOULDBLOCK.
for (;;) {
char buf[4096];
ssize_t count = read(fd, buf, sizeof buf);
if (count > 0) {
handle_bytes(buf, count);
continue;
}
if (count == 0) {
close_connection(fd); // peer performed an orderly shutdown
break;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // drained for now
}
handle_error(errno);
break;
}
If the program reads only part of the available data and then waits again, no new edge may occur: the socket never stopped being readable. The remaining data can sit unnoticed, making the connection appear hung. This is the classic edge-triggered bug.
The same rule applies to accepting connections and writing. An edge-triggered server should accept until accept would block, and should write queued output until the send buffer is full or all output is gone. Descriptors used this way must normally be set to nonblocking mode; otherwise draining until “would block” can stall the entire event loop.
Choosing the contract
Level-triggered mode is usually the safer default. It tolerates partial work and makes missed progress less likely, at the cost of potentially repeated notifications. Edge-triggered mode can reduce redundant wakeups, but it requires disciplined drain loops and careful output buffering.
Both modes still require handling flags such as EPOLLERR and EPOLLHUP; those conditions can matter even when the event mask did not explicitly request them. If a connection uses EPOLLONESHOT, epoll disables further notifications for that registration after reporting an event, and the program must explicitly rearm it with epoll_ctl.
When an incident report says a Linux service is using “epoll ET,” it is therefore describing more than a performance setting. It says the event loop has chosen a notification contract: notifications describe changes, and the program is responsible for consuming the currently available work before waiting again.