Linux epoll Edge Triggering Reports Readiness Transitions, Not Work Units
With EPOLLET, an epoll interest does not behave like a queue containing one event for each byte, packet, connection, or application message. It reports changes in readiness state. Once a file descriptor is ready, additional work can accumulate without producing another edge that an application may rely on. The handler therefore has to consume available work until the nonblocking operation reports that progress would block.
That boundary separates kernel readiness from application framing. epoll_wait() can indicate that an operation is possible without blocking, but it does not state the amount of work present or map notifications to protocol messages.
Level and edge triggering expose different observation contracts
The default epoll behavior is level-triggered. If a watched descriptor remains ready, later epoll_wait() calls can report it again. A handler may consume only part of the available input and still receive another readiness report while the ready condition persists.
EPOLLET changes that observation pattern. The kernel reports readiness changes rather than repeatedly reporting a condition that remains true. Linux documentation consequently recommends nonblocking descriptors with edge-triggered operation and processing until read() or write() returns EAGAIN.
Consider a nonblocking pipe. If a writer places two kilobytes into an empty pipe, the read end changes from not ready to ready. A reader notified through EPOLLET that consumes only one kilobyte leaves unread data behind. Treating the single notification as a single work item can strand the remaining bytes because the descriptor may stay ready without a new transition that produces another notification.
The robust unit is therefore a readiness episode: notification starts processing, and EAGAIN marks the point at which the current ready state has been drained for that operation.
EAGAIN is a synchronization boundary
For a nonblocking descriptor, EAGAIN or EWOULDBLOCK indicates that the requested operation cannot make progress immediately. In an edge-triggered read loop, that result is more than routine error handling. It closes the processing interval associated with the observed readiness.
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) {
consume(buf, (size_t)n);
continue;
}
if (n == 0) {
handle_eof();
break;
}
if (errno == EINTR)
continue;
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
handle_error(errno);
break;
}This loop does not assume that one read() drains a stream. It also keeps end-of-file distinct from temporary lack of progress. The same principle applies to nonblocking accept loops: after a listening socket becomes ready, accept4() is commonly repeated until it reports EAGAIN.
The exact operation still controls the semantics. Stream sockets, datagram sockets, pipes, listening sockets, and other pollable objects have different data boundaries and error conditions. epoll supplies readiness information; it does not erase those interface-specific rules.
A readiness event does not preserve application message boundaries
A TCP socket illustrates the mismatch clearly. TCP presents a byte stream. One EPOLLIN notification can correspond to bytes from part of one application frame, several frames, or any other grouping allowed by stream delivery. Conversely, one logical frame may require multiple readiness episodes if its bytes arrive over time.
An event loop therefore needs separate state for transport readiness and protocol parsing. The read side drains bytes into application-managed state, while the parser extracts complete frames according to the protocol’s own length, delimiter, or state-machine rules.
Datagram sockets retain datagram boundaries at the socket API, but the notification count still does not represent a datagram count. Multiple datagrams may be readable during one readiness episode. The handler still needs to receive until the nonblocking boundary is reached if it depends on edge-triggered notification.
Partial writes create the symmetric output problem
Writable readiness is also a state, not a promise that an entire application buffer can be accepted. A nonblocking write() or send() can consume fewer bytes than requested. The application must retain the unsent suffix and resume from the correct offset.
With edge-triggered notification, output code commonly attempts to flush queued bytes until either the queue becomes empty or the operation reports EAGAIN. If bytes remain, interest in writable readiness must stay arranged so a later transition can resume flushing.
Keeping EPOLLOUT enabled continuously can create needless wakeups under level-triggered designs because sockets are frequently writable. Edge-triggered designs reduce repeated reports of an unchanged ready state, but they also make state bookkeeping stricter: queued-output ownership, offsets, interest-mask changes, and close paths must agree on whether more output work remains.
EPOLLONESHOT adds explicit rearming to the ownership model
EPOLLONESHOT is separate from EPOLLET. After an event is delivered for a descriptor registered with EPOLLONESHOT, that descriptor is disabled in the interest list until the application rearms it with epoll_ctl(..., EPOLL_CTL_MOD, ...).
This can support worker ownership. One worker receives readiness, drains or processes the descriptor according to the design, updates connection state, then rearms it. The kernel’s one-shot behavior prevents ordinary repeated delivery for that registration before rearming, but it does not replace synchronization for application data shared through other paths.
Combining EPOLLONESHOT with EPOLLET therefore creates two distinct obligations: drain readiness according to edge-triggered semantics, and explicitly rearm the descriptor when the application is prepared to receive another notification.
File-descriptor identity and lifetime remain separate concerns
An event loop also needs a lifetime policy. File descriptor numbers are process-local integers and can be reused after close(). Application state that associates a numeric descriptor with a connection must avoid letting stale work act on a later object that happens to receive the same number.
epoll registrations have kernel object semantics that are more specific than a plain integer lookup, but application queues, worker messages, timers, and connection tables often carry their own identifiers. Generation counters, stable connection objects, or other lifetime tokens can keep those layers from confusing descriptor reuse with connection identity.
Close and deregistration paths also need to coordinate with pending application work. Readiness notification solves neither ownership nor reclamation by itself.
Edge triggering moves repetition from the kernel interface into application state
The practical difference between level and edge triggering is not merely a performance switch. Level triggering lets an application repeatedly observe a ready condition. Edge triggering suppresses that repetition and requires the application to preserve enough state to finish the work associated with each readiness transition.
That state includes unread transport data, partial protocol frames, unsent output offsets, one-shot rearming status when used, and connection lifetime. A correct edge-triggered loop treats epoll as a readiness transition mechanism and keeps work accounting in the interfaces that actually define the work.