I/O Multiplexing
Serving many connections in one thread — non-blocking I/O and the select, poll, and epoll interfaces behind event-driven servers.
How does one server handle thousands of clients at once? Blocking on a single recv ignores
everyone else, a thread per connection doesn't scale, and busy-polling burns CPU. I/O
multiplexing is the answer: ask the kernel to watch many descriptors and tell you which are
ready, so you only act on those. It's the foundation of every event-driven server.
Non-blocking I/O
By default I/O calls block. You make a descriptor non-blocking so calls return immediately
with EAGAIN/EWOULDBLOCK instead of waiting:
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // or socket(..., SOCK_STREAM | SOCK_NONBLOCK, 0)But polling non-blocking descriptors in a tight loop wastes CPU — multiplexing is the right way to wait on many at once.
select, poll, epoll
Three interfaces do the job, with very different scaling:
select— the classic. Watches threefd_setbitmaps, but is capped atFD_SETSIZE(usually 1024), must be rebuilt every call, and is O(n). Avoid in new code.poll— an array ofpollfd { fd, events, revents }. No hard limit and no rebuilding (onlyreventsis overwritten), but still O(n). A good portable fallback.epoll— Linux-specific and O(1): the kernel keeps the interest set (epoll_ctlto add/modify/remove) andepoll_waitreturns only the ready descriptors — no scanning. The default choice for Linux servers (nginx, Redis, Node.js all use it).
select | poll | epoll | |
|---|---|---|---|
| FD limit | 1024 | unlimited | unlimited |
| Scaling | O(n) | O(n) | O(1) |
| Portability | POSIX | POSIX | Linux only |
| Kernel tracks set | no | no | yes |
| Recommendation | avoid | portable fallback | use this |
Level- vs edge-triggered
epoll offers two notification modes:
- Level-triggered (default) — keeps reporting readiness while data remains. Easiest to use.
- Edge-triggered (
EPOLLET) — reports only on a change, so you must drain the descriptor untilEAGAIN, and it requires non-blocking descriptors. Fewer wakeups, but trickier to get right.
The event-driven (reactor) pattern
Put it together and you get a single-threaded event loop over non-blocking descriptors:
[epoll_wait] → ready fds → dispatch handlers → back to [epoll_wait]No thread-per-connection overhead, no per-connection stacks — one thread can serve tens of
thousands of connections. (Newer Linux kernels also offer io_uring, a ring-buffer async
interface that goes further still, but epoll is the default for now.)
See it in code
The Using select,
Using poll, and
Using epoll samples monitor stdin and a FIFO with
each interface — the same task three ways, so you can compare them directly.
Practice / lab
- Open a pipe with
O_NONBLOCKand observeEAGAINwhen there's no data. - Write a TCP echo server that handles the listener and all clients in one
poll()loop. - Rewrite it with
epoll(level-triggered), then try edge-triggered with a proper drain loop. - Use the
epoll_waittimeout to run a periodic task alongside I/O handling.
Homework
- NET-2: Multiplexed Sqrt Server —
a single-threaded
epollserver handling many clients concurrently.
References & resources
Required
- Beej's Guide —
select() epoll(7)— authoritative reference- The Linux Programming Interface (Kerrisk) — Ch. 63: Alternative I/O Models
- The C10K problem
Optional / enrichment
select(2)·poll(2)·epoll_ctl(2)·epoll_wait(2)- epoll in 3 easy steps
- Efficient IO with io_uring (LWN) · liburing
Self-check
- What problem does I/O multiplexing solve, and why aren't blocking or busy-polling acceptable?
- What does
EAGAINmean on a non-blocking descriptor? - Why does
epollscale better thanselect/pollas connections grow? - What is the difference between level-triggered and edge-triggered
epoll? - Why must edge-triggered mode use non-blocking descriptors and drain until
EAGAIN?