epoll scales better because its cost depends on how many connections are active, not on how many are open. Disk files can't use it, because the kernel always reports them as ready.
select and poll: you pass the whole set of descriptors on every call. The kernel checks all of them. Then you scan the result to find the ready ones. So each call costs O(n), where n is the number of watched descriptors. This is true even if only one is active. select also only works with descriptor numbers below FD_SETSIZE, which is normally 1024.
epoll keeps the list of watched descriptors inside the kernel. It works like this:
- You register each descriptor once with
epoll_ctl. - When a descriptor's state changes, the kernel puts it on a ready list.
epoll_waitreturns only the ready descriptors.
So 10,000 idle connections cost almost nothing. epoll supports two modes: level-triggered (it reports a descriptor while it stays ready) and edge-triggered (it reports only when the state changes).
Regular files always count as ready. A disk read never returns EAGAIN. It just blocks. epoll_ctl refuses regular files with the error EPERM. That's why libuv runs file system work on its thread pool, while sockets go through epoll.
- select and poll pass and scan every file descriptor on each call, so each call costs O(n).
- select is limited by FD_SETSIZE, which is normally 1024.
- epoll registers each file descriptor once and returns only the ready ones.
- Regular files are always ready, so epoll can't make disk reads non-blocking.
- libuv uses its thread pool for file I/O.