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:

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}:

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.

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.