Table of Contents
In the autumn of 2002, the Linux kernel received a patch from Davide Libenzi that changed the topology of the internet. It was called epoll.
Until that moment, web servers buckled under the weight of ten thousand concurrent connections—the legendary C10K problem. Traditional system calls like select() and poll() were linear scavengers: to find out which socket had received a packet, the kernel had to traverse an $O(N)$ array of file descriptors on every single wake-up. epoll replaced this linear scan with an in-kernel red-black tree and an active ready-list. Sockets registered once; when packets arrived at the network interface card (NIC), hardware interrupts queued readiness events into the ready-list in $O(1)$ time.
For twenty years, epoll was the load-bearing pillar beneath Nginx, Redis, Node.js (libuv), and Netty. But in high-throughput modern infrastructure—systems pushing ten million queries per second over 100GbE NICs and NVMe-over-Fabrics storage arrays—epoll has hit an insurmountable physical wall.
The problem is not that epoll is slow. The problem is that epoll is fundamentally a readiness notification engine, not an asynchronous execution engine.
To service a single client request under epoll, an application still has to execute multiple synchronous system calls: epoll_wait() to learn that data is ready, read() to pull bytes across the kernel boundary, and write() to push the response back out. At line rate, the CPU ceases to be a computational engine; it becomes a traffic cop spending its life trapped in the toll booths of kernel context transitions.
Enter io_uring. Introduced by Jens Axboe in Linux 5.1, io_uring re-architects Linux I/O from first principles by eliminating system calls entirely from the hot path.
1. The Epoll Scalability Wall (The C10M Barrier)
To understand why epoll cannot conquer ten million queries per second, examine the anatomy of a single x86_64 system call under modern CPU microarchitectures.
When userspace invokes read() or write(), the CPU executes a syscall instruction. Hardware transitions from Ring 3 (unprivileged user space) to Ring 0 (kernel space). Registers are saved to the thread's kernel stack. The kernel switches address spaces. Since the Meltdown and Spectre hardware mitigations (KPTI), page table isolation forces additional TLB (Translation Lookaside Buffer) flushes.
| Syscall Overhead Phase | Hardware Cycle Cost (x86_64) | Physical Mechanism |
|---|---|---|
| Trap & Privilege Transition | ~150 – 300 cycles | sys_enter / sys_exit hardware transition |
| Kernel Page Table Isolation (KPTI) | +200 – 400 cycles | CR3 register reload to swap user/kernel page tables |
| L1/L2 Cache & TLB Eviction | +500 – 1500 cycles | Memory access latency while reloading cold lines |
| Total Syscall Tax | ~1.2 – 2.5 microseconds | Incurred per invocation, 2–4 times per network transaction |
At one million requests per second, a server spending 2 microseconds per round trip loses over 60% of its total CPU execution capacity purely transitioning back and forth across the privilege boundary before executing a single line of business logic.
2. The io_uring Architecture: Shared Ring Geometry
io_uring eliminates this tax by abandoning the concept of per-operation system calls. Instead of asking the kernel to perform an operation immediately, userspace and kernel space communicate through two lock-free circular ring buffers mapped directly into shared memory via mmap():
USER SPACE (Ring 3) KERNEL SPACE (Ring 0)
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ Submission Queue (SQ Ring) │ │ Completion Queue (CQ Ring) │
│ User App appends new SQEs │ │ Kernel appends new CQEs │
└──────────────┬────────────────┘ └───────────────▲───────────────┘
│ │
└─────────────►[ SQPOLL Thread ]───────┘
Kernel worker reaps SQ
without syscall interrupts
The Invariants of the Submission and Completion Rings
- Submission Queue (SQ): The user application writes Submission Queue Entries (SQEs) describing the requested operation (
IORING_OP_READV,IORING_OP_WRITEV,IORING_OP_ACCEPT). The application updatessq.tail. The kernel consumes entries fromsq.head. - Completion Queue (CQ): When hardware DMA transfers complete, the kernel writes Completion Queue Entries (CQEs) to the completion ring, advancing
cq.tail. The user application reaps results fromcq.head. - Lock-Free Single-Producer Single-Consumer: Because only userspace mutates
sq.tailandcq.head, and only the kernel mutatessq.headandcq.tail, both rings operate completely lock-free without spinlocks or mutex contention.
3. High-Throughput Kernel Modes: SQPOLL and Fixed Buffers
To push past the 10-million QPS threshold, io_uring introduces three architectural capabilities:
1. Kernel Polling (IORING_SETUP_SQPOLL)
When initialized with the IORING_SETUP_SQPOLL flag, the kernel spawns a dedicated kernel thread (io_uring-sq) pinned to an isolated CPU core. This thread continuously polls the shared Submission Queue ring.
The application simply writes SQEs into shared RAM and updates the atomic tail index with a release memory barrier. The kernel picks up the work immediately. The total number of system calls executed to service millions of requests drops to exactly zero.
2. Registered Zero-Copy Buffers (IORING_REGISTER_BUFFERS)
In standard POSIX I/O, the kernel must validate user virtual memory pointers, lock the underlying physical pages into RAM (get_user_pages()), and build scatter-gather lists for the NIC's DMA engine on every call.
With io_uring, the application pre-registers a pool of memory buffers during startup. The kernel locks the pages and pins the physical page-table mappings once. Future reads and writes skip all page validation and DMA setup overhead.
3. Registered File Descriptors (IORING_REGISTER_FILES)
Every time an application calls read(fd) or write(fd), the kernel must acquire a reference lock on the file descriptor table (fget() / fput()). When thousands of threads access shared sockets, lock contention on the descriptor table degrades multicore throughput. Pre-registering file arrays into the ring instance replaces table lookups with direct array indexing.
Python Simulation: The Lock-Free Ring Engine
The following Python script models the memory geometry and head/tail index mechanics of an io_uring event loop operating with a simulated kernel SQPOLL worker:
from dataclasses import dataclass
from typing import List, Optional
import time
@dataclass
class SQE:
opcode: str
fd: int
payload: bytes
user_tag: int
@dataclass
class CQE:
user_tag: int
bytes_transferred: int
status: int
class LockFreeRing:
"""
Simulates a power-of-two circular ring buffer mapped into shared memory.
"""
def __init__(self, capacity: int = 16):
self.capacity = capacity
self.mask = capacity - 1
self.entries: List[Optional[any]] = [None] * capacity
self.head = 0
self.tail = 0
def push(self, item: any) -> bool:
if (self.tail - self.head) >= self.capacity:
return False # Ring is full
self.entries[self.tail & self.mask] = item
self.tail += 1
return True
def pop(self) -> Optional[any]:
if self.head >= self.tail:
return None # Ring is empty
idx = self.head & self.mask
item = self.entries[idx]
self.entries[idx] = None
self.head += 1
return item
class IOUringEngine:
"""
Userspace event loop communicating with a simulated kernel SQPOLL worker.
"""
def __init__(self):
self.sq = LockFreeRing(capacity=32)
self.cq = LockFreeRing(capacity=32)
def submit_request(self, opcode: str, fd: int, data: bytes, tag: int) -> bool:
sqe = SQE(opcode=opcode, fd=fd, payload=data, user_tag=tag)
return self.sq.push(sqe)
def kernel_sqpoll_tick(self) -> int:
reaped = 0
while True:
sqe: Optional[SQE] = self.sq.pop()
if not sqe:
break
# Kernel hardware DMA transfer simulation
transferred = len(sqe.payload)
cqe = CQE(user_tag=sqe.user_tag, bytes_transferred=transferred, status=0)
self.cq.push(cqe)
reaped += 1
return reaped
def reap_completion(self) -> Optional[CQE]:
return self.cq.pop()
# Demonstration Run
if __name__ == "__main__":
engine = IOUringEngine()
# Userspace submits a batch of operations without invoking syscalls
print("Enqueuing batch of 3 async requests into SQ ring...")
engine.submit_request("WRITE", fd=10, data=b"HTTP/1.1 200 OK\r\n\r\n", tag=101)
engine.submit_request("READ", fd=11, data=b"0" * 4096, tag=102)
engine.submit_request("WRITE", fd=12, data=b"{\"status\":\"active\"}", tag=103)
# Simulated kernel worker reaps entries asynchronously
reaped = engine.kernel_sqpoll_tick()
print(f"Kernel worker reaped {reaped} requests via shared ring buffer.")
# Userspace harvests completions
while True:
cqe = engine.reap_completion()
if not cqe:
break
print(f"Reaped Completion: Tag {cqe.user_tag} | Transferred: {cqe.bytes_transferred} bytes")
Architectural Comparison: epoll vs io_uring
| Performance Dimension | epoll Event Loop | io_uring Engine (SQPOLL) |
|---|---|---|
| I/O Paradigm | Readiness notification (synchronous read) | Asynchronous completion (kernel DMA) |
| Syscall Overhead per Transaction | 2 – 4 system calls | Zero system calls |
| Storage (Disk) I/O Support | Synchronous only (requires worker threads) | Unified async support across network and NVMe |
| Buffer Management | User-to-kernel copies on every invocation | Pre-registered zero-copy memory buffers |
| Scalability Limit | ~2.5 Million QPS per core | > 10 Million QPS per core |
The Paradigm Shift
epoll solved the connection scale problem: it allowed a single thread to observe millions of idle connections without melting the CPU.
io_uring solves the throughput density problem: it allows a single CPU core to saturate modern 100-gigabit network interfaces and PCIe Gen5 NVMe arrays by dismantling the syscall barrier.
For high-throughput systems architects, the era of polling readiness is over. The era of lock-free shared memory ring execution has arrived.

Discussion & Comments