Skip to main content
UltraInstinct
Back to latest articles
Software Development•By ••10 min read

poll vs epoll: Why Linear Multiplexing Stalls at Scale

Compare poll vs epoll descriptor monitoring, kernel linear scanning, wait queue registrations, and event readiness delivery across high-concurrency servers.

Featured visual representing poll vs epoll: Why Linear Multiplexing Stalls at Scale

Network servers handling concurrent network traffic rely on kernel I/O multiplexing primitives to monitor active connections. When evaluating poll vs epoll for high-throughput network architectures, performance characteristics diverge dramatically as monitored descriptor counts climb. Early Unix designs centered on synchronous scanning mechanisms like select() and poll(). These interfaces require user applications to submit state descriptions on every query cycle. At low descriptor counts, linear overhead remains negligible. Once connections expand into tens of thousands of idle sockets, runtime profiles reveal latency cliffs.

System throughput collapses under linear multiplexing because operating systems repeatedly copy, scan, and rebuild monitoring structures during every polling pass. Modern event loops avoid this bottleneck through stateful registration models. Understanding exact performance discrepancies between linear polling and event-driven readiness notifications demands inspecting Linux kernel internals, memory copying costs, wait queue dispatching, and CPU cache line eviction behavior.

poll Memory Layout and O(N) Descriptor Traversal

System call poll() operates over contiguous array of struct pollfd elements allocated inside user space. Each structure contains eight bytes: four-byte integer fd, two-byte bitmask events specifying monitored conditions, and two-byte bitmask revents populated by kernel upon return. Worker thread passes memory pointer and integer descriptor count to poll().

Kernel entry initiates synchronous data transfer. Subsystem executes copy_from_user() to replicate entire descriptor block into kernel space. With 10,000 file descriptors, this operation transfers 80,000 contiguous bytes into kernel memory for single query. Understanding boundary crossing tax is critical, as documented when measuring System Call vs vDSO: What Entering the Kernel Costs under heavy context switches.

Once memory arrives in kernel space, do_poll() runs nested loop across every submitted element. Kernel retrieves file table entry associated with each descriptor through process file table, invoking poll operator stored inside file_operations table. For network sockets, this function corresponds to sock_poll(), executing protocol-specific routines like tcp_poll().

Inside socket callbacks, driver verifies socket ring buffers under spinlock protection. When socket contains data, driver returns readiness bitmask matching POLLIN. If no checked sockets hold data, kernel attaches process thread to wait queue of every monitored socket. Scanning 10,000 descriptors requires 10,000 pointer dereferences, 10,000 indirect function invocations, and 10,000 file table lookups. Execution duration scales strictly with total descriptor count $O(N)$.

poll vs epoll: Architectural Trade-Offs at Scale

Fundamental limitation of poll() stems from stateless interface design. Kernel retains zero state between consecutive poll() invocations. User process must reconstruct monitoring list, transfer memory across system call boundary, and trigger exhaustive kernel scans every iteration. In contrast, epoll splits multiplexing into state allocation via epoll_create1(), state mutation via epoll_ctl(), and event collection via epoll_wait(). Deep structural trade-offs of this subsystem appear in How Linux epoll Works: Red-Black Trees and Ready Lists.

Stateful design changes algorithmic complexity from $O(N)$ to $O(1)$ relative to total monitored descriptors. When registering descriptor via epoll_ctl(), kernel constructs internal node struct epitem inside red-black tree rooted within struct eventpoll. Red-black tree allows $O(\log N)$ insertion, lookup, and deletion by descriptor number.

During epoll_ctl() addition, kernel registers custom wait queue callback ep_poll_callback() on socket wait queue head. Calling thread then invokes epoll_wait(), passing empty result array. If ready list contains ready items, kernel transfers events immediately. If ready list is empty, calling thread sleeps until hardware events populate list.

Comparison between these models reveals key differences:

  • Registration: poll() repeats $O(N)$ memory copies every call; epoll pays one-time $O(\log N)$ registration overhead per socket.
  • Query: poll() executes $O(N)$ linear scans through kernel descriptor tables; epoll_wait() consumes $O(K)$ work for $K$ active sockets.
  • Memory: poll() shuttles complete descriptor arrays across user-kernel boundary twice per loop; epoll transfers only active readiness structures through packed buffers.
  • Wakeups: poll() attaches and detaches wait queues on each sleep cycle; epoll leaves callbacks attached continuously.

When monitoring 10,000 connections with one active socket, poll() evaluates 10,000 descriptors sequentially. In contrast, epoll_wait() returns immediately after reading single ready list entry.

Kernel Wait Queue Registrations and Wakeup Paths

Detailed mechanics of wait queues explain why poll() degrades severely under idle connections. When poll() scans descriptor array and finds zero ready events, calling thread sleeps until descriptor becomes active or timeout expires. To receive notification, calling thread registers poll_wqueues container, allocating poll_table_entry records for every checked descriptor.

Kernel executes __pollwait() callback during driver poll routines. Routine allocates wait queue entry from slab allocator, initializing entry with process task pointer and wakeup callback default_wake_function(). Routine links wait queue entry into socket wait queue head wait_queue_head_t guarded by socket wait queue lock. Monitoring 10,000 descriptors forces kernel to allocate and link 10,000 individual wait queue nodes across 10,000 distinct socket structures.

When incoming network packet arrives, network interface raises hardware interrupt. Kernel handles interrupt through top-half driver routine, scheduling softirq (NET_RX_SOFTIRQ) via NAPI polling loop. Kernel packet processing pipeline traverses network stack through ip_rcv() and delivers payload into target TCP socket receive buffer inside tcp_v4_rcv().

Socket state update invokes sock_def_readable(), locking sk_wq wait queue head and executing default_wake_function() to transition sleeping process state from TASK_INTERRUPTIBLE to TASK_RUNNING.

Once process wakes up, poll() cleans up temporary wait queue linkages. Calling thread traverses entire descriptor array second time, removing wait queue entry from every socket wait queue head under spinlock acquisition. Locking and unlocking 10,000 spinlocks across socket wait queues consumes millions of CPU cycles, creating cache contention. Under epoll, this teardown cycle is eliminated because callbacks remain persistently installed.

Cache Thrashing and Memory Bus Pressure Under Heavy Multiplexing

Beyond algorithmic complexity, poll() imposes severe micro-architectural penalties on modern processors. Scanning large pollfd arrays generates substantial memory bus traffic, displacing hot execution data from L1 and L2 CPU caches.

Consider server monitoring 20,000 concurrent sockets. Array of struct pollfd structures consumes 160,000 bytes, requiring roughly 2,500 standard 64-byte cache lines. During each polling pass, user space touches these cache lines to initialize structures. System call boundary crossing copies these lines into kernel buffers, causing cache line invalidations and memory bus transactions. Memory access patterns across pages resemble degradation documented in How Linux Page Fault Handling Impacts Memory Throughput.

Sequential traversal inside do_poll() forces CPU memory controller to stream descriptor array through prefetchers. While linear array access patterns are prefetcher-friendly, subsequent pointer dereferences inside loop destroy prefetcher efficacy. For every descriptor, kernel reads struct file pointer from process file table, dereferences f_op pointer, loads driver function pointer, dereferences socket sock structure, and inspects socket state flags.

These dereferences follow fragmented memory locations scattered across DRAM. Kernel socket objects reside inside separate slab allocations, rarely sharing contiguous cache lines. Sequential pass over 20,000 descriptors produces tens of thousands of dependent memory reads, triggering L3 cache misses and stalling execution pipelines.

In contrast, epoll_wait() touches only active events. Ready list implemented as doubly linked list (struct list_head) contains only descriptors with verified state changes. When three sockets become readable out of 20,000, epoll_wait() traverses exactly three struct epitem elements, copying 384 bytes of packed struct epoll_event data into user buffer. Caches retain application working sets, instructions execute without DRAM stalls, and memory bandwidth remains available for application data.

Measuring Wakeup Costs and Implementing epoll in Practice

Constructing reliable network services requires adopting modern multiplexing primitives while handling edge cases cleanly. Direct implementation shows how lightweight event notification structures replace heavy scanning arrays.

Below C program demonstrates direct configuration of epoll monitoring loop. Code registers listening socket and handles read readiness through level-triggered event polling without auxiliary allocations.

#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define MAX_EVENTS 64

static int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    assert(flags != -1);
    int res = fcntl(fd, F_SETFL, flags | O_NONBLOCK);
    assert(res != -1);
    return 0;
}

int main(void) {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    assert(listen_fd >= 0);

    int opt = 1;
    int sopt_res = setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    assert(sopt_res == 0);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    addr.sin_port = htons(9876);

    int bind_res = bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
    assert(bind_res == 0);

    int listen_res = listen(listen_fd, 128);
    assert(listen_res == 0);

    set_nonblocking(listen_fd);

    int epoll_fd = epoll_create1(EPOLL_CLOEXEC);
    assert(epoll_fd >= 0);

    struct epoll_event ev;
    memset(&ev, 0, sizeof(ev));
    ev.events = EPOLLIN;
    ev.data.fd = listen_fd;

    int ctl_res = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);
    assert(ctl_res == 0);

    /* ponytail: single-threaded event loop, upgrade to multi-worker epoll via SO_REUSEPORT when scaling cores */
    struct epoll_event events[MAX_EVENTS];

    /* self-check verification: verify epoll_wait returns 0 when no events pending and timeout is zero */
    int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, 0);
    assert(nfds == 0);

    close(epoll_fd);
    close(listen_fd);
    return 0;
}

Implementation demonstrates minimal surface area required for scalable multiplexing. File descriptor registration happens once via epoll_ctl(). Ready event retrieval happens via fixed-size stack buffer events. Kernel never scans inactive sockets, never allocates dynamic memory inside epoll_wait(), and never transfers unready descriptors across user-kernel boundary.

Edge-Triggered vs Level-Triggered Operational Modes

Architectural choice between level-triggered (LT) and edge-triggered (ET) notification modes represents another crucial dimension where epoll surpasses primitive interfaces. System call poll() supports only level-triggered semantics: as long as underlying socket buffer contains unread data, poll() returns POLLIN on every invocation. Application unable to consume entire socket payload immediately spins continuously, consuming 100% CPU time in busy-wait polling cycle unless application temporarily removes descriptor from scanning array.

Under epoll, developers select notification semantics per descriptor using EPOLLET flag during epoll_ctl() registration:

  • Level-Triggered mode: mirrors poll() semantics. Kernel checks if socket buffer contains unconsumed bytes when building ready list. If bytes remain, descriptor stays on ready list or reappears on subsequent epoll_wait() calls. Mode simplifies error recovery and partial reads, but generates extra system calls when descriptors stay ready across multiple loop iterations.
  • Edge-Triggered mode: delivers event notifications only when socket state transitions occur. When incoming network packet arrives at previously empty socket buffer, kernel delivers single readiness signal. Subsequent calls to epoll_wait() will not report socket again unless fresh packet arrives, even if socket buffer contains unread bytes.

Edge-triggered multiplexing allows worker threads to minimize kernel transitions by draining socket buffers completely using non-blocking loops until read() or recv() returns EAGAIN or EWOULDBLOCK. By combining non-blocking sockets with edge-triggered notifications, server runtimes reduce event signaling overhead to minimum boundaries.

Edge-triggered models introduce concurrency pitfalls if handled incorrectly. If worker thread fails to drain buffer completely, remaining data sits undetected until next packet arrives, causing stalled connections and unexplained application timeouts. In multi-threaded architectures where multiple threads share single epoll descriptor, edge-triggered wakeups cause thundering herd contention unless controlled via EPOLLEXCLUSIVE or EPOLLONESHOT flags. These flags instruct kernel to deliver event notification to exactly one thread, disarming descriptor until worker thread explicitly re-arms socket via epoll_ctl().

Conclusion

Architectural divergence between poll() and epoll reflects evolution of high-performance operating system design. Stateless interfaces like poll() impose linear $O(N)$ computational complexity, continuous memory bus saturation, repetitive kernel allocations, and destructive cache eviction loops. While acceptable for utility tools monitoring few file descriptors, linear polling collapses under load when descriptor sets scale into thousands of concurrent connections.

Adopting epoll transforms connection multiplexing into stateful $O(1)$ event dispatching model. Persistent red-black tree structures eliminate user-kernel memory transfers. Continuous wait queue callbacks decouple event detection from sleep state management. Packed ready lists ensure CPU cycles correlate directly with actual I/O activity rather than total connection volume. Network engines targeting scalable throughput must structure event loops around persistent kernel-managed state to avoid linear polling traps.

Measured on our own hardware: poll() vs epoll: What One Wakeup Costs With 10 to 10,000 Idle Descriptors

When a process watches many file descriptors and only one becomes ready, how does the cost of noticing it grow with the number of idle descriptors under poll() and under epoll_wait()?

We ran it. The numbers below come from a program executed on the server hosting this site on 2026-10-25 — an AMD EPYC 9354P 32-Core Processor with 8 cores visible, 31.3 GB of memory, Linux 6.8.0-147-generic.

Metric Value
epoll speedup at largest 240.75
epoll ns per wakeup 10000 fds 2728
epoll ns per wakeup 1000 fds 2379
epoll ns per wakeup 100 fds 2359
epoll ns per wakeup 10 fds 2473
largest fd count 10000
poll ns per wakeup 10000 fds 656745
poll ns per wakeup 1000 fds 96158
poll ns per wakeup 100 fds 11355
poll ns per wakeup 10 fds 3160
repeats 5

This is a shared virtual server, not an isolated test rig, so treat the absolute figures as indicative and the ratio between the two cases as the finding. The full method, the machine specification, and the complete source code are on the poll() vs epoll: What One Wakeup Costs With 10 to 10,000 Idle Descriptors benchmark page, so you can check the method or run it yourself.

References