ML
Concurrency

Epoll Starvation: Why Edge-Triggered I/O Left Sockets Hanging Under High Load

When building high-performance network servers with Linux epoll, Edge-Triggered (EPOLLET) mode promises fewer syscalls than Level-Triggered mode. But without careful loop management, a single greedy socket streaming large payloads can starve all other file descriptors in the event loop.

September 03, 20268 min readConcurrencyLinuxNetworkingSystem Design

The Linux epoll API is the foundation of high-concurrency event loops powering Nginx, Node.js (libuv), Envoy, Netty, and Redis. When developers optimize socket notification performance, the discussion inevitably reaches Level-Triggered (LT) versus Edge-Triggered (ET) notification modes.

Edge-Triggered mode (enabled via the EPOLLET flag) is often praised as the faster option because it reduces redundant wakeups. However, in production network servers handling uneven payload distributions, a naive Edge-Triggered event loop can easily cause severe socket starvation, leading to tail latency spikes and timeouts on idle connections.

Level-Triggered vs Edge-Triggered Semantics

The distinction between the two modes lies in how the kernel signals readiness:

  • Level-Triggered (Default): epoll_wait() returns an event as long as the underlying file descriptor buffer is ready (e.g. unread bytes remain in the socket receive buffer). If your application reads only half the buffer, the next call to epoll_wait() immediately wakes up again.
  • Edge-Triggered (EPOLLET): epoll_wait() delivers a notification only when a state transition occurs (e.g. from no data available to new data arriving). If you do not consume all available bytes, epoll will not notify you again until fresh data arrives from the network.

The Edge-Triggered Contract

Because Edge-Triggered mode notifies only on state transitions, application code is required to read in a loop until receiving EAGAIN or EWOULDBLOCK:

// Standard Edge-Triggered read loop
while (true) {
    ssize_t count = read(fd, buffer, sizeof(buffer));
    if (count == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // Read buffer is completely drained. Safe to return to epoll_wait.
            break;
        }
        // Handle actual read error
        close(fd);
        break;
    } else if (count == 0) {
        // Peer cleanly closed connection
        close(fd);
        break;
    }
    process_data(buffer, count);
}

The Starvation Vulnerability

The requirement to loop until EAGAIN creates a fundamental fairness problem when a single client sends a continuous, high-bandwidth stream (such as a multi-megabyte file upload or an aggressive websocket burst).

If client A streams data faster than the application can process it, read() never returns EAGAIN. The worker thread remains trapped inside the while (true) loop processing bytes for socket A:

Client A: [==== Streaming 50MB Payload Continuously ====] -> Traps Worker in read() loop
Client B: [Pending SYN/ACK Handshake]                     -> Starving in epoll ready list
Client C: [Small 64-byte JSON query]                       -> Starving in epoll ready list

Meanwhile, hundreds of other connections sitting in the epoll ready list (including connections with small, urgent requests) wait without receiving any CPU cycles. Under moderate concurrency, p99 request latency degrades rapidly, and clients eventually abort with connection reset or read timeout errors.

Engineering the Solution

1. Bounded Batch Reads with Ring Buffers

To prevent any single socket from monopolizing the event loop, never loop until EAGAIN unconditionally. Instead, enforce a maximum read quota (e.g. read up to 64KB or 16 iterations per connection event):

const int MAX_CHUNKS_PER_CYCLE = 16;
int chunks_read = 0;

while (chunks_read < MAX_CHUNKS_PER_CYCLE) {
    ssize_t count = read(fd, buffer, sizeof(buffer));
    if (count == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            break; // Completely drained
        }
        close(fd);
        return;
    }
    process_data(buffer, count);
    chunks_read++;
}

// If we exited due to quota rather than EAGAIN, data remains.
// Re-queue or manually schedule the socket for the next event loop iteration.
if (chunks_read == MAX_CHUNKS_PER_CYCLE) {
    schedule_continuation(fd);
}

2. Multi-Threaded Dispatch with EPOLLONESHOT

In multi-threaded server architectures (such as thread pools or worker pools), multiple threads may call epoll_wait() on the same epoll descriptor. If an Edge-Triggered event fires on socket A, and socket A takes time to process, another thread might receive a new event for the same socket, creating a dangerous concurrent read race.

Adding the EPOLLONESHOT flag ensures that after an event is delivered to one worker thread, epoll disables notifications for that file descriptor until the application explicitly re-arms it using epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &event):

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

// Inside worker after processing bounded chunk:
epoll_ctl(epfd, EPOLL_CTL_MOD, client_fd, &ev); // Re-arm socket

3. Why High-Performance Servers Often Prefer Level-Triggered

It is instructive that both Nginx (on Linux) and libuv (the networking engine behind Node.js) default to Level-Triggered mode for general HTTP and TCP socket handling. Level-Triggered mode naturally supports fair single-read passes across all active sockets per event iteration without needing artificial continuation queues or risking silent deadlocks if an Edge-Triggered event is partially drained.

Summary

Edge-Triggered epoll reduces kernel-to-user notification overhead, but it shifts the burden of fairness and starvation management entirely to the application layer. If you use EPOLLET, bound your read loops, monitor per-connection quotas, and use EPOLLONESHOT when sharing descriptors across threads.

SharePostLinkedIn

Reader Discussion

7 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Bảo Trần🇻🇳 Cần Thơ· Software EngineerStory

    Bọn em từng deadlock cổ điển 2-row trong ledger. Ordering by account_id ASC trước khi lock — 1 dòng commit, drop deadlock retries 98% trong tuần. Nhớ mãi vì PR đó merge lúc mình về quê ăn Tết.

    Sep 06, 2026·3 days later
  2. Sofia Marquez· Backend LeadAgrees

    immutability as default is the single most under-rated concurrency advice. every nightmare I've debugged in 8 years comes back to someone mutating shared state "just this once." make wrong things hard to express.

    Sep 07, 2026·4 days later
  3. Hiếu Nguyễn· Full StackPushback

    tiny precision nit — volatile in Java provides visibility AND atomicity for single 32-bit reads/writes (long/double on legacy 32-bit JVMs is the exception). worth being precise because juniors read "visibility primitive" and reach for AtomicInteger when volatile is enough.

    Sep 10, 2026·1 week later·edited
  4. Maya Iyer· PlatformFrom experience

    Go's race detector is criminally under-used. Caught a bug in our scheduler we'd been running past for 6 months — turned out our "thread-safe" map was thread-safe in the way a chair is bulletproof. -race in CI, no exceptions.

    Sep 08, 2026·5 days later
  5. Tomáš Havel· Senior EngineerAgrees

    go channels solve a problem you don't have until you have it, and then they're the only thing that solves it. people reaching for sync.Mutex everywhere are usually one refactor away from a clean channel topology.

    Sep 09, 2026·6 days later
  6. Rachel Gold· Staff SREAgrees

    the on-call framing throughout this piece is what makes it land. too many infra articles assume you never get paged. those are written by people who never got paged.

    Sep 06, 2026·3 days later
  7. Omar Khalil· Senior SWEKind words

    this is the third article from this blog I've sent to my team this month. you're cooking. don't switch to crypto.

    Sep 08, 2026·5 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email