2026-07-18
The fundamental innovation of epoll over select/poll isn't the API — it's that epoll_wait never iterates the set of watched file descriptors. It reads a pre-populated linked list. Understanding how that list gets populated tells you why edge-triggered mode has its notorious draining requirement.
Inside the kernel, an epoll instance owns two structures: an interest list (a red-black tree keyed by {fd, file*}, holding every epoll_ctl(EPOLL_CTL_ADD) registration as an epitem) and a ready list (a doubly-linked list of epitems whose underlying file has signaled readiness).
When you EPOLL_CTL_ADD a socket, epoll calls the file's ->poll method, which does two things: reports current readiness, and installs a wait queue entry onto the socket's internal wait queue. That entry's wakeup callback is ep_poll_callback, not the usual "wake a sleeping task."
Now the interesting bit. When a packet arrives and the socket's receive path calls wake_up_interruptible(&sk->sk_wq), the kernel walks the wait queue and invokes ep_poll_callback for the epoll registration. That callback does one thing: it splices the epitem onto the epoll instance's ready list (if not already there) and wakes any task blocked in epoll_wait. The watched fd pushes itself onto the ready list. Epoll never polls it.
So epoll_wait is roughly: grab the ready list, for each epitem call the file's ->poll to confirm current readiness and get the event mask, copy to userspace. The cost scales with ready fds, not watched fds.
Where ET diverges from LT: in level-triggered mode, after each epoll_wait epoll re-checks readiness and re-inserts the epitem onto the ready list if the fd is still readable. In edge-triggered mode, the epitem is only re-added when a fresh wakeup fires from the underlying file. If your socket has 40KB pending and you read() 4KB, no new wakeup arrives — the ready list forgets that fd until more data comes in. Hence the rule: with EPOLLET, always read() until you get EAGAIN.
Rule of thumb: if watching N fds where K are typically ready per wait, select costs O(N) per wait plus O(N) per readiness change; epoll costs O(K) per wait plus O(1) per readiness change. At N=10,000 and K=10, that's a ~1000× reduction in wait-path work.
Real-world example: nginx watching 50,000 keep-alive connections. Only ~50 have data in any given millisecond. With poll, the kernel copies and scans 50,000 pollfd structs each call. With epoll, TCP's receive path pushed those 50 onto the ready list as packets arrived; epoll_wait returns them without touching the other 49,950.