3.5
I/O Models: Blocking, Non-Blocking, epoll and io_uring
How one thread can watch ten thousand sockets, from select and poll to epoll and io_uring.
45 min Reading + animations
Tip: tap or hover any dotted-underlined term to see what it means.
By the end of this lesson you will be able to
- Split any read into its two phases (waiting for data, copying it) and explain the five classic I/O models by who waits during each phase.
- Work out what one thread per connection really costs, and why servers moved away from it to solve the C10K problem.
- Use non-blocking sockets correctly:
EAGAIN, short reads, partial writes, end of file and backpressure. - Compare
select,poll,epollandkqueueby their real costs, and explain whyepoll_waitdoes not slow down as idle connections grow. - Read and write a working
epollserver, and choose between level-triggered and edge-triggered mode without creating hangs or busy loops. - Share a port between workers using
EPOLLEXCLUSIVEorSO_REUSEPORT, knowing the failure mode of each. - Diagnose
Too many open files, theEMFILEspin and descriptor leaks. - Explain why a regular file is always “ready”, and how servers keep disk reads from freezing an event loop.
- Tell readiness from completion, describe how
io_uringworks, and say when you would or would not use it.
#Where this lesson fits
Lesson 1.2 laid out the concurrency models of a server (process, thread, event loop) and told the story of C10K, and lesson 1.13 showed Nginx’s workers and Apache’s MPMs. Lesson 2.6 counted what a socket costs in memory. All of them said “the kernel tells the loop which sockets are ready” and moved on.
This lesson opens that sentence. It builds on lesson 3.1 (threads, system calls, file descriptors), lesson 3.2 (the cost of switching) and lesson 3.3 (pools). The loop that sits on top of these system calls, with its phases and callbacks, is lesson 3.6. Here we stay at the boundary between your program and the kernel.
#What I/O is, and why the model matters
I/O means input and output: any time a program moves data to or from something outside the CPU and memory.
| Example | What your thread is really waiting for | Typical wait |
|---|---|---|
| Reading from a socket | Another machine to send something | Microseconds to forever |
| A network request to another service | The network and that service | Around a millisecond in a data centre, 100 ms or more across the world |
| A database read | A network round trip, plus the database’s own disk and locks | One to hundreds of milliseconds |
| Reading a file | The disk, unless the data is already in memory | Microseconds (cached) to milliseconds |
| Writing to disk | Usually only a copy into memory; the disk only if you force it | Microseconds, or milliseconds with fsync |
A modern core runs billions of instructions a second, so a one millisecond wait costs millions of instructions’ worth of time. A typical web request spends nearly all its life waiting. Performance at scale therefore depends less on how fast your code runs than on what your threads do during those waits. That is what an I/O model decides.
Every read has two phases, and everything in this lesson follows from them:
- Wait until the data exists in the kernel (a packet arrives, the disk answers).
- Copy it from the kernel’s buffer into your program’s memory.
#Why one thread per connection stops scaling
The simplest server gives each connection a thread that blocks (lesson 1.2 walked through it). The history is in that lesson: in 1999 Dan Kegel’s C10K page asked how to serve ten thousand clients at once. What deserves a closer look is why threads run out, since the usual answer (“each one takes 8 MB”) is wrong.
- Address space is not memory. glibc reserves a default stack of 8 MB per thread (
ulimit -s) and the JVM about 1 MB, but only the pages a thread actually touches take real memory (lesson 3.4). On a 64-bit machine, 50,000 × 8 MB = 400 GB of reservation is harmless. - Real memory is still significant. Each thread uses some touched stack plus a kernel stack (16 KB on x86-64) and bookkeeping. At a rough 80 KB per thread, 50,000 threads hold about 4 GB doing nothing.
- Limits arrive first. Each stack is a memory mapping plus a guard page, and
vm.max_map_countis often about 65,530, which stops you near 32,000 threads.kernel.threads-max,ulimit -uand a container’spids.maxcan stop you sooner. - Scheduling hurts under bursts. Sleeping threads cost the scheduler almost nothing. The damage comes when thousands wake together and fight for a few cores: every switch costs microseconds plus cold caches and TLB misses (lesson 3.2).
- A fixed pool is a hard ceiling. With a pool of 200, 200 slow clients stop your service while the CPU sits idle.
An event loop holding the same 50,000 connections needs a handful of threads and perhaps 10 KB of state per connection. Kernel socket buffers cost the same in both designs, so the difference is threads, not sockets. WhatsApp reported about 2 million connections on a single FreeBSD server in 2012.
One thread per connection: what it costs as the numbers grow
The same server at 200 and at 50,000 connections, then with slow clients, then with one event loop. Watch the table under the map.
Scenario
At this size blocking threads are a fine design: simple code and modest cost.
Cost at 200 threads
| Item | Amount |
|---|---|
| Real memory for stacks | about 16 MB |
| Threads asleep waiting | most of them |
Two hundred clients connect and each gets a thread. The code reads top to bottom: read the request, query the database, write the response.
#Blocking I/O
In blocking I/O, a thread that starts an operation stops until the operation can finish. This is the default for every socket.
data = sock.recv(1024) # waits here until at least one byte exists
process(data)
On a timeline, the thread spends most of its life waiting:
Thread 1:
|------------- I/O waiting -------------| processing |
If no data arrives, recv() waits. The thread can do nothing else, so serving a second client at the same time needs another execution unit: a thread or a process.
Three details that the simple picture hides:
- A blocked thread uses no CPU. The kernel takes it off the run queue and puts it on the socket’s wait queue. It holds memory and its slot in your pool, not a core.
recv(1024)means “up to 1024 bytes”. It returns as soon as anything is available. TCP has no message boundaries, so you must buffer and loop until a full message has arrived. It returns 0 when the peer has closed, which is end of file (EOF), not an error.- Writes block too.
send()copies into the socket’s send buffer. When a slow reader lets that buffer fill, a blockingsend()sleeps. SetSO_RCVTIMEOandSO_SNDTIMEO, or their equivalents, so a stalled peer cannot hold a thread forever.
Disk writes are a special case worth correcting: a plain write() to a file normally returns after copying into the page cache, without waiting for the disk. The wait comes with fsync, O_SYNC, or when too much dirty data has built up (lesson 3.4).
| Blocking I/O | |
|---|---|
| Strengths | Code is simple and sequential, logic is easy to follow, stack traces make sense |
| Weaknesses | Needs many threads, costs thread resources, more context switching, a blocked thread is lost capacity |
| Good for | Simple applications, low-concurrency servers, worker-based processing such as background jobs |
Blocking I/O: what a sleeping thread really does
Follow one recv() into the kernel: the wait, the wake-up and the copy, then the short reads and full buffers that catch beginners.
Scenario
The normal blocking read when nothing has arrived.
A system call moves the thread from user mode to kernel mode (lesson 3.1). The kernel looks at this socket’s receive buffer and finds it empty.
#Non-blocking I/O
Set the O_NONBLOCK flag and the kernel will not make the thread wait for data. If the call cannot make progress, it returns at once.
result = sock.recv(1024) # with sock.setblocking(False)
Thread:
| check I/O | not ready | do other work | check I/O | ...
If no data is ready, the system call returns -1 and sets errno to EAGAIN or EWOULDBLOCK (the same value on Linux, but check both if you want portability). Neither is a failure: they mean “nothing now, come back later”. Python raises BlockingIOError; Go and Node.js hide it from you. The application does other work and checks readiness later.
Non-blocking code must handle what blocking code got for free:
- Partial writes. A
send()of 1 MB may take 256 KB and return that count. You must keep the rest and send it when the socket has room. - State. A connection may be half way through a request when the thread moves on. The position has to live in an explicit object, not on a stack.
- Backpressure. That pending data lives in your memory, so cap it per connection, or stop reading from a client who is not reading your responses.
| Non-blocking I/O | |
|---|---|
| Strengths | Few threads can handle many connections, less blocking, suitable for high-concurrency servers |
| Weaknesses | Complex programming, hard state management, intricate error handling, and a bad polling loop becomes a CPU busy loop |
On its own, non-blocking I/O is not useful, since you do not know when to retry. If you retry at once you waste a core (the busy loop), and if you sleep between retries you add latency. It becomes useful when paired with something that says when to retry.
Non-blocking I/O: EAGAIN, and how to misuse it
Set O_NONBLOCK and the kernel never makes your thread wait for data. Step through a clean read, the classic busy loop and a partial write.
Scenario
An empty socket answers immediately instead of sleeping.
The socket has O_NONBLOCK set. The buffer is empty, but this time the kernel will not put the thread to sleep.
#The map: five I/O models
W. Richard Stevens’s UNIX Network Programming names five models, and interviewers still use this list. They differ in what your thread does in each phase:
| Model | Phase 1: waiting for data | Phase 2: copying | Linux examples |
|---|---|---|---|
| Blocking | Thread blocked | Thread blocked | Default read, recv |
| Non-blocking | Thread keeps asking (EAGAIN) | Thread blocked | O_NONBLOCK |
| I/O multiplexing | Thread blocked in one call covering many sockets | Thread blocked | select, poll, epoll |
| Signal-driven | Thread runs freely; a signal arrives | Thread blocked | O_ASYNC, SIGIO (rarely used) |
| Asynchronous | Thread runs freely | Thread runs freely | io_uring; POSIX AIO |
Two precise points:
epollis not a model of its own. It is one implementation of multiplexing. Lists that show “blocking, non-blocking, multiplexing, async, epoll” mix levels.- By the POSIX definition only the last model is asynchronous. The first four all block during the copy, so strictly they are all synchronous I/O. That copy is usually fast, so in everyday speech, people call a Node.js server “async” even though it uses
epollunderneath. The difference between a non-blocking API in your language and asynchronous I/O in the kernel is covered further in lesson 3.6.
A note on POSIX AIO: the aio_read family exists, but glibc emulates it with a pool of user-space threads. Linux also has an older native interface (io_submit, used through libaio) that is really useful only for O_DIRECT files and can still block on metadata. Neither took hold for sockets. Real asynchronous I/O on Linux means io_uring.
The five I/O models on one timeline
Every read has two phases: the kernel waits for data, then copies it into your buffer. Compare what your thread is doing during each one.
Scenario
The thread is blocked through both phases.
The thread called recv() and sleeps. It can serve nobody else, which is why this model needs one thread per connection.
#I/O multiplexing: select, poll, epoll, kqueue
I/O multiplexing means one thread or process monitors the readiness of many connections. Say your server has 10,000 socket connections: rather than a thread per socket, a single mechanism watches all of them.
| Mechanism | Platform | Kind | Cost per wait | Limit |
|---|---|---|---|---|
select() | Everywhere | Readiness | Linear in the highest descriptor number | FD_SETSIZE, typically 1024 |
poll() | Unix-like | Readiness | Linear in the number watched | None of its own |
epoll | Linux | Readiness | Linear in the number ready | fs.epoll.max_user_watches |
kqueue | FreeBSD, macOS | Readiness | Linear in the number ready | Kernel memory |
| IOCP | Windows | Completion | Linear in the number finished | Kernel memory |
#select()
The oldest, from 4.2BSD in the 1980s. You pass bitmaps of descriptor numbers, and it reports which are ready. Its limits are severe:
FD_SETSIZE, typically 1024, limits the descriptor number itself, not merely how many you watch. Descriptor 1500 cannot be used even if it is the only one. Writing it withFD_SEToverflows the bitmap, and this is a real production bug: raiseulimit -n, grow past 1,024 connections, and a forgotten library that callsselect()starts corrupting memory or aborting. (Windows differs: itsselectlimits the count, to 64 by default.)- The whole set is copied and scanned on every call, by the kernel and then by you, however few sockets are active.
- It overwrites your bitmaps, so you rebuild them before each call.
#poll()
poll() monitors readiness the same way but takes an array of struct pollfd, 8 bytes each. There is no fixed set size, and input and output are separate fields, so you do not rebuild. It still copies the array in and walks all of it: at 50,000 sockets that is 400 KB copied per call plus 50,000 checks, to discover perhaps three active sockets.
#epoll
epoll is Linux’s event notification mechanism, added in the 2.5/2.6 series around 2002. It removes the scan by splitting the job in two: register once, then wait many times. The kernel keeps the list, so nothing is copied per wait, and it builds the ready list at the moment events occur. Its two modes, level-triggered and edge-triggered, are covered below.
#kqueue and IOCP
kqueue (FreeBSD 4.1 in 2000, also on macOS) does the same job, and many consider it the cleaner design. One call, kevent(), both registers changes and waits, so it needs fewer system calls, and it watches more than sockets: files changing, signals, timers, process exits.
IOCP on Windows is often listed with these, but it is not a readiness mechanism. You start an operation with a buffer (WSARecv) and collect the finished result later (GetQueuedCompletionStatus). It is completion-based, a real difference that we return to below.
select, poll and epoll: who scans what
Each dot stands for many sockets. Three have data. Compare how much work each call does to find those three.
Scenario
A fixed bitmap of 1024 bits, copied and scanned on every call.
the whole bitmap, every call
1023 (FD_SETSIZE)
You build three bitmaps of descriptor numbers (read, write, error) and pass them in. They hold 1024 bits, so at most 1024 sockets here, and each dot is about 11 of them.
Self-checkQuestion
A service holds 40,000 mostly idle WebSocket connections and uses poll(). It spends a lot of CPU in system time even when traffic is light. Why would moving to epoll help?
#epoll in detail
Say a web server has 50,000 client connections. A thread per connection brings the memory and scheduling costs we measured above. With epoll, one event loop can monitor them all.
#Three calls, two lists
| Call | What it does |
|---|---|
epoll_create1(flags) | Creates an epoll instance in the kernel; returns a descriptor for it |
epoll_ctl(epfd, op, fd, &event) | Adds, modifies or removes a descriptor: EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL |
epoll_wait(epfd, events, max, timeout) | Waits until something is ready and returns the ready entries |
Inside the kernel, the instance holds two structures:
- The interest list, a red-black tree of everything you are watching. Changing it costs O(log n), and each entry takes about 160 bytes of kernel memory on 64-bit (the
epollman page’s figure). - The ready list, a linked list of what is ready right now.
How does a socket get onto the ready list without anyone scanning? When you register a socket, the kernel attaches a small callback to the socket’s wait queue. When a packet arrives and the TCP code wakes that queue, the callback runs and links the socket onto the ready list. The work happens once, at the moment of the event, and epoll_wait only has to look at the list. That is why idle sockets cost nothing.
Here is the skeleton:
int epfd = epoll_create1(0);
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &event);
while (1) {
struct epoll_event events[100];
int n = epoll_wait(epfd, events, 100, -1);
for (int i = 0; i < n; i++) {
// handle the ready socket
}
}
This is only a conceptual skeleton. A production server must also make sockets non-blocking, run an accept loop, handle partial reads and writes, handle error events, manage the connection lifecycle and clean up resources.
The epoll API, call by call
Three system calls, two kernel lists. Set up a server, accept a burst of clients, and see what happens when a client leaves.
Scenario
create, ctl, wait: the whole API.
This creates an epoll instance in the kernel and returns a descriptor for it. Inside are the two lists that matter: everything you are watching, and what is ready now.
#A complete server
This echo server does the things the skeleton skips. Build it on Linux with gcc -O2 -Wall -o echo echo.c, run ./echo, and connect with nc 127.0.0.1 8080:
// echo.c: an edge-triggered epoll echo server.
#define _GNU_SOURCE
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#define MAX_EVENTS 128
static void die(const char *what) { perror(what); exit(1); }
static void watch(int epfd, int fd, unsigned events) {
struct epoll_event ev = { .events = events, .data.fd = fd };
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) < 0) die("epoll_ctl");
}
int main(void) {
int lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
if (lfd < 0) die("socket");
int on = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);
struct sockaddr_in addr = {
.sin_family = AF_INET, .sin_port = htons(8080), .sin_addr.s_addr = htonl(INADDR_ANY)
};
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) < 0) die("bind");
if (listen(lfd, 4096) < 0) die("listen");
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd < 0) die("epoll_create1");
watch(epfd, lfd, EPOLLIN); // level-triggered listener
struct epoll_event events[MAX_EVENTS];
char buf[4096];
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1); // sleeps until something is ready
if (n < 0) {
if (errno == EINTR) continue; // a signal woke us: not an error
die("epoll_wait");
}
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == lfd) { // new connections: accept them all
for (;;) {
int c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (c < 0) {
if (errno != EAGAIN && errno != EWOULDBLOCK) perror("accept4");
break;
}
watch(epfd, c, EPOLLIN | EPOLLET); // clients are edge-triggered
}
continue;
}
for (;;) { // edge-triggered: drain until EAGAIN
ssize_t r = read(fd, buf, sizeof buf);
if (r > 0) {
// Simplified: a real server keeps what write() did not take and waits for EPOLLOUT.
if (write(fd, buf, r) < 0) { close(fd); break; }
} else if (r == 0) { // the peer closed: end of file
close(fd); // also removes fd from the epoll set
break;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // drained: wait for the next edge
} else if (errno != EINTR) {
close(fd); // a real error such as ECONNRESET
break;
}
}
}
}
}
Some things to notice: EINTR is expected and retried, and EPOLLHUP and EPOLLERR need not be requested, because they are always reported (the read then shows what happened). It still simplifies on purpose: no pending-write buffers, no per-connection objects, no timeouts, and no answer to EMFILE, which we come to shortly. In practice you would rarely write this. Libraries such as libuv, libevent, Tokio, Netty and Go’s runtime wrap it. You should still understand it, since their bugs and limits are its bugs and limits.
#Level-triggered and edge-triggered
This is the most-asked epoll question, and it is a question about when you are told.
Level-triggered (LT) is the default. While a descriptor is readable or writable, every epoll_wait reports it. Suppose a socket buffer holds 1000 bytes and you read 100: 900 remain, so the next wait reports the socket as readable again.
Edge-triggered (ET), set with EPOLLET, reports a change of state: new data arrived. After that it stays quiet until there is another change. So the socket should be non-blocking, and you generally keep reading or writing until you get EAGAIN or EWOULDBLOCK:
New data arrives
|
v
Edge event triggered
|
v
Application reads data
|
v
Continue until EAGAIN
| Topic | Level-triggered | Edge-triggered |
|---|---|---|
| Event | Reported for as long as it is ready | Reported on a state change |
| Handling | Relatively easy | Needs careful handling |
| Non-blocking socket | Helpful (recommended) | Essential |
| Reading data | Step by step is possible | Usually drain until EAGAIN |
| Risk | Extra notifications and system calls | Missed events: hung connections |
| Used by | Redis, libuv | Nginx, Netty’s native transport |
An important correction to a common description: an edge does not need the buffer to go from empty to non-empty. Each arrival of new data fires an event, even if old data is still unread. That is why the bug hides: on a chatty connection, the next message rescues the bytes you left behind. It bites when the client has sent its whole request and goes silent to wait for your answer. The server sleeps, the client waits, the CPU is idle, and there is no error anywhere.
Which should you pick? Neither is faster by nature in typical servers, and benchmarks differ. Level-triggered is simpler and hard to get badly wrong, so start with it. Edge-triggered can save some epoll_wait and epoll_ctl calls, since you need not toggle EPOLLOUT, and it pairs well with several threads. Choose it only if you have a measured reason and a careful team.
Level-triggered and edge-triggered: the 1000-byte test
A socket buffer holds 1000 bytes and the code reads 100. Watch which mode tells you again, then the bugs each mode invites.
Scenario
Level-triggered is the default and it forgives a short read.
Data arrives and the socket joins the ready list.
Each mode has one classic bug beyond the obvious ones:
- ET starvation. “Read until
EAGAIN” can run forever on a fast sender, starving every other connection. Cap each socket’s work per round and keep it on your own to-do list, since no new event may come. - LT
EPOLLOUTspin. A socket is writable almost always. LeaveEPOLLOUTon andepoll_waitreturns immediately, forever. Subscribe only while you have pending data.
Two flags worth knowing: EPOLLONESHOT disables the descriptor after one event until you re-arm it, so that in a multi-threaded loop only one thread handles a socket at a time. EPOLLRDHUP reports that the peer has shut down its sending side.
#Why sockets should be non-blocking even with level-triggered
You might expect that after epoll says “readable”, a blocking read is safe. It is not. The Linux select man page warns that a socket can be reported ready and then block on read, for instance when an arriving packet turns out to have a bad checksum and is thrown away. Another process or thread sharing the socket may also have taken the data first. A single such event on a blocking socket freezes the whole loop. So always pair multiplexing with non-blocking sockets.
#Many workers, one port
One loop uses one core, so a 32-core machine runs 32 workers (lesson 1.13 showed Nginx doing this). They all serve port 443, and that creates a question: which worker takes a new connection?
The thundering herd problem is when one event wakes many waiters and only one can use it. Blocking accept() got a wake-one fix long ago, but many workers each watching one shared socket through their own plain epoll instance can all wake for a single connection. All but one get EAGAIN and go back to sleep. At high connection rates on many cores, this shows up as real CPU in system time and lock contention. There are three standard answers:
| Approach | How | Strength | Weakness |
|---|---|---|---|
| Accept mutex | Workers take turns watching the socket, in user space | Works everywhere | Adds latency. Nginx’s accept_mutex is now off by default |
EPOLLEXCLUSIVE (Linux 4.5) | One shared socket; wake at least one waiter, not all | A stuck worker traps nobody; a free worker takes the connection | Uneven: the kernel tends to favour the same waiter, so some workers get far more connections |
SO_REUSEPORT (Linux 3.9) | Each worker has its own socket on the same port; the kernel hashes the 4-tuple to pick one | No herd, no shared lock, near-even spread | A stuck worker still gets its share, so those connections wait, hurting tail latency. Closing a socket resets what is in its queue |
Nginx enables this with listen 443 reuseport;. No approach wins everywhere: use SO_REUSEPORT for even load on short, uniform work, and be wary of it when one request can stall a worker. Note that it also applies to UDP, where one shared socket is otherwise a serious bottleneck.
Many workers, one port: the thundering herd
One event loop uses one core, so servers run one per core. How should they share port 443? Compare a plain shared socket, EPOLLEXCLUSIVE and SO_REUSEPORT.
Scenario
All workers watch one listening socket with plain epoll.
The master opens the socket and forks the workers, which inherit it, and each one adds it to its own epoll instance. All three are asleep in epoll_wait.
#File descriptors and their limits
Every socket, file, pipe and epoll instance is a file descriptor (lesson 3.1), and the limits come in layers:
| Limit | Scope | Where to set | Error |
|---|---|---|---|
Soft RLIMIT_NOFILE | Process, often 1024 | ulimit -Sn, LimitNOFILE= in systemd, --ulimit nofile= in Docker | EMFILE |
Hard RLIMIT_NOFILE | Process, ceiling for the soft limit | ulimit -Hn, the same settings | EMFILE |
fs.nr_open | Largest value any process may set, often 1,048,576 | sysctl | Setting the limit fails |
fs.file-max | The whole machine | sysctl | ENFILE |
fs.epoll.max_user_watches | Registered epoll entries per user | sysctl | ENOSPC from epoll_ctl |
Budget at least one descriptor per client connection, two when proxying (client plus upstream), plus logs, listeners and so on. A proxy with 100,000 clients needs more than 200,000. Systemd services ignore limits you type in a shell or put in limits.conf for login sessions, which is the most common reason “I raised it, but it did not change”. Check the running process with cat /proc/<pid>/limits.
The two failures you will actually meet:
- The
EMFILEspin.acceptfails, but the connection stays in the queue, so a level-triggered listener stays readable. The loop spins at 100% CPU, printing errors. Libraries handle this by keeping a spare descriptor, or by pausing the listener for a moment. - A descriptor leak. Some error path forgets
close(). The socket sits inCLOSE_WAITuntil the limit is reached, and raising the limit only delays the outage. A growingCLOSE_WAITcount is always your application’s bug, never the network’s.
Running out of file descriptors
Every socket is a descriptor. See what the limit looks like from inside an event loop, why it can spin at 100% CPU, and how a descriptor leak ends in the same place.
Scenario
The default soft limit is surprisingly small.
Log files, the epoll descriptor and the listening socket also count, so the limit arrives a little before 1,024 connections.
#Regular files are always “ready”
Readiness models were built for things whose data comes from outside, like sockets and pipes. A regular file is different:
selectandpollalways report a regular file as readable and writable.epoll_ctlon a regular file refuses withEPERM.O_NONBLOCKhas no effect on it, so you never getEAGAIN.
Why? “Ready” means a read will not wait for some outside event, and a file’s bytes are always obtainable. Waiting on a disk does not count, because the kernel treats it as a short delay rather than an open-ended wait. So a read() of a page that is not in the page cache simply puts your thread to sleep (shown as D state, “uninterruptible”), and your event loop freezes with it. On a laptop, with everything cached, you never see it. On a production box with data far larger than memory or a congested network disk, it shows up as occasional latency spikes on every connection at once.
The answers:
| Approach | How | Notes |
|---|---|---|
| Thread pool | Hand the read to a helper thread, get notified when it ends | The classic: libuv’s pool for Node.js file operations, aio threads in Nginx. Simple and portable |
io_uring | Submit the read and collect the result | Real asynchronous file I/O, also for buffered reads |
preadv2 with RWF_NOWAIT | Read only if it will not block; otherwise returns EAGAIN, and you fall back to the pool | Saves a thread hop when the data is cached |
sendfile (for sending files to sockets) | Kernel copies straight from page cache to socket | Avoids copies, can still block on a cold page |
Regular files: always “ready”, and still slow
Readiness works for sockets, whose data comes from outside. A file has nothing to wait for, as far as readiness goes, so the disk can still freeze your loop.
Scenario
O_NONBLOCK and poll have no effect on a regular file.
The loop tries to watch a file the way it watches a socket.
#Readiness and completion, and io_uring
#Two different questions
| Readiness (reactor) | Completion (proactor) | |
|---|---|---|
| You say | “Tell me when I can read” | “Read into this buffer, tell me when done” |
| Who does the I/O | You, in a further system call | The kernel |
| Buffer needed | Only when the socket is ready | From the moment you start the operation |
| Works on files | No | Yes |
| Examples | select, poll, epoll, kqueue | IOCP, io_uring (POSIX AIO in name) |
The cost of epoll is that it reports and does nothing else. A thousand ready sockets means one epoll_wait, a thousand reads and perhaps a thousand writes, each crossing the kernel boundary, and that crossing has become noticeably more expensive since the Spectre and Meltdown mitigations. Under heavy traffic, this can be a large share of a server’s CPU.
#How io_uring works
io_uring, by Jens Axboe, arrived in Linux 5.1 in 2019. It is built on two ring buffers in memory shared between your program and the kernel:
- The submission queue (SQ), where you write requests. Each entry (SQE) says what to do: operation, descriptor, buffer, and a
user_datavalue of your choice. - The completion queue (CQ), where the kernel writes results. Each entry (CQE) has your
user_dataand a result code.
Your program Kernel
writes SQE ---> [ SQ ring ] ---> takes requests, does the I/O
|
reads CQE <--- [ CQ ring ] <--- writes results (any order)
The result is what epoll cannot do:
- Batching. Queue 100 read and write requests, then make one
io_uring_entercall. Reading results from the CQ costs no system call. - Files and sockets alike. One interface for network and buffered disk I/O, and much more, such as
accept,connect,openat,fsync. - Fewer copies and lookups. Registered buffers and files avoid repeated setup work, and there is a zero-copy send.
- No system calls at all, with
SQPOLL: a kernel thread watches the SQ, at the price of a thread spinning while it is active.
It comes with traps of its own. A completion model needs a buffer for each pending read, so 50,000 idle sockets × 16 KB means 800 MB doing nothing; provided buffers, where the kernel picks from a shared pool when data arrives, and multishot receive solve this. Your buffer must also stay alive and untouched until the completion arrives, which makes cancelling harder. And it needs a recent kernel to give you the features you want.
#The security question
io_uring lets a process do almost anything with no individual system call, so seccomp filters and many security tools that watch system calls cannot see what it is doing. It has also had a long series of serious kernel vulnerabilities. Google reported in 2023 that most of the exploits submitted to its kernel bug-bounty program in the previous year used io_uring, and it restricted it in Android, Chrome OS and its production servers. Recent default seccomp profiles from container runtimes such as Docker block it, and Linux 6.6 added the kernel.io_uring_disabled sysctl to disable it across a host.
So, check before you rely on it. Your code may get EPERM in production and must fall back to epoll or a thread pool, and well-built libraries do. Adoption is real but careful: PostgreSQL 18 can use it for asynchronous reads (io_method=io_uring), and storage engines and databases are its natural home, while epoll remains the everyday choice for ordinary network servers.
Completion instead of readiness: io_uring
Two shared rings between your program and the kernel. Compare it with epoll, batch several reads into one system call, then meet the costs.
Scenario
“Tell me when I can read.” Then you read.
The kernel tells you that three sockets are ready. No data has moved yet.
#How to choose
| Situation | Reasonable choice | Why |
|---|---|---|
| Internal service, hundreds of connections, mostly CPU or database work | Blocking threads and a pool | Simple code; the wait is on a database anyway, and its pool limits you first |
| Chat, push, long polling, tens of thousands of idle connections | Event loop with epoll or kqueue | Idle connections must cost almost nothing |
| Proxy, load balancer or gateway | One event loop per core | Nearly all waiting, little CPU per request |
| Lots of blocking-style business code and many connections | Goroutines or Java virtual threads | Simple code, runtime multiplexes onto epoll (lesson 3.6) |
| Storage engine or database with very high I/O rates | io_uring, if allowed, with a fallback | Batching and real file asynchrony pay off |
| Cross-platform library | libuv, or your language’s runtime | Hides epoll, kqueue and IOCP behind one interface |
#What this means in a system design
- Connection count is a resource you can plan. “We will have 1 million WebSocket users” becomes: descriptors, memory per connection, event loops, the number of machines, what happens when one dies and they all reconnect at once (lesson 2.6).
- Your choke point moves. An event-loop front-end can accept 100,000 connections and pass them to a database that handles 500. Non-blocking I/O removes thread limits, not capacity limits. Protect downstream with pools, queues and limits.
- A load balancer in front needs the same care. It holds two descriptors per connection and has its own ephemeral port and connection limits (Chapter 25).
- Test with slow clients. The designs differ most with idle and slow connections, and a load test that sends fast requests will not show the difference.
#Common production problems
| Symptom | Likely cause | What to do |
|---|---|---|
Random crash or “buffer overflow detected” after raising ulimit -n | A library uses select() with a descriptor of 1024 or more | Replace with poll or epoll; audit dependencies |
| One core at 100%, no traffic | Busy loop: EPOLLOUT left on, or a socket at EOF not closed, or an EMFILE spin | strace the process and see what repeats |
| Connections hang, CPU idle, no errors | ET read loop that stopped before EAGAIN | Drain, or switch to level-triggered |
Too many open files | Limit too low, or a leak | Compare the count with /proc/<pid>/limits; look at CLOSE_WAIT |
| Latency spikes on all connections at once | Blocking call on the loop: disk read, DNS, heavy CPU | Move to a pool; measure loop lag (lesson 3.6) |
| One worker hot, others idle | Shared socket wake-up favouritism | SO_REUSEPORT |
| p99 latency poor while workers look idle | SO_REUSEPORT queues piling up behind one stuck worker | Find the blocking call; a shared socket avoids the trap |
| Truncated responses to slow clients | A partial write ignored | Keep the remainder; wait for EPOLLOUT |
io_uring EPERM in containers only | Seccomp profile or kernel.io_uring_disabled | Fall back; do not disable the profile casually |
#Try it yourself
All commands are for Linux. Run the Python on Linux too, since select.epoll does not exist on macOS or Windows.
See EAGAIN, and the 1000-byte test. This runs the example from this lesson on a real kernel:
import select, socket
a, b = socket.socketpair()
a.setblocking(False)
try:
a.recv(1024)
except BlockingIOError as e:
print("empty socket:", e)
for name, mask in [("LT", select.EPOLLIN), ("ET", select.EPOLLIN | select.EPOLLET)]:
ep = select.epoll()
ep.register(a, mask)
b.send(b"x" * 1000)
print(name, "first wait: ", ep.poll(0))
a.recv(100) # read only 100 of the 1000 bytes
print(name, "second wait:", ep.poll(0))
a.recv(65536) # drain the rest before the next round
ep.close()
The LT second wait reports the socket again, and the ET second wait returns an empty list: that empty list is the hang.
Break select at descriptor 1024.
import resource, select, socket
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
resource.setrlimit(resource.RLIMIT_NOFILE, (min(2048, hard), hard))
socks = [socket.socket() for _ in range(1100)]
last = socks[-1]
print("descriptor number:", last.fileno())
p = select.poll()
p.register(last, select.POLLIN)
p.poll(0) # no complaint
print("poll is fine")
try:
select.select([last], [], [], 0)
except ValueError as e:
print("select refuses:", e) # filedescriptor out of range in select()
Inspect a real server. Use Nginx or any busy process, as root if needed:
ulimit -n # soft limit in this shell
ulimit -Hn # hard limit
PID=$(pgrep -n nginx) # or any process
grep "open files" /proc/$PID/limits # limits of the running process
ls /proc/$PID/fd | wc -l # descriptors open now
ls -l /proc/$PID/fd | grep eventpoll # its epoll instances
sysctl fs.file-nr fs.nr_open fs.file-max # system-wide counters and limits
sysctl fs.epoll.max_user_watches
ss -s # socket summary
ss -tn state close-wait | wc -l # sockets your app has not closed
Watch the loop. Attach strace to an Nginx worker and load a page:
strace -p "$(pgrep -n nginx)" -e trace=epoll_wait,accept4,recvfrom,read,writev,sendfile
# a healthy loop sits in epoll_wait, then a short burst of calls, then epoll_wait again
A process stuck printing epoll_wait returning 1 over and over is a spin. Use strace -c -p $PID for a few seconds to count which system calls dominate. strace slows the process a lot, so be careful in production.
Check io_uring.
sysctl kernel.io_uring_disabled # 0 allowed, 1 restricted, 2 disabled (Linux 6.6 or later)
uname -r # your kernel version
#Summary
Key takeaways
- Every read has two phases, wait and copy. The five models (blocking, non-blocking, multiplexing, signal-driven, asynchronous) differ in who waits in each.
epollis one way to multiplex, not a sixth model. - A blocked thread uses no CPU. It costs a stack, a pool slot and switching under bursts. The 8 MB stack is address space, not memory.
- Non-blocking calls return
EAGAINorEWOULDBLOCKrather than wait. You must then handle short reads, partial writes, explicit state and backpressure. selectis limited byFD_SETSIZE(typically 1024, and it limits the descriptor number) and scans everything;polldrops the limit but still scans.epollandkqueuekeep the list in the kernel, so the wait cost follows ready sockets. IOCP is completion-based, not readiness-based.epollis three calls (epoll_create1,epoll_ctl,epoll_wait) and two lists (a tree of interest, a list of ready).- Level-triggered repeats while ready; edge-triggered fires on change, so drain until
EAGAINwith a fairness cap. Missing that gives silent hangs; leavingEPOLLOUTon in LT gives a 100% CPU spin. - For many workers on a port: a plain shared socket gives a thundering herd,
EPOLLEXCLUSIVElimits wake-ups but is uneven, andSO_REUSEPORTspreads evenly but traps connections behind a stuck worker. - Know your descriptor limits (
LimitNOFILE,fs.nr_open,fs.file-max), expectEMFILE, and treat growingCLOSE_WAITas a leak. - Regular files are always “ready”, so a cold disk read freezes an event loop. Use a thread pool or
io_uring. io_uringuses shared rings for batching, real file asynchrony and few system calls. It is often blocked by seccomp, so always keep a fallback.
#Self-check
Answer on your own first, then read the explanation, even when you got it right.
Self-checkQuestion
What are the two phases of a read from a socket?
Self-checkQuestion
A Java service has a pool of 200 threads. During an incident, all 200 are blocked in socket reads from slow clients, new requests time out, and CPU usage is 4%. What explains the low CPU?
Self-checkQuestion
Your code does data = sock.recv(1024) once and treats data as a complete 1000-byte request. It passes all tests on localhost and fails intermittently in production. Why?
Self-checkQuestion
A non-blocking read returns 0. What should your code do?
Self-checkQuestion
A developer writes a server that loops over 5,000 non-blocking sockets calling recv on each, doing nothing else. With no traffic, one core is at 100%, mostly in system time. What is the best fix?
Self-checkQuestion
By the POSIX definition, which model is truly asynchronous?
Self-checkQuestion
A team raised ulimit -n to 65535. Weeks later, under peak load only, the service aborts with buffer overflow detected. Which is the most likely cause?
Self-checkQuestion
Which two statements correctly describe how poll improves on select?
Choose 2
Self-checkQuestion
A server uses poll() with 100,000 sockets. About how much data is copied into the kernel for each call?
Self-checkQuestion
What determines the cost of a call to epoll_wait?
Self-checkQuestion
A database proxy has 20 sockets, nearly all active on every iteration. An engineer replaces poll with epoll and sees no gain, or a small loss. Why is that plausible?
Self-checkQuestion
Your event loop calls epoll_ctl to add a regular file on disk and gets -1. What is the error, and why?
Self-checkQuestion
In level-triggered mode, a socket buffer holds 1000 bytes and you read 100. What does the next epoll_wait do?
Self-checkQuestion
An edge-triggered server reads a fixed 4 KB per event. Short requests work. Requests of about 10 KB hang until a timeout, with the CPU idle and no errors logged. What is the cause?
Self-checkQuestion
Why must a socket be non-blocking in edge-triggered mode?
Self-checkQuestion
An edge-triggered loop drains each socket until EAGAIN. One client uploads at full speed and every other client’s latency jumps. What is the right fix?
Self-checkQuestion
A level-triggered server registers every connection with EPOLLIN and EPOLLOUT permanently. CPU sits at 100% with little traffic, and strace shows epoll_wait returning instantly, over and over. What is happening?
Self-checkQuestion
Which two things does the simple epoll skeleton (create, add the listener, wait, handle) leave out that a production server must have?
Choose 2
Self-checkQuestion
While handling a batch of events, your loop closes descriptor 7 and accepts a new client, which is also given descriptor 7. A later event in the same batch refers to descriptor 7. What can happen, and what is the fix?
Self-checkQuestion
Sixteen Nginx-style workers watch one shared listening socket through plain epoll. perf shows lots of time in accept returning EAGAIN. What is this called?
Self-checkQuestion
A server moves from a shared listening socket to SO_REUSEPORT. Load spreads evenly and CPU drops, but p99 latency worsens when a request occasionally stalls a worker for a second. Why?
Self-checkQuestion
Which two statements about EPOLLEXCLUSIVE are correct?
Choose 2
Self-checkQuestion
At the file descriptor limit, a level-triggered server logs accept: Too many open files thousands of times a second, and CPU goes to 100%. Why does it spin?
Self-checkQuestion
You ran ulimit -n 100000 on the server and restarted the service with systemctl, but /proc/PID/limits still shows 1024. Which two actions actually fix it?
Choose 2
Self-checkQuestion
A service’s open descriptor count grows steadily and it dies with Too many open files every few days. ss shows tens of thousands of sockets in CLOSE_WAIT. What should you do?
Self-checkQuestion
A Node-style event loop sometimes freezes for 80 ms on every connection at once. The cause turns out to be a synchronous file read on the loop thread. Why did O_NONBLOCK not help, and which two are valid fixes?
Choose 2
Self-checkQuestion
Which correctly describes IOCP on Windows compared with epoll?
Self-checkQuestion
Which two are real advantages of io_uring over epoll?
Choose 2
Self-checkQuestion
A service using io_uring works in staging on a VM and fails with EPERM at startup in a production container. What is the most likely cause?
Self-checkQuestion
A chat gateway holds 200,000 idle sockets and its team switches to io_uring with one 16 KB buffer per pending receive. Memory jumps by about 3.2 GB. What is the right fix?
Self-checkQuestion
You are designing an internal admin API with about 150 concurrent users, each request running a few database queries. A colleague proposes a custom edge-triggered epoll server for performance. What is the best response?