Where guide 1 left us: watching many at once
Guide 1 ended on a cliffhanger. You learned to set a socket to non-blocking, so a read() that has no data returns immediately with errno set to EAGAIN instead of putting your thread to sleep. That solves one problem and creates another: if a single thread now juggles ten thousand connections, it cannot just spin in a loop calling read() on each one. Most of those calls would return EAGAIN, burning a whole CPU core asking "anything yet? anything yet?" ten thousand times a millisecond. You need to ask the kernel which of your file descriptors are ready, and sleep until at least one is.
That capability has a name: I/O multiplexing. The deal is simple and powerful. You hand the kernel a set of file descriptors and say "put me to sleep, and wake me the moment any of these can be read or written without blocking." One thread, one syscall, ten thousand connections, zero busy-waiting. This is the load-bearing idea behind every event loop you have ever used — nginx, Redis, Node.js, the async runtime in Rust or Go — and the next three guides build directly on it. This guide is about the mechanism itself: how the kernel watches, and the surprisingly sharp choice in how it tells you.
The old way: select and poll, and why they buckle
The first portable tools for this are select() and poll(), and they are worth understanding precisely because their flaw teaches you what epoll fixes. Both work the same shape: you build a list of every descriptor you care about, hand the entire list to the kernel on each call, the kernel scans all of them, marks the ready ones, and hands the whole list back. You then walk the list yourself to find the marks. It works, it is in POSIX, it runs everywhere — and it has a cost that grows with the number of connections, not with the amount of activity.
Sit with that cost, because it is the heart of the C10k problem from guide 1. Suppose 10,000 connections are open but only 5 have data this instant. With select() you still copy a 10,000-entry set into the kernel, the kernel still scans all 10,000, and you still walk all 10,000 looking for the 5 marks — every single call, every single time. That is O(n) work to discover O(1) events. Worse, plain select() has a hard ceiling at FD_SETSIZE descriptors, usually 1024, baked in at compile time. poll() removes that ceiling but keeps the linear copy-and-scan. At ten thousand connections this is the difference between a server that idles cool and one that melts a core doing bookkeeping.
The diagnosis points straight at the fix. The wasteful part is re-telling the kernel the whole set on every call, and re-scanning everything to find the few that fired. What if you registered your interest once, and the kernel kept its own running list of only the descriptors that actually became ready? Then a wait would return just the ready ones, and the cost would scale with activity, not with the total connection count. That is exactly the leap the next mechanisms make.
epoll and kqueue: register once, wait many
Linux's answer is epoll; the BSD and macOS answer is kqueue. They differ in spelling but share the winning idea, so learn the shape once. Instead of one call that does everything, you split the work into two phases. First you create a kernel object — epoll_create1() on Linux, kqueue() on BSD — that holds your interest set across calls. Then, once per connection, you register it: epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) tells the kernel "watch this fd for readability, and remember it." You pay that registration cost a single time, when the connection arrives.
The second phase is the hot loop. You call epoll_wait(epfd, events, maxevents, timeout) and it sleeps until something is ready, then returns only the ready descriptors, written into the events array you supplied. If 10,000 connections are registered but 5 fired, epoll_wait gives you exactly 5 entries — not 10,000 to sift through. No re-copying the interest set, no linear scan on your side. The cost of each wake-up now scales with the number of ready descriptors, which is the whole game. This is how a single thread comfortably holds 10,000, or 100,000, connections; it is the mechanical core of the C10k and C10M solutions.
Edge versus level: the choice that bites
Now the sharp part. When epoll reports a descriptor is readable, what exactly does "readable" mean? There are two answers, and choosing between them is the edge- versus level-triggered decision — the single most common source of mysterious event-loop bugs. The names come from electronics: level-triggered cares about the state of a signal (is it high right now?), while edge-triggered cares about a transition (did it just go from low to high?). epoll lets you pick per descriptor with the EPOLLET flag; the default is level-triggered.
Level-triggered is the forgiving one. As long as there is any unread data sitting in the socket buffer, every call to epoll_wait will keep reporting that descriptor as ready — over and over, until you have drained it. This matches your intuition and matches how select()/poll() always behaved. The practical upshot: you may read just some of the available bytes, handle them, and return to the loop; next time around, epoll cheerfully reminds you there is more. It is hard to lose an event. The price is a few extra wake-ups when you do not fully drain.
Edge-triggered (EPOLLET) is the efficient, unforgiving one. It reports a descriptor as ready only once per transition — only when new data arrives and flips the buffer from empty to non-empty. If you do not read everything in that one notification, epoll will not remind you. The leftover bytes sit there, unread, and epoll_wait stays silent about them because no new arrival has happened to create a fresh edge. Your connection appears to hang for no reason — the classic edge-triggered bug, and you will write it at least once.
level-triggered (default): edge-triggered (EPOLLET): arrives: 200 bytes arrives: 200 bytes epoll_wait -> READY epoll_wait -> READY (one edge) read 100, handle, loop read 100, handle, loop epoll_wait -> READY (still!) epoll_wait -> (silent, hangs!) read 100, handle, loop 100 bytes stuck, no new edge ever epoll_wait -> EAGAIN, done the edge-triggered rule: on each notification, read in a loop until read() returns EAGAIN, THEN go back to epoll_wait.
The edge-triggered contract: drain to EAGAIN
If edge-triggered is so easy to misuse, why does anyone choose it? Efficiency under load. Level-triggered can wake you repeatedly for the same backlog of data, and in a busy server those redundant wake-ups add up. Edge-triggered fires once per arrival, the bare minimum, which is why high-throughput servers and the async runtimes built on epoll usually run in edge mode. The trick is to honor its one strict rule, and that rule is short enough to memorize.
- Set the descriptor to non-blocking — this is mandatory, not optional, in edge mode (you are about to read in a loop and need the last read() to return cleanly instead of blocking).
- Register it with EPOLLET so epoll only notifies you on each new transition.
- When epoll_wait reports the descriptor readable, call read() repeatedly in a loop, consuming every byte it hands back.
- Keep looping until read() returns -1 with errno == EAGAIN (or EWOULDBLOCK) — that, and only that, means the buffer is now truly empty.
- Only then return to epoll_wait. Skipping the drain is the single bug that makes an edge-triggered connection silently stall.
Notice how this leans on something from guide 1: the drain loop only terminates because read() can return fewer bytes than asked and finally returns EAGAIN on an empty non-blocking socket. You must treat EAGAIN not as an error to log but as a normal, expected signal meaning "all drained, go back to sleep." And mirror this for writes: in edge mode, write what you can, and when write() returns EAGAIN because the send buffer filled, register for EPOLLOUT so epoll wakes you when there is room again — then resume. Forgetting the symmetric write side is the second-most-common edge bug.
Holding the shape
Step back and assemble the picture. Multiplexing solved the question guide 1 raised: one thread can watch ten thousand non-blocking sockets by asking the kernel which are ready. select() and poll() do this but re-copy and re-scan the whole set every call, so their cost grows with connections, not events — the C10k wall. epoll and kqueue break that wall by splitting registration from waiting: you declare interest once, and each wait returns only the descriptors that actually fired. All of this is still the readiness model — the kernel says "ready," you do the read.
And underneath the performance sits the one design choice you must never get wrong: level- versus edge-triggered. Level keeps reminding you while data remains; edge fires exactly once per arrival and demands that you drain every byte to EAGAIN before sleeping again. Carry these three things forward — register-once-wait-many, readiness-not-completion, and the edge-triggered drain contract — into guide 3, where you wrap this raw mechanism into a real event loop and meet the reactor pattern that organizes it all.