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(): removesselect()’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 ofselect/poll.- Two readiness-notification modes: level-triggered (keeps notifying as long as unread data remains, the safer default used by
select/poll/defaultepoll) and edge-triggered (notifies only once when state transitions to ready,EPOLLETin 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/polltoepoll/kqueuewas 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/kqueuebehavior is portable. Code relying on Linuxepollsemantics 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 itsFD_SETSIZElimit 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.epollwas added to Linux 2.5.44 (2002) andkqueueto FreeBSD around the same time, both direct responses to the C10K problem being written about heavily in that era as web traffic scaled past whatselect/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 evenepoll, closer in spirit to Windows’ completion-based IOCP than toepoll’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 call | O(N) | O(N) | O(1) amortized | O(1) amortized |
| Max descriptors | Fixed (FD_SETSIZE) | Unlimited | Unlimited | Unlimited |
| Registration model | Re-pass full set each call | Re-pass full set each call | Persistent, register once | Persistent, register once |
| Edge-triggered support | No | No | Yes (EPOLLET) | Yes (EV_CLEAR) |
| Platform | POSIX, universal | POSIX, universal | Linux only | BSD, macOS only |
| Registration cost | None (stateless) | None (stateless) | O(log n) tree insert | O(log n) tree insert |
| Syscalls per idle wait cycle | 1 (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_NONBLOCKreturns immediately withEAGAIN/EWOULDBLOCKif 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.
Related Terms
Referenced by