Linux epoll lets one thread wait on readiness changes across many file descriptors without scanning every descriptor on each iteration. The interface is common in network servers, proxies, runtimes, and other programs that keep large sets of sockets active.
The registration mode matters. Level-triggered operation keeps reporting a descriptor while the relevant condition remains ready. Edge-triggered operation reports transitions in readiness and expects the application to consume available work until the descriptor would block.
Both modes use the same kernel readiness state, but they impose different event-loop obligations. A loop that treats an edge-triggered descriptor like a level-triggered one can leave unread bytes or unwritten capacity behind with no fresh notification to bring the descriptor back into the loop.
Readiness is not completion
An epoll event says that an operation can make progress under the reported condition. It does not mean an entire application message is available, that a write can accept an arbitrary payload, or that the peer will remain reachable after the event is delivered.
For a TCP socket marked readable, recv() may return some bytes, reach end of stream, fail, or eventually return EAGAIN on a nonblocking descriptor. A writable notification similarly means that at least some output can be accepted without blocking at that moment.
This distinction keeps event-loop logic tied to actual system-call results. The notification selects descriptors worth servicing; the subsequent read or write determines how much progress is possible.
Level-triggered mode keeps a ready condition visible
Level-triggered behavior is the default when a registration does not include EPOLLET. If a socket still has unread data after the event loop handles it, a later epoll_wait() can report the socket again because the readable condition still exists.
That repeated visibility makes partial consumption relatively forgiving. A handler can read a bounded amount, return control to the loop, and rely on the remaining readiness state to produce another event.
The same pattern applies to writable descriptors. If output capacity remains available and the descriptor stays registered for EPOLLOUT, repeated wakeups can occur. For busy servers, permanently watching writable readiness can therefore create needless loop activity because sockets are often writable.
A common design registers write interest only when an output queue contains data that could not be fully sent. Once the queue drains, the loop removes write interest until new application output appears.
Edge-triggered mode reports readiness transitions
Adding EPOLLET changes notification semantics. The loop is informed when readiness changes in a relevant direction rather than receiving repeated reports merely because the condition remains true.
Consider a nonblocking TCP socket that receives 32 KiB. An edge-triggered handler reads only 4 KiB and then stops even though more bytes remain in the socket receive queue. The descriptor can stay readable, but no new readiness transition is required to occur. The remaining bytes can sit untouched while the event loop waits for an event that may not arrive.
The robust pattern is to keep reading until recv() returns -1 with errno set to EAGAIN or EWOULDBLOCK. At that point the receive queue has been consumed as far as the nonblocking interface currently permits.
Writes follow the same principle. If a socket becomes writable, the handler can flush queued output until the queue is empty or send() reports that progress would block. Any unsent remainder stays in the application queue and write interest remains active.
Nonblocking descriptors make draining safe
Edge-triggered code is normally paired with O_NONBLOCK. Draining until a blocking operation stalls would defeat the event loop: one descriptor could hold the thread while other ready descriptors wait.
With nonblocking I/O, EAGAIN marks a useful boundary. It tells the handler that current readiness has been exhausted without putting the thread to sleep.
A simplified read path has this shape:
for (;;) {
ssize_t n = recv(fd, buf, sizeof buf, 0);
if (n > 0) {
consume(buf, (size_t)n);
continue;
}
if (n == 0) {
close_connection(fd);
break;
}
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
if (errno == EINTR)
continue;
close_connection(fd);
break;
}Production code also has to coordinate parser state, connection lifetime, backpressure, and errors, but the drain-until-EAGAIN boundary remains central to edge-triggered servicing.
Draining does not require unlimited application work
Consuming kernel readiness and performing application work are separate scheduling concerns. A single connection can deliver enough data to occupy a loop for a long period if parsing, decompression, routing, or business logic runs inline for every byte received.
An event loop can drain the socket into bounded user-space buffers, then schedule heavier processing elsewhere or enforce per-connection budgets. If memory limits prevent further intake, the program can deliberately stop reading and manage interest according to its backpressure design.
This is different from accidentally stopping while the descriptor remains ready. Deliberate suspension needs explicit state so the application can resume the connection when capacity returns.
Fairness therefore comes from event-loop policy, not from leaving an edge-triggered descriptor partially drained and assuming another notification will repair the scheduling decision.
One-shot registration adds an explicit rearm step
EPOLLONESHOT disables a registered descriptor after an event is delivered. The application must rearm it with epoll_ctl(..., EPOLL_CTL_MOD, ...) before another event can be reported.
This mode is useful when multiple worker threads share an epoll instance and a program wants one worker to own a connection while it is being serviced. The worker can process current readiness, update connection state, and rearm the descriptor after the state is consistent.
One-shot behavior is separate from edge triggering. They can be used together, but EPOLLONESHOT does not remove the need to drain edge-triggered readiness correctly. Rearming a descriptor also needs careful ordering so state changes between service and rearm are not lost.
Hangups and errors still need normal I/O handling
Event masks can include EPOLLERR, EPOLLHUP, or EPOLLRDHUP alongside ordinary readiness. These flags should not turn the handler into a shortcut that discards data already queued for the connection.
A peer can close its sending side after transmitting final bytes. The local socket may therefore have readable data and a shutdown indication at the same time. Reading until the stream reaches its terminal result preserves those final bytes.
System-call results remain authoritative. Event bits guide dispatch, while recv(), send(), and related calls expose the concrete state encountered during service.
Registration changes need connection-state discipline
Real event loops modify registrations as connection state changes. A socket may begin with read interest, add write interest when output queues build, remove it after flushing, and eventually leave the epoll set when the connection closes.
Those transitions must agree with application ownership. Closing a descriptor while another task still holds its numeric value can be dangerous because the kernel may reuse that descriptor number for an unrelated resource. Event-loop implementations commonly attach stable connection objects or generation state to registrations rather than treating the integer alone as a durable identity.
Threaded designs also need clear rules for which worker may call epoll_ctl, mutate queues, close sockets, and rearm one-shot registrations. The kernel interface provides readiness notification; it does not supply the application’s connection-state synchronization.
The mode determines the service contract
Level-triggered epoll repeatedly exposes a condition that remains ready, which supports handlers that make partial progress across several loop iterations. Edge-triggered epoll reduces repeated notifications for unchanged readiness but requires handlers to drive nonblocking I/O to its current boundary.
Neither mode makes a server inherently faster. Performance depends on workload, wakeup patterns, handler cost, batching, memory behavior, lock contention, and the surrounding architecture.
The practical distinction is the service contract. With level-triggered registration, persistent readiness remains visible. With edge-triggered registration, the handler must account for all work associated with the transition and stop at a deliberate nonblocking boundary. Event loops stay reliable when that contract is reflected directly in their read, write, backpressure, and connection-lifecycle code.