Course outline

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, epoll and kqueue by their real costs, and explain why epoll_wait does not slow down as idle connections grow.
  • Read and write a working epoll server, and choose between level-triggered and edge-triggered mode without creating hangs or busy loops.
  • Share a port between workers using EPOLLEXCLUSIVE or SO_REUSEPORT, knowing the failure mode of each.
  • Diagnose Too many open files, the EMFILE spin 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_uring works, 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.

ExampleWhat your thread is really waiting forTypical wait
Reading from a socketAnother machine to send somethingMicroseconds to forever
A network request to another serviceThe network and that serviceAround a millisecond in a data centre, 100 ms or more across the world
A database readA network round trip, plus the database’s own disk and locksOne to hundreds of milliseconds
Reading a fileThe disk, unless the data is already in memoryMicroseconds (cached) to milliseconds
Writing to diskUsually only a copy into memory; the disk only if you force itMicroseconds, 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:

  1. Wait until the data exists in the kernel (a packet arrives, the disk answers).
  2. 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_count is often about 65,530, which stops you near 32,000 threads. kernel.threads-max, ulimit -u and a container’s pids.max can 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.

Clients
Server process
Database
200 requests

Cost at 200 threads

ItemAmount
Real memory for stacksabout 16 MB
Threads asleep waitingmost of them
Step 1 of 2
1

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 blocking send() sleeps. Set SO_RCVTIMEO and SO_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
StrengthsCode is simple and sequential, logic is easy to follow, stack traces make sense
WeaknessesNeeds many threads, costs thread resources, more context switching, a blocked thread is lost capacity
Good forSimple 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.

Your threaddata = sock.recv(1024)
Kernelsocket buffersreceive buffer: empty
Client
recv(fd, buf, 1024)
Step 1 of 5
1

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
StrengthsFew threads can handle many connections, less blocking, suitable for high-concurrency servers
WeaknessesComplex 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.

Your thread
Kernelsocket buffersreceive buffer: empty
Client
recv(fd, buf, 1024)
Step 1 of 4
1

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:

ModelPhase 1: waiting for dataPhase 2: copyingLinux examples
BlockingThread blockedThread blockedDefault read, recv
Non-blockingThread keeps asking (EAGAIN)Thread blockedO_NONBLOCK
I/O multiplexingThread blocked in one call covering many socketsThread blockedselect, poll, epoll
Signal-drivenThread runs freely; a signal arrivesThread blockedO_ASYNC, SIGIO (rarely used)
AsynchronousThread runs freelyThread runs freelyio_uring; POSIX AIO

Two precise points:

  • epoll is 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 epoll underneath. 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.

012345678910time (arbitrary units)Your threadKernel
thread doing useful workthread blockedthread asking in vainkernel waits for datakernel copies data
Step 1 of 3
1

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.

MechanismPlatformKindCost per waitLimit
select()EverywhereReadinessLinear in the highest descriptor numberFD_SETSIZE, typically 1024
poll()Unix-likeReadinessLinear in the number watchedNone of its own
epollLinuxReadinessLinear in the number readyfs.epoll.max_user_watches
kqueueFreeBSD, macOSReadinessLinear in the number readyKernel memory
IOCPWindowsCompletionLinear in the number finishedKernel 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 with FD_SET overflows the bitmap, and this is a real production bug: raise ulimit -n, grow past 1,024 connections, and a forgotten library that calls select() starts corrupting memory or aborting. (Windows differs: its select limits 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.

idle socketchecked by this callhas datahanded back to you
Copied into the kernel
the whole bitmap, every call
Largest descriptor
1023 (FD_SETSIZE)
Step 1 of 3
1

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

CallWhat 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 epoll man 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.

Event loopone thread
epoll instancein the kernelinterest: none, ready: none
Clients
epoll_create1(0)
Step 1 of 5
1

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
TopicLevel-triggeredEdge-triggered
EventReported for as long as it is readyReported on a state change
HandlingRelatively easyNeeds careful handling
Non-blocking socketHelpful (recommended)Essential
Reading dataStep by step is possibleUsually drain until EAGAIN
RiskExtra notifications and system callsMissed events: hung connections
Used byRedis, libuvNginx, 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.

Event loop
epollin the kernelbuffer: 1000 bytes
Client
1000 bytes
Step 1 of 4
1

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 EPOLLOUT spin. A socket is writable almost always. Leave EPOLLOUT on and epoll_wait returns 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:

ApproachHowStrengthWeakness
Accept mutexWorkers take turns watching the socket, in user spaceWorks everywhereAdds latency. Nginx’s accept_mutex is now off by default
EPOLLEXCLUSIVE (Linux 4.5)One shared socket; wake at least one waiter, not allA stuck worker traps nobody; a free worker takes the connectionUneven: 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 oneNo herd, no shared lock, near-even spreadA 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.

New connection
Shared socket :443
Worker 1asleep
Worker 2asleep
Worker 3asleep
Step 1 of 3
1

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:

LimitScopeWhere to setError
Soft RLIMIT_NOFILEProcess, often 1024ulimit -Sn, LimitNOFILE= in systemd, --ulimit nofile= in DockerEMFILE
Hard RLIMIT_NOFILEProcess, ceiling for the soft limitulimit -Hn, the same settingsEMFILE
fs.nr_openLargest value any process may set, often 1,048,576sysctlSetting the limit fails
fs.file-maxThe whole machinesysctlENFILE
fs.epoll.max_user_watchesRegistered epoll entries per usersysctlENOSPC 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 EMFILE spin. accept fails, 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 in CLOSE_WAIT until the limit is reached, and raising the limit only delays the outage. A growing CLOSE_WAIT count 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.

Event loopulimit -n = 10241,024 descriptors open
Kernelaccept queue
Clients
connection 1,020
Step 1 of 3
1

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:

  • select and poll always report a regular file as readable and writable.
  • epoll_ctl on a regular file refuses with EPERM.
  • O_NONBLOCK has no effect on it, so you never get EAGAIN.

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:

ApproachHowNotes
Thread poolHand the read to a helper thread, get notified when it endsThe classic: libuv’s pool for Node.js file operations, aio threads in Nginx. Simple and portable
io_uringSubmit the read and collect the resultReal asynchronous file I/O, also for buffered reads
preadv2 with RWF_NOWAITRead only if it will not block; otherwise returns EAGAIN, and you fall back to the poolSaves a thread hop when the data is cached
sendfile (for sending files to sockets)Kernel copies straight from page cache to socketAvoids 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.

Event loop
Kernelpage cache
Disk
epoll_ctl(EPOLL_CTL_ADD, file_fd)
Step 1 of 4
1

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/OYou, in a further system callThe kernel
Buffer neededOnly when the socket is readyFrom the moment you start the operation
Works on filesNoYes
Examplesselect, poll, epoll, kqueueIOCP, 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_data value of your choice.
  • The completion queue (CQ), where the kernel writes results. Each entry (CQE) has your user_data and 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_enter call. 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.

Your program
Shared ringsSQ + CQ
Kernel
epoll_wait: 3 sockets ready
Step 1 of 2
1

The kernel tells you that three sockets are ready. No data has moved yet.

#How to choose

SituationReasonable choiceWhy
Internal service, hundreds of connections, mostly CPU or database workBlocking threads and a poolSimple code; the wait is on a database anyway, and its pool limits you first
Chat, push, long polling, tens of thousands of idle connectionsEvent loop with epoll or kqueueIdle connections must cost almost nothing
Proxy, load balancer or gatewayOne event loop per coreNearly all waiting, little CPU per request
Lots of blocking-style business code and many connectionsGoroutines or Java virtual threadsSimple code, runtime multiplexes onto epoll (lesson 3.6)
Storage engine or database with very high I/O ratesio_uring, if allowed, with a fallbackBatching and real file asynchrony pay off
Cross-platform librarylibuv, or your language’s runtimeHides 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

SymptomLikely causeWhat to do
Random crash or “buffer overflow detected” after raising ulimit -nA library uses select() with a descriptor of 1024 or moreReplace with poll or epoll; audit dependencies
One core at 100%, no trafficBusy loop: EPOLLOUT left on, or a socket at EOF not closed, or an EMFILE spinstrace the process and see what repeats
Connections hang, CPU idle, no errorsET read loop that stopped before EAGAINDrain, or switch to level-triggered
Too many open filesLimit too low, or a leakCompare the count with /proc/<pid>/limits; look at CLOSE_WAIT
Latency spikes on all connections at onceBlocking call on the loop: disk read, DNS, heavy CPUMove to a pool; measure loop lag (lesson 3.6)
One worker hot, others idleShared socket wake-up favouritismSO_REUSEPORT
p99 latency poor while workers look idleSO_REUSEPORT queues piling up behind one stuck workerFind the blocking call; a shared socket avoids the trap
Truncated responses to slow clientsA partial write ignoredKeep the remainder; wait for EPOLLOUT
io_uring EPERM in containers onlySeccomp profile or kernel.io_uring_disabledFall 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. epoll is 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 EAGAIN or EWOULDBLOCK rather than wait. You must then handle short reads, partial writes, explicit state and backpressure.
  • select is limited by FD_SETSIZE (typically 1024, and it limits the descriptor number) and scans everything; poll drops the limit but still scans. epoll and kqueue keep the list in the kernel, so the wait cost follows ready sockets. IOCP is completion-based, not readiness-based.
  • epoll is 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 EAGAIN with a fairness cap. Missing that gives silent hangs; leaving EPOLLOUT on in LT gives a 100% CPU spin.
  • For many workers on a port: a plain shared socket gives a thundering herd, EPOLLEXCLUSIVE limits wake-ups but is uneven, and SO_REUSEPORT spreads evenly but traps connections behind a stuck worker.
  • Know your descriptor limits (LimitNOFILE, fs.nr_open, fs.file-max), expect EMFILE, and treat growing CLOSE_WAIT as a leak.
  • Regular files are always “ready”, so a cold disk read freezes an event loop. Use a thread pool or io_uring.
  • io_uring uses shared rings for batching, real file asynchrony and few system calls. It is often blocked by seccomp, so always keep a fallback.

#Self-check