epoll and Scalable Readiness Notification
epollis Linux’s scalable I/O-readiness interface: a kernel object that lets one thread watch tens of thousands of file descriptors and learn which are ready to read or write at a cost proportional to the number that are actually ready, not the number being watched. Where select rebuild a registration and rescan the entire descriptor set on every call (O(n)), epoll registers each descriptor once withepoll_ctl(EPOLL_CTL_ADD), plants a wakeup callback (ep_poll_callback) directly on that descriptor’s wait queue, and maintains a ready list that only descriptors whose state changed are pushed onto.epoll_waitthen simply drains that ready list. This stateful, callback-driven design is what solved the C10K problem — handling ten thousand simultaneous clients on commodity hardware (Kegel, The C10K Problem). This note traces the mechanism as implemented infs/eventpoll.cof Linux 6.12 LTS (released 2024-11-17). Theepoll(7)HISTORY section dates the interface to Linux 2.5.44 with glibc 2.3.2 — that is, it landed in the 2.5 development series and shipped in 2.6, not “in 2.6” as it is usually stated (epoll(7));epoll_create1was added later, in Linux 2.6.27 with glibc 2.9 (epoll_create(2)). The counterpart to this note is io_uring as a Syscall Batching Mechanism: epoll is readiness notification — tell me when I can act — while io_uring is completion notification — do it and tell me when it is done. That single distinction, developed below, explains most of what each can and cannot do.
Mental Model
The core idea is invert the question. poll/select ask the kernel, on every call, “of these N descriptors, which are ready?” — forcing a full scan. epoll instead tells the kernel once, “remember that I care about these descriptors,” and the kernel pushes readiness to a list as it happens. By the time epoll_wait runs, the answer is already assembled: it just hands back the ready list. The watched set lives in the kernel across calls (a red-black tree, the interest list); readiness accumulates in a separate ready list. The two never have to be reconciled by scanning.
The man page names the two lists exactly: the interest list is “the set of file descriptors that the process has registered an interest in monitoring,” and the ready list is “the set of file descriptors that are ‘ready’ for I/O… dynamically populated by the kernel as a result of I/O activity” (epoll(7)).
flowchart TD subgraph SETUP["Setup: epoll_create1 + epoll_ctl(ADD) per fd"] CREATE["epoll_create1(EPOLL_CLOEXEC)<br/>=> struct eventpoll (rbtree + ready list)"] CREATE --> ADD["epoll_ctl(ADD, fd): ep_insert<br/>alloc epitem, insert into rbtree"] ADD --> ARM["ep_item_poll . vfs_poll . ep_ptable_queue_proc<br/>installs ep_poll_callback on fd's wait queue"] end READY["fd becomes ready<br/>(packet queued on socket)"] --> CB["wake_up on socket wait queue<br/>fires ep_poll_callback"] CB --> PUSH["ep_poll_callback: list_add_tail(epi.rdllink, ep.rdllist)<br/>wake_up(ep.wq)"] PUSH --> RDLLIST["ready list (rdllist)<br/>holds ONLY changed fds"] WAIT["epoll_wait: ep_poll"] --> DRAIN["ep_send_events: walk rdllist (ready only)<br/>re-poll each, copy revents to userspace"] RDLLIST --> DRAIN DRAIN --> LT{"level-triggered<br/>(!EPOLLET)?"} LT -->|"yes, still ready"| REQUEUE["list_add_tail back onto rdllist<br/>=> reported again next wait"] LT -->|"no (EPOLLET)"| DONE["removed; not reported until next edge"]
Figure: the epoll life cycle. What it shows: registration (epoll_ctl) happens once and installs ep_poll_callback on each fd’s wait queue; thereafter readiness pushes the fd onto the ready list (rdllist) with no scanning; epoll_wait drains only that list. The insight to take: the expensive O(n) work — installing wakeup hooks — is paid once per fd at ADD time, not once per epoll_wait call. The wait call’s cost is proportional to the number of ready descriptors. The branch at the bottom is the level- vs edge-triggered distinction: in level-triggered mode a still-ready fd is re-queued so it keeps being reported; in edge-triggered (EPOLLET) mode it is reported only on the transition.
Readiness, and What It Cannot Do
Everything epoll is good at and everything it is blind to follows from one choice: it is a readiness interface. It answers “which of my descriptors would not block if I acted on them right now?” and then gets out of the way. It never performs I/O. The alternative design — a completion interface — takes the whole operation from you, performs it, and reports the result; that is io_uring, and also Linux’s older aio and Windows I/O Completion Ports.
The consequences are worth stating up front because they recur throughout this note:
- Syscall count. A readiness tick costs one
epoll_waitplus one syscall per descriptor you then act on. If 100 sockets are readable, that is 101 boundary crossings. A completion interface fuses all 101 into one, because the reads were part of the submission. - The “would block” precondition. Readiness is only meaningful for objects that have a not-ready state. Sockets, pipes, FIFOs, terminals,
eventfd,timerfd,signalfd, andinotifyall qualify. Regular files and directories do not — a regular file is always “ready” in the readiness sense even when reading it will stall for milliseconds fetching a page from disk. The kernel does not pretend otherwise; it refuses the registration outright, which is covered in its own section below. - Spurious readiness is inherent. Because notification and action are separate steps, the world can change in between — another thread drains the socket, the peer resets it — so a readiness report is always “maybe ready.” Every correct epoll program treats it that way and handles
EAGAIN.
flowchart TD subgraph RD["READINESS — epoll, poll, select, kqueue"] direction TB R1["Kernel: fd 7 is readable"] --> R2["epoll_wait returns fd 7<br/>(syscall 1)"] R2 --> R3["App: read(7, buf, n)<br/>(syscall 2 — may still return EAGAIN)"] R3 --> R4["App owns the I/O.<br/>Kernel only signalled."] end subgraph CP["COMPLETION — io_uring, aio, Windows IOCP"] direction TB C1["App: submit READ(fd=7, buf, n)<br/>as an SQE"] --> C2["Kernel performs the read<br/>whenever it can"] C2 --> C3["Kernel posts CQE{res = bytes}"] C3 --> C4["App reads the result.<br/>Kernel owned the I/O."] end RD -.->|"regular files have no<br/>'not ready' state → EPERM"| GAP["The gap epoll cannot cover"]:::gap CP -.->|"works identically on files<br/>and sockets"| FILL["No gap"]:::ok classDef gap fill:#fdd,stroke:#c33 classDef ok fill:#dfd,stroke:#3a3
The two notification models side by side. What it shows: under readiness the application still performs the I/O after being told it may, so the kernel’s involvement ends at the signal; under completion the kernel performs the I/O and the application only collects results. The insight: this is not a performance detail, it is a capability difference. The readiness model structurally cannot describe “this file read will block” because a file read’s blocking is caused by page-cache misses, not by an empty buffer with a wait queue attached — and that missing half of the I/O world is a large part of why io_uring was built. Within its own half, epoll is excellent and considerably simpler; see io_uring as a Syscall Batching Mechanism for the other model in depth.
Why select and poll Are O(n) — and epoll Is Not
The claim “select and poll are O(n) per call” gets repeated so often that it is usually taken on faith. It is worth actually reading the loop, because which n it is, and why it must be rescanned, is what motivates every design choice in epoll.
select is implemented by do_select in fs/select.c at v6.12. Stripped to its skeleton:
for (;;) { /* the retry loop */
for (i = 0; i < n; ++rinp, ++routp, ++rexp) { /* <-- O(n) over ALL fds */
unsigned long in, out, ex, all_bits, bit = 1, j;
in = *inp++; out = *outp++; ex = *exp++;
all_bits = in | out | ex;
if (all_bits == 0) {
i += BITS_PER_LONG; /* fast-skip 64 unset bits at once */
continue;
}
for (j = 0; j < BITS_PER_LONG; ++j, ++i, bit <<= 1) {
if (!(bit & all_bits)) continue;
f = fdget(i); /* look up the fd */
if (fd_file(f)) {
wait_key_set(wait, in, out, bit, busy_flag);
mask = vfs_poll(fd_file(f), wait); /* POLL IT */
fdput(f);
}
...
}
}
...
}Three separate costs are visible, and they are all proportional to n, the highest-numbered watched descriptor plus one:
- The scan itself.
for (i = 0; i < n; ...)walks the bitmaps every call. Theall_bits == 0fast path skips 64 clear bits at a time, so a sparse set is cheaper than a dense one, but the walk is still linear in the range of descriptor numbers, not in the number actually watched. Watching only fd 4,999 out of 5,000 costs you the full 5,000-bit walk. - The registration.
vfs_pollon the first pass invokes each file’s->pollmethod, which callspoll_wait(file, wait_queue, pt), which callsselect’s queue-proc__pollwait— allocating and installing a wait-queue entry on every watched descriptor. When the call returns,poll_freewaittears all of them down again. This work is repeated in full on every singleselectcall. - The copy. The three
fd_setbitmaps are copied from userspace on entry and back out on return, every call.
poll differs only cosmetically: do_poll walks an array of struct pollfd rather than three bitmaps, so its n is the number of entries you passed rather than the highest fd number, and it has no FD_SETSIZE ceiling. The per-call install-and-teardown of wait-queue entries is identical, and so is the O(n) character.
Now contrast with epoll, whose costs decompose completely differently:
select | poll | epoll | |
|---|---|---|---|
| Registration | rebuilt every call | rebuilt every call | once, at epoll_ctl(ADD) |
| Per-call kernel work | O(n) scan + O(n) wait-queue install/teardown | same | O(r), r = descriptors actually ready |
| Per-call userspace↔kernel copy | 3 bitmaps in and out | array of struct pollfd in and out | up to maxevents ready entries out only |
| State kept across calls | none | none | interest list (red-black tree) + ready list |
epoll_ctl cost | n/a | n/a | O(log N) rbtree lookup/insert |
| Descriptor ceiling | FD_SETSIZE (1024 in glibc) | none | /proc/sys/fs/epoll/max_user_watches |
| Timeout resolution | microseconds (timeval) | milliseconds | milliseconds; nanoseconds via epoll_pwait2 |
The three multiplexers by cost model. What it shows: the decisive row is “state kept across calls.” select and poll are stateless APIs — the kernel forgets everything between calls, so it must be told everything again, and the retelling is the O(n). epoll is stateful, so the O(n) work happens once per descriptor for the descriptor’s whole lifetime. The insight: this is why the win depends on the ratio of calls to registrations. A server with 50,000 long-lived connections calls epoll_wait millions of times against 50,000 epoll_ctl calls — an enormous win. A program that watches 5 descriptors and calls once is better served by poll, whose entire API is one call with no setup. epoll is not universally faster; it is faster when the watched set is large and stable.
flowchart LR subgraph SEL["select / poll — every call"] direction TB S1["copy fd sets in"] --> S2["walk ALL n descriptors"] S2 --> S3["vfs_poll each → install<br/>a wait-queue entry on each"] S3 --> S4["sleep"] S4 --> S5["wake: walk ALL n again"] S5 --> S6["poll_freewait: tear down<br/>ALL n wait-queue entries"] S6 --> S7["copy results out"] S7 -.->|"next call repeats<br/>every step"| S1 end subgraph EP["epoll — split across time"] direction TB E1["epoll_ctl(ADD) — ONCE per fd<br/>rbtree insert, install<br/>ep_poll_callback"]:::once E1 --> E2["... fd lives for hours ..."] E2 --> E3["readiness PUSHES the fd<br/>onto rdllist, no scan"] E3 --> E4["epoll_wait: drain rdllist<br/>O(ready), copy out"] E4 -.->|"next call does<br/>ONLY this box"| E4 end classDef once fill:#dfd,stroke:#3a3
Where the work happens, over time. What it shows: select/poll compress registration, scan, sleep, rescan, and teardown into every single call; epoll hoists registration out of the loop entirely, leaving only the drain. The insight: epoll does not make the O(n) work disappear — installing a wait-queue hook on each descriptor is still O(n) in total — it amortizes it over the descriptor’s lifetime instead of paying it per call. The same trick io_uring plays with IORING_REGISTER_FILES; “pay a fixed cost once instead of every time” is the recurring idea in this whole corner of the kernel.
The Three Syscalls and the Kernel Objects
epoll is three system calls operating on one anonymous-inode-backed file:
epoll_create1(flags)creates the epoll instance and returns a file descriptor for it. The only valid flag isEPOLL_CLOEXEC(sets close-on-exec). Infs/eventpoll.c,do_epoll_createallocates astruct eventpoll, grabs an unused fd, and backs it withanon_inode_getfile("[eventpoll]", &eventpoll_fops, ep, ...). The legacyepoll_create(size)does the same but ignoressizeentirely (it only checkssize > 0for historical reasons — the argument “has been disregarded since Linux 2.6.8” per epoll_create(2)):SYSCALL_DEFINE1(epoll_create, int, size) { if (size <= 0) return -EINVAL; return do_epoll_create(0); // size otherwise unused }epoll_ctl(epfd, op, fd, event)edits the interest list.opis one ofEPOLL_CTL_ADD(registerfd),EPOLL_CTL_MOD(change its event mask / user data), orEPOLL_CTL_DEL(deregister it) (epoll_ctl(2)). Theeventis astruct epoll_event { __poll_t events; __u64 data; }—eventsis the requested-events bitmask plus behavior flags,datais an opaque 64-bit cookie the kernel hands back verbatim (typically a pointer to the connection’s state).epoll_wait(epfd, events, maxevents, timeout)blocks until at least one watched fd is ready (or the millisecondtimeoutelapses), then fills the userspaceeventsarray with up tomaxeventsready descriptors and returns the count.epoll_pwait/epoll_pwait2add an atomic signal mask (the same race-free pattern asppoll;epoll_pwait2uses a nanosecondtimespec).
The kernel side, from fs/eventpoll.c:
struct eventpoll {
struct mutex mtx; // serializes ctl ops and the event-collection loop
wait_queue_head_t wq; // threads blocked in epoll_wait sleep here
wait_queue_head_t poll_wait; // for epoll-on-epoll nesting
struct list_head rdllist; // THE READY LIST
rwlock_t lock; // protects rdllist and ovflist
struct rb_root_cached rbr; // THE INTEREST LIST (red-black tree of epitems)
struct epitem *ovflist; // overflow chain used while delivering events
...
};Each watched descriptor is a struct epitem, the node stored in the red-black tree (rbr) and linked onto the ready list (rdllist) when it fires:
struct epitem {
union {
struct rb_node rbn; // node in the interest-list rbtree
struct rcu_head rcu;
};
struct list_head rdllink; // node on the eventpoll ready list
struct epoll_filefd ffd; // (file*, fd) this item watches
struct eppoll_entry *pwqlist; // the wait-queue hooks installed on the target
struct eventpoll *ep;
struct epoll_event event; // the user's requested events + data cookie
};The interest list is a red-black tree keyed on (file pointer, fd), so epoll_ctl ADD/MOD/DEL look up the existing item in O(log N) (ep_find / ep_rbtree_insert). The comment in the source is blunt about why a compact node matters: “there can be many thousands of these on a server and we do not want this to take another cache line.” The kernel enforces this with a compile-time assertion in eventpoll_init — BUILD_BUG_ON(sizeof(void *) <= 8 && sizeof(struct epitem) > 128) — so on any 64-bit build a struct epitem is guaranteed to fit in two 64-byte cache lines.
A third object completes the picture, and it is the one most explanations omit: struct eppoll_entry, the actual wait-queue entry that gets planted on the watched file. One epitem can own several of them, because a single file’s ->poll may call poll_wait more than once — a socket registers on separate read and write wait queues, and this is why the source comments warn that ep_poll_callback “can be called concurrently for the same @epi from different CPUs if poll table was inited with several wait queues entries.”
classDiagram class eventpoll { +struct mutex mtx +wait_queue_head_t wq «epoll_wait sleepers» +wait_queue_head_t poll_wait «for epoll-on-epoll» +struct list_head rdllist «THE READY LIST» +rwlock_t lock +struct rb_root_cached rbr «THE INTEREST LIST» +struct epitem* ovflist «overflow during delivery» +struct user_struct* user «for max_user_watches» } class epitem { +struct rb_node rbn «node in rbr» +struct list_head rdllink «node on rdllist» +struct epoll_filefd ffd «file ptr plus fd, the rbtree key» +struct eppoll_entry* pwqlist +struct eventpoll* ep +struct epoll_event event «mask + user cookie» +sizeof at most 128 bytes, BUILD_BUG_ON enforced } class eppoll_entry { +wait_queue_entry_t wait «func = ep_poll_callback» +wait_queue_head_t* whead «the TARGET's wait queue» +struct epitem* base +struct eppoll_entry* next } class target_file { +«socket, pipe, eventfd, timerfd...» +wait_queue_head_t «e.g. a socket sk_wq wait head» } eventpoll "1" *-- "N" epitem : rbr (rbtree, O(log N) lookup) eventpoll "1" o-- "0..N" epitem : rdllist (only the READY ones) epitem "1" *-- "1..N" eppoll_entry : pwqlist eppoll_entry "N" --> "1" target_file : installed on its wait queue
The three kernel objects and how they link. What it shows: every watched descriptor appears in the red-black tree exactly once as an epitem, but appears on the ready list only while it is ready; each epitem plants one or more eppoll_entry hooks onto the target file’s own wait queue. The insight to take: the same epitem is simultaneously a member of two different containers — a tree node (rbn) for lookup and a list node (rdllink) for delivery — which is why “is it in the interest list?” and “is it ready?” are independent questions answered by different fields. The 1..N on eppoll_entry is the detail that bites: a socket typically gets hooks on both its read and write wait queues, so the same readiness callback can fire on two CPUs at once for one descriptor, and the ready-list insertion is therefore done with a cmpxchg-based list_add_tail_lockless rather than a plain list operation.
Mechanical Walk-through: How Readiness Reaches the Ready List
This is the crux — the mechanism that makes epoll O(ready) instead of O(watched). It reuses the same ->poll/vfs_poll/poll_wait substrate that select use (described in detail there), but with a callback that persists across waits instead of a one-shot wakeup of the calling thread.
At ADD time, ep_insert builds the epitem, inserts it into the rbtree, then calls ep_item_poll(epi, &epq.pt, 1). The poll table’s queue-proc is ep_ptable_queue_proc, so when the target file’s ->poll method calls poll_wait(file, &sk->sk_wq->wait, pt), control lands in (fs/eventpoll.c):
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead, poll_table *pt)
{
struct ep_pqueue *epq = container_of(pt, struct ep_pqueue, pt);
struct epitem *epi = epq->epi;
struct eppoll_entry *pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL);
...
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback); // <-- the persistent callback
pwq->whead = whead;
pwq->base = epi;
if (epi->event.events & EPOLLEXCLUSIVE)
add_wait_queue_exclusive(whead, &pwq->wait);
else
add_wait_queue(whead, &pwq->wait); // hook onto the fd's wait queue
pwq->next = epi->pwqlist;
epi->pwqlist = pwq;
}This installs ep_poll_callback as a wait-queue entry on the target descriptor’s own wait-queue head (for a socket, the socket’s wait queue — see Socket Wait Queues and Wakeups). Unlike select/poll’s __pollwait entry, this hook is not torn down when a wait returns; it lives until EPOLL_CTL_DEL or the fd is closed. Registration is paid once.
When the descriptor becomes ready — e.g. the network stack queues a packet onto a socket and calls the socket’s sk_data_ready — the kernel walks that wait-queue head and invokes ep_poll_callback. For a TCP/UDP socket the default sk_data_ready is sock_def_readable (installed by sock_init_data in net/core/sock.c, Linux 6.12), and it passes a poll-mask key to the wakeup:
void sock_def_readable(struct sock *sk)
{
...
if (skwq_has_sleeper(wq))
wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI |
EPOLLRDNORM | EPOLLRDBAND); // key = what's ready
...
}That key argument is exactly why ep_poll_callback below calls key_to_poll(key) and can cheaply filter out events the user did not request without re-polling the file:
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
__poll_t pollflags = key_to_poll(key);
...
read_lock_irqsave(&ep->lock, flags);
...
if (pollflags && !(pollflags & epi->event.events)) // not an event we asked for
goto out_unlock;
...
if (!ep_is_linked(epi)) {
/* In the usual case, add event to ready list. */
if (list_add_tail_lockless(&epi->rdllink, &ep->rdllist)) // PUSH onto ready list
ep_pm_stay_awake_rcu(epi);
}
if (waitqueue_active(&ep->wq)) {
...
wake_up(&ep->wq); // wake any thread blocked in epoll_wait
}
...
}The callback does three cheap things: confirm the firing event is one the user actually requested, append the epitem to ep->rdllist (the ready list), and wake any thread sleeping in epoll_wait (ep->wq). No scanning of other descriptors occurs. This is the inversion: readiness flows to a list instead of being searched for. (The EP_PRIVATE_BITS early-exit at the top of the callback — if (!(epi->event.events & ~EP_PRIVATE_BITS)) goto out_unlock; — is how EPOLLONESHOT disables a descriptor; covered below.)
At epoll_wait time, ep_poll checks ep_events_available(ep) (is the ready list non-empty?). If so it calls ep_send_events; if not it sleeps on ep->wq via schedule_hrtimeout_range until the callback wakes it or the timeout fires. ep_send_events is where events are delivered — and, importantly, epoll is not a pure callback system: it re-confirms readiness by calling ->poll again on each ready item (fs/eventpoll.c):
list_for_each_entry_safe(epi, tmp, &txlist, rdllink) {
...
list_del_init(&epi->rdllink);
revents = ep_item_poll(epi, &pt, 1); // RE-POLL the ready item
if (!revents)
continue; // spurious: was on list but not actually ready
events = epoll_put_uevent(revents, epi->event.data, events); // copy to userspace
...
res++;
if (epi->event.events & EPOLLONESHOT)
epi->event.events &= EP_PRIVATE_BITS; // ONESHOT: disable until re-armed
else if (!(epi->event.events & EPOLLET)) {
/* Level-triggered: re-insert so the next epoll_wait re-checks it. */
list_add_tail(&epi->rdllink, &ep->rdllist);
}
}Two things to read precisely here. First, the re-poll (ep_item_poll) means a descriptor that was queued but is no longer ready (!revents) is silently dropped — the source of epoll’s occasional “spurious wakeup” where epoll_wait returns an event that, by the time you read, no longer applies. Second, the else if (!(epi->event.events & EPOLLET)) branch is the level-triggered re-arm: a level-triggered descriptor that is still ready is put back onto the ready list, so the very next epoll_wait reports it again. An edge-triggered (EPOLLET) descriptor takes neither branch — it leaves the ready list and is not reported again until ep_poll_callback pushes it back on a new readiness transition.
To collect the ready list safely while delivering to userspace (which can sleep on a fault), ep_start_scan “steals” the whole rdllist into a private txlist and points ep->ovflist at the live chain; any ep_poll_callback firing during delivery appends to ovflist instead, and ep_done_scan splices it back. This is the ovflist machinery referenced in the struct. The callback shows both branches explicitly:
if (READ_ONCE(ep->ovflist) != EP_UNACTIVE_PTR) { /* delivery in progress */
if (chain_epi_lockless(epi)) /* → park on ovflist */
ep_pm_stay_awake_rcu(epi);
} else if (!ep_is_linked(epi)) {
/* In the usual case, add event to ready list. */
if (list_add_tail_lockless(&epi->rdllink, &ep->rdllist))
ep_pm_stay_awake_rcu(epi);
}Putting the whole path end to end — from a packet arriving on the wire to a thread returning from epoll_wait — makes the division of labour visible:
sequenceDiagram autonumber participant NIC as NIC / softirq participant SK as struct sock<br/>(sk_wq.wait) participant CB as ep_poll_callback participant EP as struct eventpoll<br/>(rdllist, wq) participant T as Thread in epoll_wait Note over T,EP: SETUP, once per fd T->>EP: epoll_ctl(ADD, fd) → ep_insert EP->>EP: ep_rbtree_insert(epi) into rbr EP->>SK: ep_item_poll → vfs_poll → sk.poll<br/>→ poll_wait → ep_ptable_queue_proc SK->>SK: add_wait_queue(sk_wq.wait, &pwq.wait)<br/>with .func = ep_poll_callback Note right of SK: the hook PERSISTS until<br/>EPOLL_CTL_DEL or close Note over T,EP: STEADY STATE — repeated forever, no re-registration T->>EP: epoll_wait → ep_poll EP->>EP: ep_events_available()? no EP->>EP: __add_wait_queue_exclusive(&ep.wq, &wait) EP->>T: schedule_hrtimeout_range() — thread sleeps NIC->>SK: packet queued → sk_data_ready = sock_def_readable SK->>CB: wake_up_interruptible_sync_poll(&wq.wait,<br/>key = EPOLLIN + EPOLLPRI + EPOLLRDNORM + EPOLLRDBAND) CB->>CB: key_to_poll(key) — does it match epi.event.events? CB->>EP: list_add_tail_lockless(&epi.rdllink, &ep.rdllist) CB->>EP: wake_up(&ep.wq) — wakes ONE exclusive waiter EP->>T: thread runs T->>EP: ep_send_events → ep_start_scan steals rdllist into txlist EP->>SK: ep_item_poll(epi) — RE-POLL to confirm still ready EP->>T: epoll_put_uevent(revents, epi.event.data, events) EP->>EP: ep_done_scan — splice ovflist back — LT items re-queued
The full wakeup path, from softirq to userspace. What it shows: the setup block (steps 1–4) runs once per descriptor and plants a persistent hook; the steady-state block runs on every event and never touches any descriptor other than the one that fired. The insight to take: the key passed to wake_up_interruptible_sync_poll at step 10 is the piece that makes the callback cheap — it carries what became ready, so ep_poll_callback can reject an irrelevant event with a bitmask test instead of calling back into the file’s ->poll method. Note also step 16: ep_send_events re-polls the item before delivering it. epoll is therefore not a pure callback system — the callback decides candidacy and the re-poll decides truth, which is exactly why a candidate that went un-ready in the meantime is silently dropped and why “spurious wakeup” is a normal, expected outcome rather than a bug.
Level-Triggered vs Edge-Triggered
This is the single most consequential epoll concept. Both flags select when a descriptor is reported as ready, not what counts as ready.
-
Level-triggered (the default). As long as the condition holds (data is buffered, the socket is writable), every
epoll_waitreports it. The man page: in this mode “epoll is simply a faster poll(2), and can be used wherever the latter is used since it shares the same semantics” (epoll(7)). Mechanically, the re-arm above re-queues a still-ready fd each time. You may read part of a buffer and stop; the nextepoll_waitwill tell you again that there is more. -
Edge-triggered (
EPOLLET). A descriptor is reported only on the transition from not-ready to ready. The man page warns: after a partial read leaves data in the buffer, “subsequentepoll_wait()calls will not notify until new data arrives, potentially causing indefinite blocking.” Because of this, edge-triggered usage has two strict rules (epoll(7)): “(1) with nonblocking file descriptors; and (2) by waiting for an event only after read(2) or write(2) return EAGAIN.” That is — on each event you must drain the descriptor in a loop untilEAGAIN, because you will get no further edge for the bytes already buffered.
The State Machine
The clearest way to settle level- versus edge-triggered is to draw the descriptor’s reporting state and label every transition with the code that causes it. Both modes share the same “is data present?” condition; they differ only in what the delivery loop does after reporting.
stateDiagram-v2 [*] --> NotReady: epoll_ctl(ADD) NotReady: NOT READY<br/>not on rdllist<br/>epoll_wait does not report it OnReadyList: ON READY LIST<br/>epi.rdllink linked into ep.rdllist<br/>next epoll_wait will consider it Reporting: BEING DELIVERED<br/>inside ep_send_events —<br/>removed from rdllist, re-polled Disabled: DISABLED (EPOLLONESHOT)<br/>epi.event.events masked down to EP_PRIVATE_BITS<br/>callback early-exits, no events at all NotReady --> OnReadyList: ep_poll_callback fires<br/>(an EDGE — data arrived)<br/>list_add_tail_lockless(rdllink, rdllist) OnReadyList --> Reporting: epoll_wait → ep_send_events<br/>list_del_init on epi.rdllink Reporting --> NotReady: re-poll returned 0 (revents == 0)<br/>SPURIOUS — silently dropped Reporting --> Disabled: EPOLLONESHOT set<br/>mask cleared after delivery Reporting --> OnReadyList: LEVEL-TRIGGERED and still ready<br/>else-if branch — not EPOLLET<br/>list_add_tail(rdllink, rdllist) — RE-QUEUED Reporting --> NotReady: EDGE-TRIGGERED (EPOLLET)<br/>NOT re-queued — waits for the next edge Disabled --> NotReady: epoll_ctl(EPOLL_CTL_MOD)<br/>re-arms the event mask NotReady --> [*]: epoll_ctl(DEL) or last close
Level- versus edge-triggered as one state machine. What it shows: every mode is a different exit edge from the same Reporting state, and each edge is a literal branch in ep_send_events. Level-triggered takes the loop back to OnReadyList (re-queued, so it is reported again next call); edge-triggered falls through to NotReady and waits for ep_poll_callback to push it back on a genuinely new event; EPOLLONESHOT goes to Disabled and needs an explicit EPOLL_CTL_MOD to come back. The insight to take: level-triggered does not mean “the kernel keeps checking whether data is present.” Nothing polls anything. It means the delivery loop puts the item back on the ready list, and the next epoll_wait re-polls it and finds it still ready. That is why a level-triggered fd you never drain produces a wakeup on every single epoll_wait — you are not being re-notified of new data, you are being handed the same re-queued item forever.
The Partial-Read Stall, Worked Step by Step
The epoll(7) man page gives a five-step scenario that is worth walking through with the state machine in hand, because it is the single most common epoll bug and the reason edge-triggered mode is dangerous in careless hands. rfd is the read end of a pipe:
| Step | Action | Kernel-side effect | Fd state (ET) | Fd state (LT) |
|---|---|---|---|---|
| 1 | rfd registered on the epoll instance | ep_insert; hook planted on the pipe’s wait queue | NOT READY | NOT READY |
| 2 | Writer writes 2 kB to the pipe | ep_poll_callback fires — an edge | ON READY LIST | ON READY LIST |
| 3 | epoll_wait returns rfd | ep_send_events delivers, removes from rdllist | — reported, then NOT READY (no re-queue) | — reported, then re-queued (still 2 kB buffered) |
| 4 | Reader reads 1 kB — a partial read | 1 kB remains buffered. No new data arrived, so no new edge. | NOT READY | ON READY LIST |
| 5 | epoll_wait called again | ET: nothing on rdllist for rfd → blocks indefinitely | HANGS | returns rfd again immediately |
The man page’s five-step scenario, resolved against the state machine. What it shows: steps 1–4 are identical in both modes; the entire difference is the step-3 exit edge, and it only becomes visible at step 5. The insight: the man page’s wording is exact — “the call to epoll_wait(2) done in step 5 will probably hang despite the available data still present in the file input buffer; meanwhile the remote peer might be expecting a response based on the data it already sent” (epoll(7)). Note the failure mode: not a crash, not an error, but a deadlock between two correct-looking programs. Your reader waits for an edge that will never come; the peer waits for a reply that will never be sent. Under light testing the buffer is usually drained by the first read and the bug never appears, which is exactly why it reaches production.
The fix is the man page’s two rules, and they are not independent — rule (2) is unimplementable without rule (1):
(1) with nonblocking file descriptors; and (2) by waiting for an event only after
read(2)orwrite(2)returnEAGAIN.
Concretely, every edge-triggered handler must look like this:
/* EDGE-TRIGGERED: drain until EAGAIN, or you will stall. */
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) {
consume(buf, n);
continue; /* there may be more — keep going */
}
if (n == 0) { close_connection(fd); break; } /* peer sent FIN */
if (errno == EINTR) continue; /* retry */
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; /* NOW it is safe to wait for the next edge */
handle_error(fd); break;
}The EAGAIN return is the only reliable signal that the buffer is empty and therefore that a future arrival will generate a new edge. A short read is not such a signal — read may return fewer bytes than requested for reasons unrelated to the buffer being empty. This is also why rule (1) is mandatory: on a blocking descriptor the loop above would never see EAGAIN; it would simply block inside read forever, stalling every other connection this thread was responsible for. The man page says this directly: an EPOLLET application “should use nonblocking file descriptors to avoid having a blocking read or write starve a task that is handling multiple file descriptors.”
There is a second, subtler edge-triggered hazard the man page also flags: starvation. If one connection has a continuous flood of data, the drain-until-EAGAIN loop may never terminate, and the thread never returns to epoll_wait to service anyone else. The standard mitigation is to cap the loop — read at most k times or b bytes, then, because you have not reached EAGAIN, push the descriptor onto your own application-level ready queue rather than relying on epoll to tell you again. Edge-triggered mode gives you speed by making you the bookkeeper.
Edge-triggered mode is the high-performance choice (fewer wakeups, fewer epoll_wait returns), but it shifts the burden of “fully drain” onto the application.
It is exactly the mode the Go runtime netpoller arms its descriptors with (EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET) — Go’s internal/poll retry-until-EAGAIN loop is the userspace half of rule (2). See Network Poller for that integration; the point here is that Go is a consumer of exactly this kernel behavior.
EPOLLONESHOT and EPOLLEXCLUSIVE — Taming Wakeups
Two flags address multi-threaded servers where several threads call epoll_wait on a shared epoll fd.
EPOLLONESHOT (Linux 2.6.2+) makes a descriptor fire once, then disable itself until re-armed. The man page: after the event, “the file descriptor is disabled in the interest list and no other events will be reported… Requires explicit rearmament via epoll_ctl(EPOLL_CTL_MOD)” (epoll(7), epoll_ctl(2)). Mechanically, ep_send_events does epi->event.events &= EP_PRIVATE_BITS, masking off all real event bits; the next ep_poll_callback then hits if (!(epi->event.events & ~EP_PRIVATE_BITS)) goto out_unlock and ignores the descriptor. This guarantees that only one thread ever handles a given readiness event for that fd — the canonical pattern for a thread pool where each connection must be processed by one worker at a time. The worker re-arms with EPOLL_CTL_MOD when done.
EPOLLEXCLUSIVE — and Exactly Which Thundering Herd It Fixes
Correction to a widespread claim
It is very commonly written — and an earlier revision of this note said so too — that “many threads blocked in
epoll_waiton the same epoll fd are all woken by one event, andEPOLLEXCLUSIVEfixes it.” That is wrong on both halves. The v6.12 source showsep_pollenqueuing each sleeping thread with__add_wait_queue_exclusive(&ep->wq, &wait), so a plainwake_up(&ep->wq)— which passesnr_exclusive = 1— wakes exactly one thread already, with or withoutEPOLLEXCLUSIVE. AndEPOLLEXCLUSIVEis not about threads on one epoll fd at all:epoll_ctl(2)scopes it to “when multiple epoll file descriptors are attached to the same target file.” The two situations are genuinely different problems with different fixes, so it is worth separating them carefully.
Topology A — N threads, one epoll instance. Every thread that blocks in epoll_wait is added to ep->wq as an exclusive waiter, so one event wakes one thread. There is no herd here in the classic sense. There is, however, a related effect that is easy to mistake for one, and it is level-triggered specific. When ep_send_events delivers a level-triggered item it re-queues it (list_add_tail(&epi->rdllink, &ep->rdllist)), and then ep_done_scan ends with:
if (!list_empty(&ep->rdllist)) {
if (waitqueue_active(&ep->wq))
wake_up(&ep->wq); /* ready list still non-empty → wake ANOTHER thread */
}So thread 1 takes the event, re-queues the still-ready listener, and explicitly wakes thread 2, which also sees the listener, calls accept, and gets EAGAIN because thread 1 already took the connection. That is a genuine wasted-wakeup chain — and it is precisely why epoll(7)’s single-wake guarantee is worded for the edge-triggered case: “If multiple threads (or processes…) are blocked in epoll_wait(2) waiting on the same epoll file descriptor and a file descriptor in the interest list that is marked for edge-triggered (EPOLLET) notification becomes ready, just one of the threads (or processes) is awoken” (epoll(7)). With EPOLLET there is no re-queue, so there is no chain wake. The fix for topology A is EPOLLET (or EPOLLONESHOT), not EPOLLEXCLUSIVE.
Topology B — N processes or threads, N separate epoll instances, all watching the same listening socket. This is the classic pre-fork server shape: nginx workers, or any design where each worker owns its own event loop and the listener is inherited across fork. Here each epoll instance has planted its own eppoll_entry on the socket’s wait queue, and by default those hooks are added with plain add_wait_queue — non-exclusive. One incoming connection walks the whole wait queue and fires every instance’s ep_poll_callback, so all N workers wake and race to accept. epoll_ctl(2) states the default plainly: “The default in this scenario (when EPOLLEXCLUSIVE is not set) is for all epoll file descriptors to receive an event.” This is the herd EPOLLEXCLUSIVE was built for.
The mechanism is one line in ep_ptable_queue_proc:
if (epi->event.events & EPOLLEXCLUSIVE)
add_wait_queue_exclusive(whead, &pwq->wait); /* only one hook fires */
else
add_wait_queue(whead, &pwq->wait); /* every hook fires */flowchart TB subgraph A["TOPOLOGY A — N threads, ONE epoll instance"] direction TB SKA["listening socket<br/>wait queue"] -->|"ONE eppoll_entry"| EPA["struct eventpoll<br/>rdllist + wq"] EPA -->|"__add_wait_queue_exclusive<br/>→ wake_up() wakes ONE"| TA1["thread 1 ✅ accepts"] EPA -.->|"LEVEL-TRIGGERED ONLY:<br/>ep_done_scan sees rdllist<br/>non-empty → wake_up again"| TA2["thread 2 ⚠️ accept → EAGAIN"] EPA -.-> TA3["thread 3 (idle)"] end subgraph B["TOPOLOGY B — N workers, N SEPARATE epoll instances"] direction TB SKB["listening socket<br/>wait queue"] -->|"eppoll_entry #1"| EPB1["eventpoll 1"] SKB -->|"eppoll_entry #2"| EPB2["eventpoll 2"] SKB -->|"eppoll_entry #3"| EPB3["eventpoll 3"] EPB1 --> W1["worker 1 ✅ accepts"] EPB2 --> W2["worker 2 ❌ EAGAIN"] EPB3 --> W3["worker 3 ❌ EAGAIN"] end A -.->|"fix: EPOLLET<br/>(or EPOLLONESHOT)"| FIXA["one wake, no chain"]:::ok B -.->|"fix: EPOLLEXCLUSIVE<br/>add_wait_queue_exclusive"| FIXB["'one or more' wake,<br/>not all N"]:::ok classDef ok fill:#dfd,stroke:#3a3
The two topologies that both get called “thundering herd,” and the different flag each needs. What it shows: in topology A the socket’s wait queue holds a single hook and the fan-out happens inside one eventpoll’s own wait queue, which is already exclusive; in topology B the socket’s wait queue holds N hooks, one per epoll instance, and by default every one of them fires. The insight to take: matching flag to topology matters because the wrong one is a no-op. Adding EPOLLEXCLUSIVE to a single-instance multi-threaded server changes nothing — there is only one hook on the socket to make exclusive. Conversely EPOLLET does not help topology B at all, because the problem is not re-queueing, it is N independent callbacks.
Two further cautions about EPOLLEXCLUSIVE that the man page states and that people routinely miss:
- It guarantees “one or more,” not “one.” The wording is exact: “one or more of the epoll file descriptors will receive an event.” The
ep_poll_callbackreturn valueewakeis what drives this — withEPOLLEXCLUSIVEset, the callback returns 1 only when the firing event matches the requestedEPOLLIN/EPOLLOUTbit, and returning 0 causes__wake_up_commonto keep walking to the next exclusive waiter. So a non-matching event does not consume the wakeup, and more than one instance may end up woken. You must still handleacceptreturningEAGAIN. - Mixing is not additive. “If the same file descriptor is in multiple epoll instances, some with the
EPOLLEXCLUSIVEflag, and others without, then events will be provided to all epoll instances that did not specifyEPOLLEXCLUSIVE, and at least one of the instances that did.” One worker forgetting the flag re-creates the herd for itself.
Uncertain
Verify: the claim that
EPOLLEXCLUSIVEcan wake more than one epoll instance becauseep_poll_callbackreturnsewake = 0for a non-matching event, causing__wake_up_commonto continue to the next exclusive waiter. Reason: the outcome is documented —epoll_ctl(2)says “one or more of the epoll file descriptors will receive an event” — but the mechanism stated here is my own reading ofep_poll_callback’s return value at v6.12 together with the generic__wake_up_commoncontract, not something any primary source spells out end to end.kernel/sched/wait.cwas not read during this research. To resolve: read__wake_up_commoninkernel/sched/wait.cat v6.12 and confirm that a wake function returning 0 does not decrementnr_exclusive. uncertain
Three hard restrictions, all visible in do_epoll_ctl, all returning EINVAL:
EPOLLEXCLUSIVE may be used only with EPOLL_CTL_ADD (and a later EPOLL_CTL_MOD on that same (epfd, fd) pair also fails), it may not target another epoll instance, and only EPOLLIN, EPOLLOUT, EPOLLWAKEUP, EPOLLET (plus the always-reported EPOLLERR/EPOLLHUP) may accompany it — the EPOLLEXCLUSIVE_OK_BITS mask:
if (ep_op_has_event(op) && (epds->events & EPOLLEXCLUSIVE)) {
if (op == EPOLL_CTL_MOD)
goto error_tgt_fput; // no MOD with EXCLUSIVE
if (op == EPOLL_CTL_ADD && (is_file_epoll(fd_file(tf)) ||
(epds->events & ~EPOLLEXCLUSIVE_OK_BITS)))
goto error_tgt_fput; // no nesting, restricted bits
}SO_REUSEPORT — The Other Answer to the Multi-Threaded Accept Problem
EPOLLEXCLUSIVE reduces the wakeups in topology B, but it does not change the underlying architecture: there is still one listening socket, one accept queue, and N workers contending on it. SO_REUSEPORT attacks the problem one level down — it lets each worker have its own listening socket bound to the same port, and moves the load distribution into the kernel’s connection-demultiplexing path where there is no contention to resolve at all.
The option landed in Linux 3.9, implemented by Tom Herbert, and Michael Kerrisk’s write-up is the canonical description (The SO_REUSEPORT socket option, LWN, 2013-03-13). Usage is three lines, and the order matters:
int sfd = socket(domain, socktype, 0);
int optval = 1;
setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval)); /* BEFORE bind */
bind(sfd, (struct sockaddr *) &addr, addrlen);Two safety rules are baked in. The first binder must set the option, “which prevents port hijacking — the possibility that a rogue application binds to a port already used by an existing server in order to capture (some of) its incoming connections or datagrams.” And every later binder “must have an effective user ID that matches the effective user ID used to perform the first bind on the socket” (socket(7) documents the option; the rationale is Kerrisk’s).
The reason this is better than fixing the wakeups is a load-balancing result, not a wakeup-count result. Kerrisk records the motivating measurement: with N threads all blocked in accept() on one shared socket, “wake-ups are not fair, so that, under high load, incoming connections may be distributed across threads in a very unbalanced fashion. At Google, they have seen a factor-of-three difference between the thread accepting the most connections and the thread accepting the fewest; that sort of imbalance can lead to underutilization of CPU cores.” SO_REUSEPORT, by contrast, “distributes connections evenly across all of the threads (or processes).”
The distribution is by hash of the connection 4-tuple — peer IP, peer port, local IP, local port — visible in reuseport_select_sock_by_hash in net/core/sock_reuseport.c at v6.12, which starts at reciprocal_scale(hash, num_socks) and walks forward to the first usable socket. Kerrisk notes the practical consequence: “if a client uses the same socket to send a series of datagrams to the server port, then those datagrams will all be directed to the same receiving server (as long as it continues to exist). This eases the task of conducting stateful conversations.” Modern kernels layer more on top — the v6.12 code also prefers a socket whose sk_incoming_cpu matches the receiving CPU, and an optional BPF program (reuse->prog) can override the selection entirely.
flowchart TB subgraph SHARED["ONE listening socket — EPOLLEXCLUSIVE at best"] direction TB C1["incoming SYN"] --> AQ["single accept queue<br/>(contended)"] AQ --> WQ["socket wait queue<br/>N epoll hooks"] WQ --> P1["worker 1"] WQ --> P2["worker 2"] WQ --> P3["worker 3"] P1 -.->|"unfair wake-ups:<br/>up to 3x imbalance<br/>(Google, per Kerrisk)"| IMB["skewed load"]:::bad P2 -.-> IMB P3 -.-> IMB end subgraph REUSE["SO_REUSEPORT — N listening sockets, same port"] direction TB C2["incoming SYN"] --> H["hash(4-tuple)<br/>reciprocal_scale(hash, num_socks)<br/>(+ incoming-CPU affinity, or a BPF prog)"] H --> S1["socket 1<br/>own accept queue"] --> R1["worker 1"] H --> S2["socket 2<br/>own accept queue"] --> R2["worker 2"] H --> S3["socket 3<br/>own accept queue"] --> R3["worker 3"] R1 -.->|"even distribution;<br/>same client → same worker"| BAL["balanced load"]:::good R2 -.-> BAL R3 -.-> BAL end classDef bad fill:#fdd,stroke:#c33 classDef good fill:#dfd,stroke:#3a3
Shared-listener versus SO_REUSEPORT. What it shows: the shared design resolves contention after the connection has been queued, by choosing which sleeper to wake; SO_REUSEPORT resolves it before, by choosing which socket the connection is queued on in the first place. The insight to take: these fix different things and are complementary rather than alternatives. EPOLLEXCLUSIVE reduces wasted wakeups; SO_REUSEPORT fixes load imbalance and removes accept-queue contention. A design that uses SO_REUSEPORT needs no EPOLLEXCLUSIVE at all, because each worker’s epoll instance watches a socket that no other worker is watching — the herd cannot form. nginx exposes exactly this as listen ... reuseport, documented as creating “an individual listening socket for each worker process (using the SO_REUSEPORT socket option on Linux 3.9+ …), allowing a kernel to distribute incoming connections between worker processes” (ngx_http_core_module).
There is one historical defect worth knowing because it still shapes deployment practice. Kerrisk’s 2013 article describes it: “If the number of listening sockets bound to a port changes because new servers are started or existing servers terminate, it is possible that incoming connections can be dropped during the three-way handshake. The problem is that connection requests are tied to a specific listening socket when the initial SYN packet is received during the handshake. If the number of servers bound to the port changes, then the SO_REUSEPORT logic might not route the final ACK of the handshake to the correct listening socket. In this case, the client connection will be reset.” This is why graceful-reload procedures for reuseport servers are more delicate than for shared-listener ones — the hash denominator changes the moment a worker’s socket leaves the group.
That defect was fixed in Linux 5.14, and the fix is worth knowing because it is opt-in. The kernel gained reuseport_migrate_sock() in net/core/sock_reuseport.c — verified absent at the v5.13 tag and present at v5.14 — whose job the source comment states as: “Select a socket from an SO_REUSEPORT group. @sk: close()ed or shutdown()ed socket in the group. @migrating_sk: ESTABLISHED/SYN_RECV full socket in the accept queue or NEW_SYN_RECV request socket during 3WHS.” In other words, exactly the two populations Kerrisk identified as being dropped. The behaviour is gated on the net.ipv4.tcp_migrate_req sysctl, documented in-tree at Documentation/networking/ip-sysctl.rst:
tcp_migrate_req- BOOLEAN. The incoming connection is tied to a specific listening socket when the initial SYN packet is received during the three-way handshake. When a listener is closed, in-flight request sockets during the handshake and established sockets in the accept queue are aborted. If the listener hasSO_REUSEPORTenabled, other listeners on the same port should have been able to accept such connections. This option makes it possible to migrate such child sockets to another listener afterclose()orshutdown().
The default is off, and the policy for choosing the destination listener is either random (“the kernel will randomly pick an alive listener only if this option is enabled”) or, for real control, a BPF_SK_REUSEPORT_SELECT_OR_MIGRATE eBPF program. The operational takeaway: if you run SO_REUSEPORT workers and do rolling reloads, set net.ipv4.tcp_migrate_req=1 on Linux 5.14+; otherwise the 2013 behaviour — connections reset mid-handshake when a worker exits — is still exactly what you get, thirteen years later, because the fix was never made the default.
Nesting epoll Instances and the Loop Check
An epoll file descriptor is itself pollable, so it can be added to another epoll instance’s interest list. This is genuinely useful — a library can hand you an epoll fd representing “all my sockets” and you fold it into your own event loop — but it creates two hazards the kernel must defend against: cycles (A watches B watches A) and unbounded recursion depth (delivering an event through a chain of nested instances recurses through ep_poll_safewake).
Both are handled at epoll_ctl(ADD) time. ep_loop_check walks the graph from the prospective target, and ep_loop_check_proc enforces both conditions at once:
if (ep_tovisit == inserting_into || depth > EP_MAX_NESTS)
error = -1; /* cycle, or too deep */
else
error = ep_loop_check_proc(ep_tovisit, depth + 1);with #define EP_MAX_NESTS 4 in fs/eventpoll.c. Either failure surfaces to userspace as ELOOP from epoll_ctl, which epoll_ctl(2) describes as “a circular loop of epoll instances… or nesting depth of epoll instances greater than 5.” (The off-by-one between the constant 4 and the documented 5 is the difference between counting edges and counting instances: EP_MAX_NESTS = 4 permits depth values 0 through 4, i.e. five instances in a chain.)
flowchart LR subgraph OK["ALLOWED — depth 0..4, five instances"] direction LR E0["ep0"] --> E1["ep1"] --> E2["ep2"] --> E3["ep3"] --> E4["ep4"] --> SOCK["socket"] end subgraph DEEP["REJECTED — depth 5"] direction LR D0["ep0"] --> D1["ep1"] --> D2["ep2"] --> D3["ep3"] --> D4["ep4"] --> D5["ep5"]:::bad D5 -.->|"depth > EP_MAX_NESTS<br/>epoll_ctl → ELOOP"| XD["rejected"]:::bad end subgraph CYC["REJECTED — cycle"] direction LR C0["epA"] --> C1["epB"] C1 -->|"epoll_ctl(epB, ADD, epA)"| C0 C1 -.->|"ep_tovisit == inserting_into<br/>epoll_ctl → ELOOP"| XC["rejected"]:::bad end classDef bad fill:#fdd,stroke:#c33
The two conditions ep_loop_check rejects. What it shows: a chain of up to five epoll instances is legal; a sixth, or any cycle however long, is refused at epoll_ctl(ADD) time with ELOOP. The insight to take: the check runs at registration, not at delivery — this is a deliberate design choice, because a cycle discovered during event delivery (in softirq context, holding locks) would be catastrophic, whereas one discovered in epoll_ctl is just an errno. It costs a graph walk on every ADD that targets an epoll fd, which is why the check is guarded to that case only. The same reason explains a restriction met earlier: EPOLLEXCLUSIVE may not target an epoll instance, because exclusive wakeup semantics through a nesting chain are not well defined.
Nesting has a further wrinkle worth flagging: ep->poll_wait (the second wait-queue head in struct eventpoll, distinct from ep->wq) exists solely for this case — it is where a parent epoll instance’s hook lives, and ep_poll_safewake is the recursion-limited wakeup used to propagate up the chain. A related constant, EPOLL_URING_WAKE (1U << 27, added in Linux 6.2 — verified absent from the v6.1 uapi header and present at v6.2), is described in the header as a wakeup “generated by io_uring, used to detect recursion back into the io_uring poll handler.” It is a direct acknowledgement in the source that the two subsystems in this note and its sibling can be wired into each other, and that doing so needs an explicit recursion guard.
The Honest Limit — epoll Does Not Work on Regular Files
This is the single most important thing epoll cannot do, and it is not a performance caveat but a hard refusal. epoll_ctl(EPOLL_CTL_ADD) on a regular file or a directory fails with EPERM. The check in do_epoll_ctl at v6.12 is two lines:
/* The target file descriptor must support poll */
error = -EPERM;
if (!file_can_poll(fd_file(tf)))
goto error_tgt_fput;file_can_poll() is simply file->f_op->poll != NULL — a regular file’s file_operations has no ->poll method, so the registration is rejected. epoll_ctl(2) documents the same thing under EPERM: “The target file fd does not support epoll. This error can occur if fd refers to, for example, a regular file or a directory.”
The reason is conceptual rather than an oversight. Readiness notification requires a wait queue that something can be woken from — a socket has one because data arrives asynchronously from the network stack; a pipe has one because a writer fills it. A regular file has no such queue, because there is no producer to wait for: the data is already on the disk. What you are actually waiting for when a file read is slow is a page-cache miss and the block I/O to satisfy it — an event that happens inside the read call, after you have already committed to it. There is no moment at which the kernel could truthfully say “this file is not ready yet.”
The practical consequence for server design is severe and is the reason epoll-based servers all look the same:
| Workload | epoll’s answer | What you actually have to build |
|---|---|---|
| Sockets, pipes, FIFOs, tty | fully supported | one event loop, one thread |
eventfd, timerfd, signalfd, inotify | fully supported | fold into the same loop |
| Another epoll instance | supported, depth ≤ 5 | fold into the same loop |
| Regular files | EPERM — cannot register | a separate thread pool doing blocking pread, with results handed back to the loop via eventfd |
open, stat, rename, fsync | not expressible at all — these are not “readiness” events | the same thread pool |
What epoll covers and what it leaves to you. What it shows: every row above the divider folds into a single event loop; every row below it requires a second, entirely different concurrency mechanism bolted alongside. The insight: this is why a “non-blocking” server that also touches the filesystem is really two servers — an event loop and a thread pool — glued together with an eventfd, and why the glue is where the bugs live. libuv is the canonical example: its network I/O is epoll and its file I/O is a thread pool, because there was no other option on Linux at the time. This gap is the strongest single argument for io_uring, whose completion model has no equivalent blind spot: an IORING_OP_READ on a regular file is exactly as asynchronous as one on a socket, and IORING_OP_OPENAT, IORING_OP_STATX, IORING_OP_FSYNC, and IORING_OP_RENAMEAT make filesystem metadata operations asynchronous too, which no readiness interface can express even in principle.
Complexity, Limits, and the C10K Win
Stating the complexity precisely (and not as a flat “O(1)”):
epoll_ctl(ADD/MOD/DEL): O(log N) — a red-black-tree lookup/insert in the interest list, where N is the number of watched descriptors.epoll_wait: O(R) where R is the number of ready descriptors delivered — it walks only the ready list, not the interest list. Independent of N.
Contrast with select: O(N) per call, because they re-register and re-scan all N descriptors every time. For a server with 50,000 connections where 100 are active per tick, epoll touches ~100 items; poll touches all 50,000. That is the C10K win: Dan Kegel’s page calls epoll “the recommended edge-triggered poll replacement for the 2.6 Linux kernel,” precisely because poll “does get slow about a few thousand, since most of the file descriptors are idle at any one time, and scanning through thousands of file descriptors takes time” (C10K).
Put concretely, for a server holding N connections of which R are active in a given tick:
| N (connections) | R (active this tick) | Items poll touches per call | Items epoll_wait touches per call | Ratio |
|---|---|---|---|---|
| 100 | 100 | 100 | 100 | 1× |
| 1,000 | 50 | 1,000 | 50 | 20× |
| 10,000 | 100 | 10,000 | 100 | 100× |
| 50,000 | 100 | 50,000 | 100 | 500× |
| 1,000,000 | 200 | 1,000,000 | 200 | 5,000× |
Descriptors examined per wait call, poll versus epoll. What it shows: poll’s column tracks N; epoll’s tracks R; the ratio is simply N/R. The insight to take: the win is entirely a function of the idle fraction. On the first row — every connection active every tick — epoll gives you nothing over poll and costs you an epoll_ctl per fd on top. The C10K regime is defined by the opposite: Dan Kegel’s page identifies exactly this, noting that poll “does get slow about a few thousand, since most of the file descriptors are idle at any one time, and scanning through thousands of file descriptors takes time” (C10K). Long-lived, mostly-idle connections — HTTP keep-alive, WebSockets, database pools — are precisely where the ratio explodes, and precisely what the modern web is made of. These are illustrative arithmetic, not measurements.
The resource limit is /proc/sys/fs/epoll/max_user_watches
— “a limit on the total number of file descriptors that a user can register across all epoll instances on the system. The limit is per real user ID. Each registered file descriptor costs roughly 90 bytes on a 32-bit kernel, and roughly 160 bytes on a 64-bit kernel. Currently, the default value for max_user_watches is 1/25 (4%) of the available low memory, divided by the registration cost in bytes” (epoll(7), available since Linux 2.6.28). Exceeding it makes epoll_ctl(ADD) return ENOSPC; ep_insert checks percpu_counter_compare(&ep->user->epoll_watches, max_user_watches) and returns -ENOSPC on overflow.
The default is computed once at boot in eventpoll_init, and the formula is worth walking symbol by symbol because it tells you the number to expect on your own machine:
si_meminfo(&si);
/*
* Allows top 4% of lomem to be allocated for epoll watches (per user).
*/
max_user_watches = (((si.totalram - si.totalhigh) / 25) << PAGE_SHIFT) / EP_ITEM_COST;si.totalram - si.totalhigh— total RAM minus high memory, in pages. On any 64-bit kerneltotalhighis 0, so this is simply all of RAM. (High memory is a 32-bit-only concept: memory above the ~896 MB the kernel can permanently map.)/ 25— take 4% of it. This is the “top 4% of lomem” the comment names.<< PAGE_SHIFT— convert pages to bytes (PAGE_SHIFTis 12 on x86-64, so multiply by 4096)./ EP_ITEM_COST— divide by the per-watch cost, defined asEP_ITEM_COST (sizeof(struct epitem) + sizeof(struct eppoll_entry)). This is where the man page’s “roughly 160 bytes on 64-bit” comes from.
So on a machine with 64 GiB of RAM: 64 GiB × 0.04 / 160 B ≈ 17.2 million watches — comfortably more than any realistic server needs, which is why this limit is normally invisible. It becomes visible on small machines and in containers: a 512 MiB VM yields roughly 512 MiB × 0.04 / 160 ≈ 134,000 watches, and note that the limit is per real UID across all epoll instances on the system, so co-tenant processes running as the same user share one budget. An ENOSPC from epoll_ctl(ADD) under those conditions is not a bug in your code.
Busy-Polling epoll — EPIOCSPARAMS
One modern addition changes the latency profile enough to be worth knowing. Since Linux 6.9 (verified: EPIOCSPARAMS absent from the v6.8 uapi header, present at v6.9), an epoll instance can be told to busy-poll the network device rather than sleeping, via an ioctl on the epoll fd:
struct epoll_params {
uint32_t busy_poll_usecs; /* microseconds to busy poll */
uint16_t busy_poll_budget; /* max packets per poll attempt */
uint8_t prefer_busy_poll; /* 0 or 1 */
uint8_t __pad; /* must be zero */
};
ioctl(epfd, EPIOCSPARAMS, ¶ms);Per ioctl_eventpoll(2): busy_poll_usecs is “the number of microseconds that the network stack will busy poll. During this time period, the network device will be polled repeatedly for packets.” busy_poll_budget is “the maximum number of packets that the network stack will retrieve on each poll attempt” and “cannot exceed NAPI_POLL_WEIGHT (which is 64 as of Linux 6.9), unless the process is run with CAP_NET_ADMIN” — the kernel enforces this in ep_eventpoll_bp_ioctl, returning -EPERM otherwise. prefer_busy_poll tells the stack that “busy poll is the preferred method of processing network data,” because “without this option, very busy systems may continue to do network processing via the normal method of IRQs triggering softIRQ and NAPI.”
Inside ep_poll, the call eavail = ep_busy_loop(ep, timed_out) runs before the thread is enqueued to sleep, and it drives napi_busy_loop(napi_id, ...) against the NAPI instance recorded by ep_set_busy_poll_napi_id(epi) at callback time. The insight: this is epoll borrowing io_uring’s IOPOLL trick — trade CPU for the removal of interrupt and scheduler latency — and it narrows, though does not close, the latency gap between the readiness and completion models for socket workloads. It does nothing at all for the file-I/O gap. See NAPI and Polled Receive for the polling machinery it sits on.
Failure Modes and Common Misunderstandings
“Edge-triggered, but my reads stall.” The classic EPOLLET bug: you read once, get some bytes, and return to epoll_wait — which never fires again because the remaining buffered bytes produced no new edge. You must loop reading until EAGAIN. This is rule (2) of edge-triggered usage and the most common epoll mistake.
“I closed the fd but still get events for it.” Closing a descriptor removes it from the interest list only after all descriptors referring to the same open file description are closed (epoll(7)). If you dup’d the fd (or it was inherited across fork), the epoll registration survives the close of one copy and keeps delivering events tagged with stale data. The man page’s fix: “explicitly remove from the interest list (using epoll_ctl(2) EPOLL_CTL_DEL) before it is duplicated.” This causes subtle use-after-free-style bugs in connection pools.
“Events were combined / I got fewer than I expected.” If multiple events occur on one fd between epoll_wait calls, “they will be combined” into a single reported event with the OR of the masks (epoll(7)). Your handler must inspect the full events bitmask (check EPOLLIN, EPOLLOUT, EPOLLHUP, EPOLLERR, EPOLLRDHUP independently), not assume one event per epoll_wait slot.
“Spurious wakeups.” Because ep_send_events re-polls each ready item and can find it no longer ready, and because epoll_wait can return for a condition another thread already consumed, code must treat a readiness report as “maybe ready” — always do the nonblocking I/O and handle EAGAIN. Never assume a returned event guarantees a successful read.
“EPOLLEXCLUSIVE didn’t stop my thundering herd.” Three distinct causes, and the fix differs for each. (1) Wrong topology. If your herd is N threads on one epoll instance, EPOLLEXCLUSIVE is a no-op — there is only one hook on the socket to make exclusive, and the threads were already exclusive waiters on ep->wq. The real cause there is the level-triggered re-queue chain-waking the next thread from ep_done_scan; the fix is EPOLLET or EPOLLONESHOT. (2) “One or more,” not “one.” Even in the right topology the guarantee is weaker than people expect: ep_poll_callback returns ewake = 0 for a non-matching event, which makes __wake_up_common continue to the next exclusive waiter, so multiple instances can still wake. Always handle accept returning EAGAIN. (3) Mixed registration. Per epoll_ctl(2), if some instances registered the listener with the flag and others without, “events will be provided to all epoll instances that did not specify EPOLLEXCLUSIVE” — one worker that forgot the flag re-creates the herd. Also note it cannot be added with EPOLL_CTL_MOD, and a later MOD on a pair that used it fails with EINVAL, so it must be right at ADD time.
“epoll_ctl returns EPERM on my log file.” Not a permissions problem, despite the errno. do_epoll_ctl rejects any target whose file_operations lacks a ->poll method (if (!file_can_poll(fd_file(tf)))), which is every regular file and directory. There is no flag or capability that changes this — readiness is not a meaningful concept for a regular file. Use a thread pool, or io_uring.
“epoll_ctl returns ELOOP.” You added an epoll fd to another epoll fd and created either a cycle or a chain more than five instances deep (EP_MAX_NESTS is 4, permitting depths 0–4). This is usually accidental — two libraries each folding the other’s event loop into their own.
“epoll_ctl returns ENOSPC and I have plenty of memory.” max_user_watches is per real UID across all epoll instances on the system, not per process and not per instance. In a container or on a small VM the default (4% of low memory divided by ~160 bytes) can be surprisingly low, and a co-tenant process running as the same user consumes the same budget. Read /proc/sys/fs/epoll/max_user_watches and raise it, or run as a different UID.
“My level-triggered loop burns 100% CPU.” A level-triggered descriptor that you report on but never drain is re-queued by ep_send_events on every delivery, so epoll_wait returns instantly, forever. The usual cause is watching EPOLLOUT on a socket that is (almost always) writable: the socket is writable, you have nothing to write, you return to epoll_wait, and it fires again immediately. The fix is to arm EPOLLOUT only while you actually have buffered output pending, and to EPOLL_CTL_MOD it off the moment your write buffer drains — or to use EPOLLET, where a writable socket produces one edge and then goes quiet.
“EPOLLERR/EPOLLHUP ignored.” Like poll, epoll reports these unconditionally — do_epoll_ctl OR-s EPOLLERR | EPOLLHUP into every ADD/MOD event mask. A handler that only watches EPOLLIN still receives error/hangup events and must handle them or risk a busy-loop on a dead socket.
Alternatives and When to Choose Them
select | poll | epoll | kqueue (BSD) | io_uring | |
|---|---|---|---|---|---|
| Model | readiness | readiness | readiness | readiness (+ some completion-like filters) | completion |
| Cost of one wait | O(n) watched | O(n) watched | O(r) ready | O(r) ready | O(r) ready |
| Registration | per call | per call | once (epoll_ctl) | batched via kevent changelist | once (io_uring_register), optional |
| Syscalls for N ready ops | N + 1 | N + 1 | N + 1 | N + 1 | 1, or 0 with SQPOLL |
| Registration and wait in one call | n/a | n/a | no — separate epoll_ctl and epoll_wait | yes — kevent() takes changes and returns events | yes — io_uring_enter submits and waits |
| Regular files | useless (always ready) | useless | EPERM | some support via EVFILT_VNODE (events, not reads) | fully async reads and writes |
| Filesystem metadata ops | no | no | no | no | yes (OPENAT, STATX, RENAMEAT, FSYNC…) |
| Edge-triggered | no | no | yes (EPOLLET) | yes (EV_CLEAR) | n/a (completion has no levels) |
| Portability | POSIX, everywhere | POSIX, everywhere | Linux only | BSD/macOS only | Linux 5.1+ only |
| Blocked by container policy | no | no | no | n/a | yes — Docker’s default seccomp profile |
| Descriptor ceiling | FD_SETSIZE = 1024 | none | max_user_watches | none | ring depth ≤ 32768 |
The multiplexing and async-I/O family on one grid. What it shows: epoll’s row is unremarkable except in two places — the “registration once” column, where it beats select/poll decisively, and the “regular files” column, where it is the only entry that returns an outright error. The insight: the two rows that most often decide a real choice are portability and container policy, not performance. epoll is Linux-only but is available everywhere Linux runs, including inside every default-configured container; io_uring is faster on paper but blocked by Docker’s default seccomp profile, so a portable server needs an epoll path regardless and io_uring becomes an opportunistic fast path rather than a replacement. kqueue’s “registration and wait in one call” column is worth noting as the design epoll arguably should have had — it removes a syscall per registration, which matters for short-lived connections where every accepted socket costs an epoll_ctl.
A few notes on when each is genuinely the right answer:
- select over epoll: for small or churning descriptor sets. With 3–10 fds,
poll’s O(n) is free and the API is simpler and portable; epoll’s per-fdepoll_ctlbookkeeping is pure overhead. epoll wins only when the set is large and mostly idle and stable across calls. The crossover is not a fixed number of descriptors but a ratio: epoll pays off once you callepoll_waitmany times perepoll_ctl. kqueue(BSD/macOS): the equivalent scalable mechanism; the C10K page notes it “supports both edge and level triggering.” Cross-platform event libraries (libevent,libev) abstract epoll/kqueue behind one API.- io_uring: a completion-based model rather than readiness-based — you submit the actual
recv/send/acceptand get told when it completed, not when it could start. Fewer syscalls than epoll under high op rates, and it handles file I/O (which epoll cannot poll meaningfully). epoll remains simpler and is still the default for most readiness-driven servers and for the Go runtime. - AF_XDP: bypasses the socket stack entirely for raw zero-copy frames — for line-rate packet processing where even epoll’s per-event cost is the bottleneck.
Production Notes
Uncertain
Verify: the specific claims in this section about the Go runtime’s netpoller — that it arms every network fd with
EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET, and that it registers aneventfdin the same epoll set as a self-wake. Reason: these are inherited from an earlier revision of this note and were not re-verified against Go source during this pass; the kernel-side facts here were all checked against Linux v6.12, butruntime/netpoll_epoll.gowas not read. Go’s runtime changes across releases, so this is also a point-in-time claim with no version attached. To resolve: readsrc/runtime/netpoll_epoll.goat a pinned Go tag viaraw.githubusercontent.com/golang/go/<tag>/src/runtime/netpoll_epoll.goand confirm the event mask innetpollopenand thenetpollBreakRd/eventfdusage. See Network Poller, which owns this topic. uncertain
epoll is the load-bearing I/O primitive of essentially every modern Linux network server
: nginx, HAProxy, Envoy, Redis, and the event loops of Node.js (via libuv), Netty, and Python’s asyncio all sit on it. The migration of high-scale servers from poll/select to epoll/kqueue in the 2000s was driven directly by C10K-style load measurements. The Go runtime is a particularly clean case study: it arms every network fd edge-triggered (EPOLLET), parks goroutines instead of threads, and uses an eventfd registered with the epoll set as a self-wake to break a blocking epoll_wait — a design that holds a million idle connections on a handful of OS threads, and a deliberate contrast to the thread-per-connection or hand-rolled-event-loop alternatives.
The two dominant production topologies are worth drawing side by side, because they represent genuinely different answers to the same question and both are correct.
flowchart TB subgraph GO["Go runtime — ONE epoll instance, M OS threads"] direction TB GEP["single netpoll epoll fd<br/>every socket armed EPOLLIN + EPOLLOUT + EPOLLRDHUP + EPOLLET"] GEV["eventfd registered in the same set<br/>= self-wake to break a blocking epoll_wait"] GEP --> GNP["netpoll: epoll_wait(non-blocking or timed)"] GEV --> GEP GNP --> GRUN["mark goroutines runnable<br/>(park the GOROUTINE, never the thread)"] GRUN --> GSCHED["scheduler runs them on any P"] GSCHED -.->|"1M idle conns on<br/>a handful of OS threads"| GWIN["memory per conn = a goroutine stack,<br/>not an OS thread stack"]:::good end subgraph NGX["nginx — N worker processes, N epoll instances"] direction TB NL["listen ... reuseport<br/>→ one listening socket PER worker"] NL --> NW1["worker 1: own epoll fd"] NL --> NW2["worker 2: own epoll fd"] NL --> NW3["worker 3: own epoll fd"] NW1 --> NE["each: epoll_wait → handle → epoll_ctl(MOD) as needed"] NW2 --> NE NW3 --> NE NE -.->|"kernel hashes the 4-tuple,<br/>no shared accept queue"| NWIN["no herd, even load,<br/>one CPU core per worker"]:::good end classDef good fill:#dfd,stroke:#3a3
The two production shapes. What it shows: Go multiplexes one epoll instance across many OS threads and solves the concurrency problem in userspace by parking goroutines; nginx gives each worker process its own epoll instance and its own SO_REUSEPORT listening socket, solving it in the kernel. The insight to take: neither design needs EPOLLEXCLUSIVE. Go does not, because it has a single instance (topology A) and uses EPOLLET, which suppresses the level-triggered chain wake. nginx does not, because reuseport gives each worker a private listener so no two epoll instances watch the same socket (topology B dissolved rather than mitigated). EPOLLEXCLUSIVE is the fix for the middle case — multiple instances sharing one listener — which is what you get with a pre-fork server that inherits a listening socket across fork and does not use SO_REUSEPORT.
Operationally, the knobs are few:
raise /proc/sys/fs/epoll/max_user_watches for servers holding very large fd counts, raise RLIMIT_NOFILE (the fd ceiling), and choose edge- vs level-triggered per workload (edge for max throughput with disciplined draining; level for simpler correctness). Diagnose with strace -e epoll_ctl,epoll_wait (you should see epoll_ctl once per connection at accept time and a tight epoll_wait loop — if you see epoll_ctl churning every iteration, the code is misusing epoll like poll), and watch epoll_wait return counts: persistently large counts on a level-triggered fd that the app under-drains indicate a wakeup storm. The recurring real-world bug remains the edge-triggered partial-read stall and the dup’d-fd stale-registration leak described above.
See Also
- io_uring as a Syscall Batching Mechanism — the completion-based counterpart to this note’s readiness model; it is the answer to the regular-file gap epoll structurally cannot cover, and the two notes are best read as a pair
- The poll and select Syscalls — the O(n)-per-call predecessors epoll replaced; defines the shared
->poll/vfs_poll/poll_waitsubstrate this note builds on - Socket Wait Queues and Wakeups — the per-socket wait queue that
ep_poll_callbackhooks onto, and thesk_data_readypath that fires it - Wakeups and try_to_wake_up — the kernel wake primitive that ultimately runs the callback
- Network Poller — the Go runtime’s edge-triggered-epoll integration; a real
EPOLLETconsumer (do not re-derive epoll there — it sits on top of this) - io_uring for Network IO — the completion-based alternative I/O model
- The Socket Layer — where the watched
struct socks and their wait queues live - Linux Networking Stack MOC — parent map (§11, epoll/poll/readiness)
- Linux System Call Interface MOC — the syscall-entry machinery and signal-mask handling for
epoll_pwait