The idea: one thread, many sockets, which one is ready?
A server with one thread and thousands of connections cannot call read() on each socket and wait: most of them are idle. It asks the kernel instead: which of my sockets can I read now? Linux has three system calls for that question. select (1983) and poll (1986) take the whole list of fds on every call. epoll (Linux 2.6, 2002) keeps the list inside the kernel and keeps a second list of the ones that are ready.
The canvas runs three servers on the same 16 sockets (fd 3 to fd 18). Each Call first lets data arrive on the sockets you pick, then each server makes one wait call and reads what it was told about. Every row has one column per fd, so the three lanes line up. Under each lane the counters show the bytes copied across the system-call boundary, the fds the kernel checked and the entries the app had to look at.
select: the fd_set bitmap
select(nfds, &readfds, …, timeout) takes an fd_set: a bitmap with one bit per fd number, FD_SETSIZE = 1024 bits. The kernel copies the first nfds bits in (rounded up to a long: 8 bytes for fds below 64), calls the poll method of every socket whose bit is set, then overwrites the bitmap so that only the ready bits stay 1, and copies it back. The return value is only a count, so the app tests every bit with FD_ISSET to find which ones. Because the input was overwritten, the app rebuilds it with FD_ZERO and FD_SET before the next call (Demo 1: 16 FD_SET, 16 kernel checks, 16 FD_ISSET to find 2 fds).
The bitmap is indexed by fd number, so an fd of 1024 or more simply does not fit: writing its bit corrupts memory. A server with more than about a thousand connections cannot use select at all.
poll: the pollfd array
poll(fds, nfds, timeout) takes an array of struct pollfd { int fd; short events; short revents; }, 8 bytes each. The kernel writes the result into revents and never touches events, so the array is reused without a rebuild, and there is no fd limit. Everything else is the same as select: the whole array (16 × 8 = 128 bytes) is copied in and back on every call, the kernel checks every entry, and the app loops over every revents.
epoll: interest set and ready list
epoll splits the job in two. epoll_ctl(epfd, EPOLL_CTL_ADD, fd, …) registers a socket once: the kernel adds an epitem to the interest set (a red-black tree) and hooks a callback into the socket's wait queue. When data arrives, that callback (ep_poll_callback) puts the epitem on the ready list. epoll_wait(epfd, events, maxevents, timeout) copies no fd list in; it checks only the epitems on the ready list and copies out one 12-byte struct epoll_event per ready fd, packed at the start of events[]. The app's loop runs over the returned count, not over N. See Linux epoll for the kernel structures in detail.
When every fd is ready the advantage disappears: 16 of 16 ready means 16 checks and 16 events whichever call you use (Demo 2). epoll wins when many connections are idle, which is the normal case for a server.
Cost per call as connections grow
With 2 ready fds per call, the work per call (kernel checks plus app checks) and the bytes copied are (Demo 3):
| watched fds N | select | poll | epoll |
|---|---|---|---|
| 16 | 32 checks, 16 B | 32 checks, 256 B | 4 checks, 24 B |
| 100 | 200, 32 B | 200, 1.6 KB | 4, 24 B |
| 1,000 | 2,000, 256 B | 2,000, 16 KB | 4, 24 B |
| 10,000 | ✗ fds ≥ 1024 do not fit | 20,000, 160 KB | 4, 24 B |
select and poll are O(N) per call; epoll_wait is O(ready). epoll moves the O(N) part to epoll_ctl, paid once per connection instead of once per call. For a handful of busy fds the difference is small, and poll can even be cheaper because it needs no extra system calls to register fds.
Level-triggered and edge-triggered
Level-triggered means "report the fd while it is readable". select and poll can only work this way, and it is epoll's default: after epoll_wait reports an epitem, the kernel puts it back on the ready list and checks it again on the next call. In Demo 4 1000 bytes arrive on fd 9, each server reads 512, and the next call reports fd 9 again in all three lanes.
Edge-triggered (EPOLLET) means "report the fd when new data arrives". The reported epitem leaves the ready list and only the callback puts it back. In Demo 5 the second call returns 0 events on the epoll side while 488 bytes sit in fd 9: if no more data comes, that request is never served. An edge-triggered server must use non-blocking sockets and read until read() fails with EAGAIN. In return it gets fewer wake-ups and fewer repeated reports, which nginx uses.
Which one to use
| select | poll | epoll | |
|---|---|---|---|
| fd list lives in | the app, rebuilt each call | the app, reused | the kernel |
| per-call cost | O(N) | O(N) | O(ready) |
| fd limit | FD_SETSIZE = 1024 | none | none |
| triggering | level | level | level or edge |
| portable | everywhere, also Windows | POSIX | Linux only (kqueue on BSD/macOS) |
Event loops such as libuv, nginx, Netty and Go's runtime use epoll on Linux and fall back to poll or select elsewhere. The next step, where the kernel also does the read, is io_uring vs epoll; the broader picture of waiting for I/O is in blocking vs non-blocking vs async I/O.
What the page leaves out
Write and exception sets (POLLOUT, EPOLLOUT); the sleep when nothing is ready (the page uses timeout 0) and how each call wakes up again; pselect/ppoll/epoll_pwait and signal masks; EPOLLONESHOT and EPOLLEXCLUSIVE; the cost of epoll_ctl itself; many threads sharing one epoll instance; and real timings: the counts here show how the work grows, not nanoseconds.