I-O Multiplexing

I-O Multiplexing

Definition: A mechanism allowing a single thread to monitor multiple file descriptors, typically sockets, concurrently to determine which ones are ready for I/O without blocking on any single one.

How It Works

  • select(): the earliest POSIX standard. Does an O(N) linear scan over a monitored descriptor set on every call, and is limited to a small fixed maximum number of descriptors (FD_SETSIZE, often 1024).
  • poll(): removes select()’s fixed descriptor-count limit, but is still O(N) per call, since the kernel must re-scan the entire passed-in list every time regardless of how many descriptors are actually ready.
  • epoll (Linux) / kqueue (macOS, BSD): O(1) amortized event-driven notification. The kernel maintains interest registrations persistently across calls and only returns descriptors that actually became ready, avoiding the repeated full-list rescan of select/poll.
  • Two readiness-notification modes: level-triggered (keeps notifying as long as unread data remains, the safer default used by select/poll/default epoll) and edge-triggered (notifies only once when state transitions to ready, EPOLLET in epoll, which requires draining the socket completely in a loop or risk missing later data).
  • Enables the event-driven, non-blocking I/O architecture: a single-threaded event loop repeatedly calls epoll_wait(), dispatches a callback or handler for each ready descriptor, then returns to waiting. No thread-per-connection is needed.

Under the Hood

epoll works through three syscalls. epoll_create1() allocates a kernel-side epoll instance, itself represented by a file descriptor. epoll_ctl() registers, modifies, or removes interest in a given file descriptor, storing the registration in an internal red-black tree keyed by file descriptor for fast add/remove/lookup. epoll_wait() blocks until at least one registered descriptor has a pending event, then returns only the ready ones from a separate internal ready-list, a linked list that socket/device drivers append to via a callback the instant their state changes (e.g., data arrives on a socket), rather than epoll polling every descriptor itself. That callback-driven ready-list is precisely what makes epoll O(1) relative to the number of ready events instead of O(N) relative to the number of watched descriptors.

select() and poll(), by contrast, pass the entire descriptor set into the kernel on every call and the kernel walks all of it checking readiness, then copies the whole result back to userspace, which is why their cost scales with the number of watched descriptors, not the number of ready ones, and why they degrade badly with tens of thousands of mostly-idle connections.

Windows uses a conceptually different model, I/O Completion Ports (IOCP), which is completion-based rather than readiness-based: instead of asking “which descriptors are ready to read,” the application issues asynchronous reads upfront and IOCP notifies it when they actually complete. This is why cross-platform async I/O libraries like libuv (used by Node.js) implement a separate backend per OS rather than one shared implementation.

The registration side of epoll matters as much as the wait side for real workloads. epoll_ctl(EPOLL_CTL_ADD, ...) inserts a node into the epoll instance’s internal red-black tree, keyed by file descriptor number, so a later EPOLL_CTL_MOD or EPOLL_CTL_DEL on that same descriptor is an O(log n) tree operation rather than a linear search. This is also why one epoll instance can cheaply watch tens of thousands of descriptors even though only a handful change state on any given epoll_wait() call, the persistent tree plus the separately maintained ready-list decouples “how many descriptors are registered” from “how much work each wait call does.”

Debugging Workflow

Diagnosing an event-loop-based server that’s slow or stuck usually means figuring out whether it’s actually blocked waiting on I/O, or blocked on something inside a handler that shouldn’t be blocking:

$ strace -f -e trace=epoll_wait,read,write -p <pid>
epoll_wait(4, [{EPOLLIN, {u32=17}}], 64, -1) = 1
read(17, "GET /slow HTTP/1.1...", 4096) = 312

If epoll_wait calls are returning promptly with events but the process is slow to move on to the next call, the time is being spent inside a handler, not in the multiplexer, pointing at a CPU-heavy or blocking-call bug in application code rather than an I/O problem.

$ ss -tan state established | wc -l    # count active connections
$ lsof -p <pid> | wc -l                # count open file descriptors for the process
$ cat /proc/<pid>/limits | grep "open files"   # check the fd ulimit isn't the bottleneck

A server that stops accepting new connections while ss shows plenty of established sockets often means it’s hit its file descriptor limit (ulimit -n), not an epoll problem at all, worth ruling out before assuming the multiplexing layer itself is misbehaving.

Why It Matters

  • Solves the C10K problem, handling 10,000+ concurrent network connections on a server, by replacing thread-per-connection models, which exhaust memory and context-switch overhead at scale, with a small number of threads multiplexing many sockets each.
  • Directly underpins the async I/O model in most modern high-throughput servers, letting a single OS thread service thousands of idle-most-of-the-time connections cheaply.
  • The shift from select/poll to epoll/kqueue was a major turning point in server scalability in the 2000s, since O(1) readiness notification is what made single-digit-thread servers handling tens of thousands of connections practical.

Common Pitfalls

  • Blocking the event loop thread with CPU-heavy computation halts socket event processing for every connection multiplexed on that thread. One slow synchronous handler can freeze an entire server’s I/O.
  • Using edge-triggered mode without draining a socket in a loop (reading until EAGAIN/EWOULDBLOCK) causes the event loop to silently stop receiving further notifications for that descriptor, even though more data is waiting.
  • Registering a file descriptor with a multiplexer and forgetting to unregister/close it on connection teardown leaks kernel-side tracking resources over the process’s lifetime.
  • Assuming epoll/kqueue behavior is portable. Code relying on Linux epoll semantics doesn’t run unmodified on macOS/BSD (kqueue) or Windows (IOCP), which is why cross-platform async runtimes need an abstraction layer like libuv.

History

  • select() shipped in 4.2BSD (1983), designed for the modest connection counts of early Unix networking, and its FD_SETSIZE limit reflects that era’s assumptions.
  • poll() arrived in System V (late 1980s) mainly to remove the fixed descriptor-count ceiling, but kept the same “rescan everything” cost model.
  • epoll was added to Linux 2.5.44 (2002) and kqueue to FreeBSD around the same time, both direct responses to the C10K problem being written about heavily in that era as web traffic scaled past what select/poll-based servers could handle.
  • Linux’s io_uring (2019) represents the next step beyond readiness-based multiplexing: a shared ring buffer between userspace and kernel lets an application submit I/O operations and reap completions with far fewer syscalls than even epoll, closer in spirit to Windows’ completion-based IOCP than to epoll’s readiness model.

FAQ

Why not just use one thread per connection instead of multiplexing? Threads are far more expensive per unit than a multiplexed file descriptor: each needs its own stack (commonly megabytes) and kernel scheduling entity, so tens of thousands of threads for tens of thousands of idle connections exhausts memory and context-switch bandwidth long before an epoll-based single thread would.

Does I/O multiplexing make I/O itself faster? No. It doesn’t speed up any individual read or write; it removes the cost of finding out which of many descriptors is ready, so a thread spends its time doing useful work on ready descriptors instead of blocking on or polling idle ones.

Is epoll always better than select/poll? For large numbers of mostly-idle descriptors, yes. For a handful of descriptors (a handful of connections), the simplicity of select/poll and the setup cost of epoll’s registration model can make the difference negligible or even favor the simpler call.

Comparison

select()poll()epoll (Linux)kqueue (BSD/macOS)
Complexity per callO(N)O(N)O(1) amortizedO(1) amortized
Max descriptorsFixed (FD_SETSIZE)UnlimitedUnlimitedUnlimited
Registration modelRe-pass full set each callRe-pass full set each callPersistent, register oncePersistent, register once
Edge-triggered supportNoNoYes (EPOLLET)Yes (EV_CLEAR)
PlatformPOSIX, universalPOSIX, universalLinux onlyBSD, macOS only
Registration costNone (stateless)None (stateless)O(log n) tree insertO(log n) tree insert
Syscalls per idle wait cycle1 (select)1 (poll)1 (epoll_wait)1 (kevent)

Blocking vs Non-Blocking vs Multiplexed I/O

  • Blocking I/O: a read()/write() call doesn’t return until the operation completes, simplest to write, but one thread can only ever wait on one descriptor at a time.
  • Non-blocking I/O: a socket set to O_NONBLOCK returns immediately with EAGAIN/EWOULDBLOCK if no data is ready, instead of blocking, but naively polling it in a tight loop wastes CPU.
  • Multiplexed I/O: combines non-blocking sockets with a readiness notifier (select/poll/epoll/kqueue), so a thread sleeps efficiently until the kernel says at least one descriptor is actually ready, then performs non-blocking reads/writes only on those.

Example

A minimal epoll event loop pattern:

epfd = epoll_create1(0)
epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, EPOLLIN)
while true:
    events = epoll_wait(epfd, max_events=64, timeout=-1)
    for fd in events:
        handle_ready_socket(fd)   # only sockets with actual data trigger work

A server multiplexing 50,000 mostly-idle WebSocket connections with epoll wakes up only for the handful that actually have data ready, instead of a select()-based server that would have to re-scan all 50,000 descriptors on every loop iteration. Nginx, Redis, and Node.js’s libuv all use epoll/kqueue as their default event-notification backend on Linux/BSD respectively; strace -e epoll_wait on a running Nginx worker shows this loop directly.

Dig deeper