Files & I/OAdvanced

select, poll, epoll and kqueue

“How can one thread wait on 10,000 sockets without spinning? Compare select, poll, epoll and kqueue, and say what none of them can do.”

What this tests

  • Why polling in a loop and blocking per socket both fail
  • select/poll as O(n) per call vs epoll/kqueue as O(ready)
  • Level- vs edge-triggered semantics and the pitfalls
  • Readiness vs completion models; regular files as the gap

Answers by level

Read the beginner answer first and notice what is missing.

Blocking read() on one socket parks the thread until *that* socket has data, so it cannot serve others; checking each non-blocking socket in a loop spins the CPU and still adds latency. What is needed is a single call that sleeps until any of a set is ready — I/O multiplexing — so the kernel, which already knows when each socket buffer changes, does the waiting (I/O Multiplexing: select, poll, epoll, kqueue, IOCP).

select() was the first: pass three bitmaps of descriptors, sleep, get back bitmaps of the ready ones. Two problems: the bitmap is fixed-size (FD_SETSIZE, 1,024 on most systems) and every call is O(n) — the kernel scans all n descriptors to check readiness, you rebuild the sets every time, and you scan the result. poll() removes the size limit with an array of struct pollfd but keeps the O(n) scan on both sides. At 10,000 mostly idle sockets, each call does 10,000 units of work to find the 5 that are ready.

epoll (Linux) and kqueue (BSD, macOS) fix the algorithm: you register interest once (epoll_ctl), the kernel keeps a ready list that it updates as events happen, and each epoll_wait returns only the ready descriptors — O(ready), not O(registered). That is the difference between a server that scales with active connections and one that scales with total connections, and it is what nginx, Node.js, Redis and every event loop are built on (The Event Loop). kqueue additionally covers file changes, signals, timers and process events in the same interface; Windows uses IOCP, a completion model rather than a readiness one.

Two semantic choices matter. Level-triggered (default) reports a descriptor as long as it is ready — simple, and you can read partially. Edge-triggered (EPOLLET) reports only on the *transition* to ready, so you must drain until EAGAIN or you will never hear about that socket again; it is more efficient and easier to get wrong. And none of these help with regular files, which are always "ready" on Linux and simply block on the actual read — that is why libuv and others use a thread pool for file I/O, and why io_uring exists (Async I/O: What `await readFile()` Actually Does).

Green flags · Red flags

Strong green flag · Knows epoll is on the open file description and the thundering-herd / SO_REUSEPORT story.
Green flags
  • States the problem (one thread, many waits) before the APIs
  • Explains select/poll O(n) and FD_SETSIZE vs epoll O(ready)
  • Knows level vs edge triggering and the drain-until-EAGAIN rule
  • Says regular files are the gap and names the thread pool / io_uring answer
  • Distinguishes readiness from completion
Red flags
  • Proposes polling sockets in a loop
  • Cannot say why epoll scales and select does not
  • Believes epoll makes disk reads asynchronous

Follow-up questions

F1
With edge-triggered epoll, what happens if you read 4 KB of a 10 KB message and return to epoll_wait?
F2
Why does Node.js use a thread pool for fs?
F3
Eight nginx workers, one listening socket, one connection arrives. What is the problem?

Scenario

A C++ gateway serves 50,000 connections with poll() and burns 60% CPU while only 200 connections are active. Explain the cost and describe the change, including which trigger mode you would choose and why.

Learn this topic