Asynchronous I/O and io_uring
You have an NVMe SSD that can complete a million I/Os per second. Can one program actually drive
it that hard? With the classic toolkit — a
blocking
\texttt{read()} per request — the answer is no, and the reason is the
syscall. Each blocking call crosses into the kernel (hundreds of cycles), does one I/O,
and comes back. A million I/Os means a million crossings; the CPU spends its life on boundary-crossing
overhead, not work. This lesson is the story of how the interface between a program and the kernel was
redesigned — culminating in io_uring — to make that overhead nearly vanish.
The theme threads back to the very first lesson of the course: "avoid the syscall." Here it becomes an
engineering discipline. We'll see why threads-plus-blocking doesn't scale, why POSIX AIO disappointed, why
\texttt{epoll} solves a different problem than you might think, and how
shared ring buffers finally let a program submit thousands of I/Os and reap thousands of completions with
one system call — or none.
Three failed or partial answers
Before io_uring, three approaches each solved part of the problem and left a gap:
- Thread-per-request + blocking I/O. Give each in-flight I/O its own thread that blocks
while its \texttt{read()} runs. Simple, but a thread costs ~1–2 MB of stack
and real scheduler and context-switch overhead; ten thousand concurrent I/Os means ten thousand
threads thrashing the scheduler. It doesn't scale (the "C10K/C10M" problem).
- POSIX AIO. The standard asynchronous API — but on Linux it was largely implemented
with a hidden thread pool doing blocking calls anyway, had a clumsy interface, worked poorly
for buffered files, and never delivered the promised performance. Effectively dead on arrival.
- epoll (readiness). The scalable heart of every event loop — but note carefully what
it does. \texttt{epoll} is readiness-based: it tells you
"socket X is now readable," and then you still issue a (non-blocking)
\texttt{read()} yourself. That works beautifully for sockets but poorly for
files, which are essentially always "ready" — the cost isn't waiting for readiness, it's the
data transfer itself. epoll can't make disk I/O asynchronous.
This is the conceptual crux of the whole lesson. A readiness model
(\texttt{select}, \texttt{poll},
\texttt{epoll}) tells you when you may start an operation without
blocking — then you perform it. A completion model (Windows IOCP, io_uring) is the
opposite: you start the operation immediately, and the kernel tells you when it is
finished, having already moved the data. For sockets, readiness is natural (you wait for a packet
to arrive). For disk files there is no meaningful "ready" moment — the operation simply takes time —
so only a completion model can make file I/O truly asynchronous. io_uring is Linux finally
getting a first-class completion interface, decades after IOCP.
io_uring: two rings in shared memory
io_uring's insight is to stop passing I/O requests through the syscall boundary one at a time,
and instead put them in memory that both the application and the kernel can see. It sets
up two circular buffers, mapped into the process by \texttt{mmap}:
- the Submission Queue (SQ) — the app writes I/O requests (opcode, fd, buffer, offset)
into free slots;
- the Completion Queue (CQ) — the kernel writes results (which request, return value)
into slots the app then reads.
Because the rings are shared memory, adding a request is just a memory write — no syscall. The
app can queue up hundreds of I/Os and then make one
\texttt{io\_uring\_enter()} call to tell the kernel "go" — amortising a single
boundary crossing over the entire batch. Completions likewise appear in the CQ with no per-I/O syscall to
collect them. And in SQPOLL mode a kernel thread polls the SQ itself, so a busy app can
submit and complete I/O with zero system calls at all — the boundary crossing is gone
entirely.
- it is a completion-based async interface (start now, told when finished) — unlike
readiness-based epoll;
- two shared-memory ring buffers (SQ, CQ) let the app and kernel exchange requests
and results without copying or per-op syscalls;
- one \texttt{io\_uring\_enter()} batches many submissions;
SQPOLL can reach zero syscalls under load;
- it works for files and sockets alike, fixing the gaps left by POSIX AIO and epoll.
Count the syscalls saved by batching
The model contrasts one-syscall-per-I/O (blocking \texttt{read()}) with
io_uring's batched submission: N I/Os submitted B at
a time cost \lceil N/B \rceil calls — and SQPOLL costs zero.
// Syscalls to perform N I/Os three ways.
const N = 100_000; // number of I/O operations
const batch = 256; // io_uring submission batch size
const blocking = N; // one read() per I/O
const uringBatched = Math.ceil(N / batch); // one io_uring_enter() per batch
const uringSqpoll = 0; // kernel poller: no enter() syscalls at all
const pct = (x: number) => (100 * (1 - x / blocking)).toFixed(3);
console.log(`I/O operations: ${N.toLocaleString()}`);
console.log(`blocking read(): ${blocking.toLocaleString()} syscalls`);
console.log(`io_uring (batch ${batch}): ${uringBatched.toLocaleString()} syscalls (${pct(uringBatched)}% fewer)`);
console.log(`io_uring + SQPOLL: ${uringSqpoll} syscalls (100% fewer)`);
console.log(`\nAt ~1000 cycles per boundary crossing, blocking spends ~${(blocking * 1000 / 1e6).toFixed(0)}M cycles`);
console.log(`just crossing into the kernel; batched io_uring spends ~${(uringBatched * 1000 / 1e6).toFixed(2)}M.`);
A frequent misread is "async I/O just means the kernel spins up threads to do my blocking reads in the
background." That is precisely the old POSIX-AIO trick, and precisely what io_uring
avoids. Real completion-based async I/O submits the request to the device and lets
hardware (DMA + the device's own queues) do the work while your single thread moves on; the
completion is a data structure the kernel fills, not a thread that woke up. The win is doing more I/O with
fewer threads and fewer context switches, not more. If your "async" framework is secretly a
thread pool running blocking syscalls, you've reintroduced every cost you were trying to escape — the
scheduler churn, the stacks, the context switches. The whole point is to stop paying per-operation thread
and syscall overhead, not to relocate it.
The extreme: leaving the kernel behind entirely
io_uring makes the syscall cheap. The logical endpoint is to remove the kernel from the data path
altogether. Frameworks like SPDK (storage) and DPDK
(networking) do exactly this kernel bypass: they map the NVMe device's queues directly
into a user-space process and run the driver in user space, so a request goes from application to hardware
with no kernel involvement, no interrupts, and a busy-polling loop reaping completions. A single
core can then push millions of IOPS at the theoretical limit of the device.
The cost is that you give up everything the kernel provided — the file system, permissions, sharing the
device between processes, the general-purpose scheduler. Kernel bypass is a specialist's tool for
dedicated storage/networking appliances, not a general application technique. But it completes the arc of
this module beautifully: from "call the kernel for every byte" to "map the hardware into your address
space and never call the kernel at all." The whole history of fast I/O is the history of getting the
operating system out of the way — carefully.