How IO Multiplexing Works Under the Hood: select, poll, and epoll

IO multiplexing enables a single thread to monitor multiple file descriptors (FDs) and perform IO only when those descriptors become ready (readable, writable, or exceptional). It eliminates the traditional blocking IO model’s one-thread-per-connection overhead and avoids wasted cycles of pure non‑blocking IO. Linux provides three primary multiplexing mechanisms—select, poll, and epoll—which form the basis of Java NIO's Selector, Redis, Nginx, and similar high‑performance systems.

File Descriptors and IO Readiness

In Linux, everything is a file. Network sockets, disk files, and pipes are each represented by an integer file descriptor (FD). Standard input is FD 0, standard output FD 1, standard error FD 2, and newly created sockets or files start from FD 3 onwards.

An IO operation proceeds in two phases. For a read socket:

  1. Data preparation – The kernel reads data from the NIC or disk into a kernel buffer (potentially blocking).
  2. Data copy – The kernel copies data from the kernel buffer to the user buffer (quick).

An FD is "ready" when the first phase completes, allowing a non‑blocking read or write. Multiplexing centers on monitoring this ready state across many FDs.

select

select is the earliest multiplexing interface, standardized by POSIX. Internally, the kernel scans the FD set provided by the user and checks readiness.

How It Works

  1. The application fills a fd_set bitmask with the FDs to moniter and calls select with a timeout.
  2. The kernel iterates over every bit in the fd_set, checking readiness.
  3. If no FD is ready, the process is suspended until data arrives or the timeout expires.
  4. When readiness occurs, the kernel marks the ready FDs directly in the fd_set, returns the total count of ready FDs, and the user process must again iterate through all FDs to find the ready ones.

Key Constraints

  • fd_set is a fixed‑size bitmask, limited to 1024 FDs by default.
  • Every call triggers a full traversal of the FD set (O(n) complexity) and requires the user to rebuild and recopy the FD set into the kernel.
  • Ready FDs are identified by manual user‑space iteration.

API Sketch

#include <sys/select.h>

int select(int max_fd_plus_one, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

// Helper macros
FD_ZERO(fd_set *set);
FD_SET(int fd, fd_set *set);
FD_ISSET(int fd, fd_set *set);
FD_CLR(int fd, fd_set *set);

poll

poll removes the fixed FD limit by using a dynamical sized array, though the core scanning logic remains the same.

How It Works

The application passes an array of struct pollfd, each element specifying an FD and the events of interest (e.g., POLLIN, POLLOUT). The kernel traverses this array, sets the revents field on each ready FD, and returns the total number of ready descriptors. The application then scans the array to extract ready entries.

API Sketch

#include <poll.h>

struct pollfd {
    int   fd;
    short events;  // requested events
    short revents; // returned ready events
};

int poll(struct pollfd *fds, nfds_t nfds, int timeout_millis);

Improvements and Remaining Issues

  • No built‑in FD count ceiling (limited only by system memory).
  • events and revents are separate, avoiding the need to rebuild the entire set on every call.
  • Still requires O(n) kernel traversal and user‑space scanning; repeated user‑to‑kernel copying persists.

epoll

Introduced in Linux 2.6, epoll addresses the scalability bottlenecks of select/poll through an event‑driven, O(1) architecture. Most JVM NIO implementations, Nginx, and Redis rely on it.

Core Design

Component Purpose
Red‑black tree Stores all registered FDs and their event subscriptions. Insertion and deletion are O(log n).
Ready list A linked list where the kernel places FDs that have become ready.
Callback mechanism A callback is attached to each FD when registered; the driver invokes it as soon as data arrives, moving the FD to the ready list.

Detailed Flow

  1. epoll_create
    Creates an epoll instance: a red‑black tree (for all watched FDs), a ready list (for ready FDs), and a wait queue (for sleeping application threads).
  2. epoll_ctl
    Adds, modifies, or removes an FD from the red‑black tree. When adding, the kernel registers a driver‑level callback that enqueues the FD onto the ready list once data becomes available.
  3. epoll_wait
    Checks the ready list. If it is non‑empty, the kernel directly returns the ready FDs and their events. If empty, the calling thread is placed on the wait queue until a callback fires or the timeout expires.
  4. Processing
    The application handles only the returned ready FDs. Already‑registered FDs remain in the tree and do not need re‑registration.

API Sketch

#include <sys/epoll.h>

int epoll_create(int size_hint);

int epoll_ctl(int epfd, int operation, int fd, struct epoll_event *ev);

int epoll_wait(int epfd, struct epoll_event *out_events,
               int max_events, int timeout_millis);

// Event structure
struct epoll_event {
    uint32_t     events;  // EPOLLIN, EPOLLOUT, ...
    epoll_data_t data;    // fd or user pointer
};

typedef union epoll_data {
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

Trigger Modes

  • Level‑triggered (LT, default) – epoll_wait continues to report an FD as long as it remains ready.
  • Edge‑triggered (ET) – Reports the FD exactly once when its state transitions from not‑ready to ready. This demands that the application drains all data immediately and is often paired with non‑blocking I/O for higher efficiency.

Side‑by‑Side Comparison

Dimension select poll epoll
Max FDs 1024 (configurable) Limited by system memory Limited by system file limit
Time complexity O(n) – scan all O(n) – scan all O(1) – only ready FDs
Storage structure fd_set bitmask struct pollfd array Red‑black tree + ready list
Ready discovery Application scans all FDs Application scans all FDs Kernel returns ready list
Data copy overhead Full set recopied each call Full array recopied each call Only on registration; reused
Trigger modes Level‑triggered Level‑triggered LT + Edge‑triggered
Ideal workload Very few FDs, cross‑platform Moderate FDs, cross‑platform Tens of thousands+ FDs, Linux

When to Use Each

  • select – Cross‑platform code, embedded systems, or legacy environments where FD counts stay under a thousand.
  • poll – Medium‑scale workloads that exceed 1024 FDs or need macOS/BSD support (where epoll is unavailable).
  • epoll – High‑performance Linux servers handling massive connections: instant messengers, load balancers, message brokers, and any scenario requiring minimal latency at scale.

Java NIO and epoll

java.nio.channels.Selector delegates to the best available system call:

  • Linux → epoll (since JDK 1.5)
  • Windows → IOCP
  • macOS/BSD → kqueue
  • Fallback to poll/select on older kernels

The mapping is direct:

  • Selector.open() ⇒ epoll_create
  • channel.register(sel, ops) ⇒ epoll_ctl
  • selector.select() ⇒ epoll_wait
  • selector.selectedKeys() ⇒ iterating the ready list returned by epoll

Tags: IO Multiplexing SELECT poll Epoll Linux

Posted on Fri, 30 Oct 2026 16:31:42 +0000 by jjjamie