⏱️ Reading time: 14 min
A single-worker-process Nginx server can sustain tens of thousands of simultaneous connections without the CPU spiking. The secret isn’t magic or hidden threads: it’s epoll in Linux, the system call that tells the kernel to notify only when something changes, instead of forcing the application to ask socket by socket.
📑 En este artículo
Understanding the epoll interface explains why Redis, Node.js, and Nginx choose a single-thread model for data input and output, and why that design scales better than spawning a thread per connected client.
TL;DR
- Epoll in Linux lets a single thread watch thousands of sockets without looping through them one by one on every cycle.
- The epoll_wait() call returns only the descriptors with real activity, never the full list.
- Nginx, Redis, and Node.js’s libuv use epoll as their input/output engine on Linux.
- EPOLLET enables edge-triggered mode: you must drain the entire buffer or events get lost.
- select and poll loop through every descriptor on each call; epoll only checks a ready list.
What is epoll in Linux?
Epoll is the Linux kernel’s event notification interface for watching thousands of file descriptors without looping through them one by one. It’s the piece behind the epoll mechanism in servers like Nginx and Redis, which replaces constant polling with a list of ready descriptors.
The need arose in the early 2000s, when web servers started handling thousands of persistent connections and the old select() model fell short because of its 1024-descriptor limit and linear scan. Kernel 2.5.44, released in 2002, added epoll as a direct answer to that bottleneck, documented today in the epoll(7) man page.
Why it matters
The underlying reason is arithmetic. If a server handles 10,000 connections with select(), every time the program asks whether there’s anything new, the kernel has to check all 10,000 one by one, even if only three have data waiting. That cost grows with every new client and gets paid on every cycle of the program, not just once.
Think of it as the difference between checking the mailbox every five minutes, even though it’s empty most of the time, versus the mail carrier knocking on the door only when they actually left a letter. The first strategy wastes time checking for nothing; the second only interrupts when there’s something real to handle. Epoll applies that second logic to thousands of sockets at once, without the cost of watching each one growing with the total.
With the epoll interface, the kernel flips the problem around: instead of the application asking, the kernel itself keeps track of which sockets changed state in a ready list. When the program calls epoll_wait(), it receives that list directly, without looping through anything that hasn’t changed.
That difference is what lets Nginx serve thousands of clients with a handful of worker processes, Redis handle all its operations from a single thread without locks, and Node.js run single-threaded JavaScript without blocking while waiting for a network response.
How epoll works internally
The epoll lifecycle has three system calls. First, epoll_create1() creates an epoll instance and returns a file descriptor representing it; that descriptor shows up in /proc/<pid>/fd as anon_inode:[eventpoll], documented in epoll_create(2).
Second, epoll_ctl() adds, modifies, or removes descriptors from the instance using the EPOLL_CTL_ADD, EPOLL_CTL_MOD, and EPOLL_CTL_DEL operations. Internally, the kernel stores those descriptors in a red-black tree, so looking them up or removing them is fast even with tens of thousands of entries.
Third, epoll_wait() blocks the process until at least one descriptor has activity, and returns only those ready descriptors. That ready list lives separately from the tree of registered descriptors: when a socket receives data, the kernel’s network driver moves it to the ready list through a callback, without the application having to ask anything.
flowchart TD
A["epoll_create1()"] --> B["epoll_ctl: EPOLL_CTL_ADD"]
B --> C{"Does data arrive at the socket?"}
C -->|"No"| D["The kernel waits"]
D --> C
C -->|"Yes"| E["The kernel moves the fd to the ready list"]
E --> F["epoll_wait() returns the ready fds"]
F --> G["The application processes each fd"]
G --> B
sequenceDiagram
participant App as "Application"
participant K as "Kernel"
participant S as "Socket"
App->>K: epoll_ctl ADD fd
S->>K: data arrives
K->>K: moves fd to the ready list
App->>K: epoll_wait()
K-->>App: returns ready fd
App->>S: read(fd)
Note over App,K: the cycle repeats without looping through inactive fds
The practical result is that adding descriptor number 50,000 doesn’t slow down the epoll_wait() call for the other 49,999: only how many are actually ready at that moment matters, not how many are registered in total.
Practical examples and how to get started
To try these examples you need a real Linux machine (not macOS or Windows: epoll doesn’t exist outside the Linux kernel) with the gcc compiler installed. On Debian or Ubuntu, sudo apt install build-essential is enough.
Example 1: the simplest epoll program
This first program creates an epoll instance, registers standard input (descriptor 0), and waits for the user to type something:
#include <stdio.h>
#include <sys/epoll.h>
#include <unistd.h>
int main() {
int epfd = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = 0 };
epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &ev);
struct epoll_event events[1];
printf("Waiting for you to type something (Ctrl+D to exit)...\n");
while (epoll_wait(epfd, events, 1, -1) > 0) {
char buf[128];
int n = read(0, buf, sizeof(buf) - 1);
if (n <= 0) break;
buf[n] = '\0';
printf("Got: %s", buf);
}
return 0;
}
Compile it with gcc epoll_demo.c -o epoll_demo and run it. Typing “hello” and pressing Enter produces this literal output:
Waiting for you to type something (Ctrl+D to exit)...
hello
Got: hello
Example 2: a TCP server that handles multiple clients with a single thread
The second example scales the idea up to a real server. The listening socket is registered in the same epoll instance as the client sockets, and a single epoll_wait() loop handles all of them:
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
int client_fd = accept(listen_fd, NULL, NULL);
struct epoll_event cev = { .events = EPOLLIN, .data.fd = client_fd };
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &cev);
printf("Client connected, fd=%d\n", client_fd);
} else {
char buf[256];
int r = read(fd, buf, sizeof(buf));
if (r <= 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); }
else write(fd, buf, r);
}
}
}
If you connect two clients with nc localhost 9000 in two different terminals, the server output shows something like:
Client connected, fd=4
Client connected, fd=5
No new thread is created per client: the same loop and the same epoll instance handle both sockets.
Verifying that a process uses epoll
You don’t need to compile anything to confirm a production server is using epoll. The command ls -la /proc/$(pgrep -o nginx)/fd lists the process’s open descriptors, and the epoll entry shows up as a symbolic link to anon_inode:[eventpoll], as documented in epoll_create(2):
lrwx------ 1 www-data www-data 64 Oct 16 10:02 6 -> anon_inode:[eventpoll]
Another way is to trace system calls live with strace -f -e trace=epoll_wait -p $(pgrep -o redis-server) (if strace isn’t installed, sudo apt install strace adds it on Debian and Ubuntu), which shows output similar to:
epoll_wait(7, [{EPOLLIN, {u32=12, u64=12}}], 128, -1) = 1
The 1 at the end confirms that exactly one ready descriptor arrived out of the 128 the process was watching in that call.
The Python equivalent
Python doesn’t expose epoll directly in most scripts, but the standard selectors module uses it under the hood on Linux. You can confirm it like this:
import selectors
print(selectors.DefaultSelector)
On any modern Linux with Python 3, the literal output is:
<class 'selectors.EpollSelector'>
That means any async Python server (asyncio, Tornado, aiohttp) running on Linux ends up relying on epoll even if the code never mentions the word.
Real-world use cases
Nginx uses a model with a handful of worker processes, each with its own epoll instance, to handle thousands of HTTP connections without creating a thread per client. That’s why a server with just a few cores can sustain tens of thousands of keep-alive connections with predictable memory usage.
Redis serves all its operations from a single execution thread, which eliminates the need for internal locks to protect its data structures. That design is only viable because network waiting doesn’t block that thread: epoll notifies it when there’s data, and the thread stays free in the meantime.
Node.js doesn’t implement epoll directly in JavaScript, but its libuv runtime uses it on Linux to handle network and file operations in a non-blocking way, which lets a single-threaded program serve thousands of concurrent HTTP requests.
HAProxy and Envoy, the proxies that route traffic in most microservice platforms, also rely on epoll to multiplex incoming and outgoing connections without spending an OS thread on each one.
Common mistakes and best practices
The most common mistake is confusing level-triggered mode, the default, with edge-triggered (EPOLLET). In level-triggered mode, epoll_wait() keeps notifying as long as there’s unread data in the buffer, so a partial read doesn’t break anything. In edge-triggered mode, the notification arrives only once per state change: if you don’t drain the buffer completely, that data stays invisible until something new arrives.
⚠️ Watch out: with EPOLLET you must read in a loop untilread()returnsEAGAIN, never assume a singleread()is enough.
Another common mistake is forgetting EPOLL_CTL_DEL before closing a socket that’s still registered. Even though close() automatically removes it from the epoll instance, leaving it this way hides bugs when the same descriptor number gets reused for a different connection.
A third problem shows up in multiprocess servers: if several processes share the same epoll instance on the same listening socket, all of them can wake up at once for a single new client, a phenomenon called thundering herd. The EPOLLEXCLUSIVE flag, added in kernel 4.5, limits that wake-up to a single process per event.
Finally, mixing epoll with descriptors that don’t support event polling can produce unexpected results: epoll is designed mainly for sockets, pipes, and FIFOs, not for regular files in general.
Comparison with alternatives
The choice between select, poll, and epoll on Linux almost always depends on how many descriptors need watching and whether the code needs to run on other operating systems.
| Option | When to use it | Advantage | Limitation |
|---|---|---|---|
| select() | Few descriptors (under 1024) and maximum portability | Available on almost any POSIX system | Fixed descriptor limit, O(n) scan on every call |
| poll() | More than 1024 descriptors with no fixed limit | No artificial descriptor limit | Still loops through the full list on every call |
| epoll | Thousands of simultaneous connections on Linux | Amortized O(1) complexity, only delivers ready descriptors | Linux-exclusive, not portable to other systems |
| kqueue | The same scenario on BSD and macOS | Similar philosophy to epoll, more generic | Doesn’t exist on Linux |
| io_uring | High-volume I/O with minimal system call overhead | Shared queues between kernel and application, batches operations | Steeper learning curve, requires kernel 5.1 or later |
In practice, most modern frameworks hide this decision behind an abstraction layer like libuv, asyncio, or Tokio, so developers almost never call epoll_ctl() directly, but understanding what happens underneath helps explain why switching operating systems can change the performance of the same program.
Going deeper: edge-triggered and the internal details
The difference between level-triggered and edge-triggered isn’t cosmetic: it changes the contract the application signs with the kernel. In level-triggered mode, every call to epoll_wait() asks whether there’s still unread data, and the kernel responds based on the buffer’s current state. In edge-triggered mode, the kernel only notifies the exact instant the buffer went from empty to having data, and won’t notify again until the next state change, even if the old data is still sitting there unread.
That difference explains why edge-triggered mode requires non-blocking descriptors (O_NONBLOCK): if the application tries to read more data than is available with a blocking descriptor, the process hangs waiting for something that never arrives, because there won’t be another notification to wake it up.
Internally, the epoll instance maintains two separate structures: a red-black tree with all registered descriptors, so adding or removing is fast, and a linked list with the currently ready descriptors, so epoll_wait() doesn’t have to scan the entire tree. When the kernel’s network driver receives a packet for a registered socket, it runs a callback that moves that socket to the ready list.
The epoll_pwait() variant adds an extra parameter to temporarily block signals while the process waits, avoiding a race window where a signal arrives just before blocking and gets lost until the next cycle.
That same logic explains why epoll-based servers keep nearly flat CPU usage at rest. A model that looped through every connection constantly would burn CPU even without traffic, while a process blocked in epoll_wait() stays asleep, consuming no CPU cycles, until the kernel wakes it up with real work to do.
💭 Key point: epoll doesn’t measure CPU or memory activity: it only responds to state changes on file descriptors, so it can’t notify events that don’t go through the filesystem or the network.
Your next step: compile this article’s TCP server example, connect with two nc localhost 9000 tabs at once, and watch the server terminal to see how a single process handles both clients without creating any new thread.
Frequently Asked Questions
Does epoll in Linux work the same way on macOS or Windows?
No. Epoll is an interface exclusive to the Linux kernel. macOS and BSD systems use kqueue, which solves the same problem with a similar philosophy but a different API; Windows uses IOCP, a different model based on completion rather than notification.
What’s the difference between epoll and io_uring?
Epoll notifies when a descriptor is ready to read or write, but the application still has to perform the operation with a separate call. io_uring lets you queue the entire operation and have the kernel handle it, returning the result through a shared queue, with fewer system calls overall.
Do I need to use epoll directly in my code?
Almost never. Most languages and frameworks, like Node.js with libuv, Python with asyncio, Go with its runtime, or Rust with Tokio, already use the epoll interface under the hood when running on Linux. Calling epoll_ctl() and epoll_wait() by hand only makes sense if you’re writing the runtime itself.
How many descriptors can a single epoll instance watch?
There’s no fixed limit like select()‘s 1024 descriptors. The real limit is how many file descriptors the process can open, configurable with ulimit -n, and the memory available for the internal red-black tree.
What happens if I mishandle epoll’s edge-triggered mode?
If you use EPOLLET and don’t read the entire available buffer on each notification, that data gets stuck: the epoll mechanism won’t notify again about data it already reported once, until a new packet arrives that triggers another state change.
References
- man7.org: official epoll(7) man page, with the full documentation of the interface.
- man7.org: epoll_create(2) man page, includes the detail on anon_inode:[eventpoll].
- docs.python.org: documentation for the selectors module, which uses EpollSelector on Linux.
- docs.libuv.org: documentation for libuv, the asynchronous I/O library used by Node.js.
- nginx.org: official Nginx documentation on its event processing models.
📱 Enjoy this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
Featured image: Foto de Manuel Luikenga en Unsplash
Did it work for you? Got a different error? Say so below: questions get answered and help the next reader.
Leave a comment
0 Comments