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.

Three columns for one call with 16 watched sockets of which 2 are ready. select: 8 bytes of bitmap copied in, 16 fds checked, 8 bytes copied back, 16 bits tested by the app. poll: 128 bytes copied in, 16 checked, 128 bytes back, 16 revents tested. epoll: nothing copied in, the 2 epitems on the ready list checked, 24 bytes copied back, 2 events looked at
select and poll pay for every watched fd on every call; epoll_wait pays only for the fds 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 Nselectpollepoll
1632 checks, 16 B32 checks, 256 B4 checks, 24 B
100200, 32 B200, 1.6 KB4, 24 B
1,0002,000, 256 B2,000, 16 KB4, 24 B
10,000✗ fds ≥ 1024 do not fit20,000, 160 KB4, 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.

Time runs down. 1000 bytes arrive on fd 9. Call 1: level-triggered and edge-triggered both report fd 9 and the app reads 512 bytes, leaving 488. Call 2 with no new data: level-triggered reports fd 9 again; edge-triggered reports nothing and the 488 bytes stay unread
Level-triggered asks "is there data?" on every call; edge-triggered only says "data came", once.

Which one to use

selectpollepoll
fd list lives inthe app, rebuilt each callthe app, reusedthe kernel
per-call costO(N)O(N)O(ready)
fd limitFD_SETSIZE = 1024nonenone
triggeringlevellevellevel or edge
portableeverywhere, also WindowsPOSIXLinux 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.