~/learn

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 three fd_set bitmaps, but is capped at FD_SETSIZE (usually 1024), must be rebuilt every call, and is O(n). Avoid in new code.
  • poll — an array of pollfd { fd, events, revents }. No hard limit and no rebuilding (only revents is overwritten), but still O(n). A good portable fallback.
  • epoll — Linux-specific and O(1): the kernel keeps the interest set (epoll_ctl to add/modify/remove) and epoll_wait returns only the ready descriptors — no scanning. The default choice for Linux servers (nginx, Redis, Node.js all use it).
selectpollepoll
FD limit1024unlimitedunlimited
ScalingO(n)O(n)O(1)
PortabilityPOSIXPOSIXLinux only
Kernel tracks setnonoyes
Recommendationavoidportable fallbackuse 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 until EAGAIN, 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_NONBLOCK and observe EAGAIN when 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_wait timeout to run a periodic task alongside I/O handling.

Homework

References & resources

Required

Optional / enrichment

Self-check

  1. What problem does I/O multiplexing solve, and why aren't blocking or busy-polling acceptable?
  2. What does EAGAIN mean on a non-blocking descriptor?
  3. Why does epoll scale better than select/poll as connections grow?
  4. What is the difference between level-triggered and edge-triggered epoll?
  5. Why must edge-triggered mode use non-blocking descriptors and drain until EAGAIN?

On this page