How io_uring Actually Works: Zero-Syscall Asynchronous I/O in the Linux Kernel
Try the interactive lab for this articleTake the quiz (6 questions)For more than two decades, high-performance Linux network servers and storage engines relied on event-driven architectures anchored by epoll. The programming model was familiar: register file descriptors with the kernel, sleep in epoll_wait(), receive readiness notifications when a socket became readable or writable, and execute non-blocking system calls such as read(), write(), recvmsg(), or sendmsg().
While this model scaled gracefully to tens of thousands of idle network connections, it introduced structural bottlenecks as hardware evolved. Modern Non-Volatile Memory Express (NVMe) solid-state drives sustain millions of input/output operations per second (IOPS) with random access latencies falling below 10 microseconds. High-speed network interfaces deliver 100 to 400 gigabits per second, translating to a packet arrival rate of over 140 million frames per second.
Under these workloads, the fundamental cost of transitioning between CPU protection rings (Ring 3 user space to Ring 0 kernel space) dominates execution time. Every conventional system call triggers hardware context switching, register spilling, kernel stack switching, translation lookaside buffer (TLB) pollution, and post-Spectre/Meltdown page table isolation (KPTI) mitigation overhead. Furthermore, Linux never offered a truly asynchronous interface for local disk files: POSIX aio_read() and aio_write() were implemented using user-space thread pools in glibc, while kernel asynchronous I/O (io_submit) required unbuffered direct I/O (O_DIRECT), lacked socket support, and frequently blocked on metadata allocations in the filesystem journal.
The io_uring subsystem, authored by Jens Axboe and merged in Linux 5.1, resolved these systemic limitations. Rather than passing operations through synchronous system calls or readiness queues, io_uring establishes lock-free, circular ring buffers allocated by the kernel and mapped directly into user-space memory. Applications submit batches of complex I/O requests and reap completions simply by reading and writing shared memory pointers, eliminating system call overhead entirely under sustained load.
This article examines the internal architecture, memory layouts, concurrency primitives, and hardware interactions of io_uring. We trace the lifecycle of an I/O request from memory allocation to hardware completion, analyze lock-free memory barrier invariants, evaluate kernel polling thread mechanics, and inspect zero-copy network dispatching.
+-----------------------------------------------------------------------------------+
| CONVENTIONAL EPOLL VS IO_URING |
+-----------------------------------------------------------------------------------+
| |
| 1. CONVENTIONAL EPOLL / SYSCALL MODEL |
| User Space Kernel Space Hardware (NVMe / NIC) |
| | | | |
| |-- epoll_wait() ->| (Context switch, sleep/wake) | |
| |<-- fd ready -----| | |
| |-- read(fd, buf)->| (Context switch, buffer copy) | |
| | |-- DMA Command --------------------->| |
| | |<- DMA Interrupt --------------------| |
| |<-- bytes read ---| (Context switch back to User) | |
| |
| 2. IO_URING SHARED-MEMORY RING MODEL |
| User Space (Shared Memory) Kernel (SQPOLL / Worker) Hardware |
| | | | |
| |-- Write SQE to Ring Buffer | | |
| |-- smp_store_release(SQ tail) | | |
| | (ZERO SYSCALL DISPATCH) |-- Polls SQ Ring | |
| | |-- Issues NVMe DMA ---->| |
| | |<- Hardware IRQ / Poll -| |
| | |-- Writes CQE to Ring | |
| | |-- Updates CQ tail | |
| |<-- Reads CQE from Ring -------| | |
| | (ZERO SYSCALL REAPING) | |
| |
+-----------------------------------------------------------------------------------+The Linux I/O Evolution and System Call Tax
To understand why io_uring was designed with its specific dual-ring topology, one must quantify the computational tax imposed by classical UNIX system calls.
Protection Ring Switching and Speculative Execution Mitigations
A classical system call on x86-64 processors is initiated via the syscall instruction. The CPU performs several sequential hardware operations:
- Saves the current Instruction Pointer (
RIP) to theRCXregister. - Saves the processor flags (
RFLAGS) to theR11register. - Loads the kernel entry point address from the Model-Specific Register
IA32_LSTARintoRIP. - Masks processor flags using the mask stored in
IA32_FMASK. - Transitions the privilege level from CPL=3 (Ring 3) to CPL=0 (Ring 0).
- Switches the stack pointer (
RSP) from the user-space stack to the per-thread kernel stack defined in the Task State Segment (TSS).
Upon entry, the kernel saves general-purpose registers (RAX, RDI, RSI, RDX, R10, R8, R9, etc.) onto the kernel stack, validates pointer arguments, checks security boundaries, and routes the request through the Virtual Filesystem (VFS) switch table.
Before speculative execution mitigations were introduced in 2018, an x86-64 system call round-trip cost approximately 50 to 70 nanoseconds on modern hardware. Following the integration of Kernel Page Table Isolation (KPTI) to mitigate Meltdown (CVE-2017-5754) and Return Trampolines (retpolines) to mitigate Spectre Variant 2 (CVE-2017-5715), every transition between user space and kernel space requires flipping the processor CR3 register to swap page tables and avoiding indirect branch predictions. This increased the baseline round-trip latency of an empty system call to between 150 and 250 nanoseconds.
When an application performs 1,000,000 I/O operations per second, spending 200 nanoseconds per system call consumes 200 milliseconds of raw CPU time every second. That is 20 percent of a physical CPU core dissipated purely on protection domain switching, before executing a single line of application logic or device driver code.
Readiness Models vs Completion Models
UNIX network I/O historically developed around the readiness model, exemplified by select(), poll(), and epoll(). In a readiness model, the kernel notifies the application when an underlying resource can perform I/O without blocking:
// Classical readiness loop
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
ssize_t bytes = read(events[i].data.fd, buffer, sizeof(buffer));
process_payload(buffer, bytes);
}
}The readiness model exhibits three fundamental architectural deficiencies:
- Two-phase execution: The application must first wait for readiness notification via
epoll_wait(), and then execute a second system call (read()orwrite()) to perform the actual data transfer. Even with batching inepoll_wait(), every transfer requires separate execution. - Inapplicability to disk storage: Regular files on block storage devices always report as ready in
epoll. The kernel cannot predict whether the requested data page resides in the page cache or requires an electro-mechanical or solid-state disk read. Consequently, callingread()on a regular file descriptor can block the calling thread for milliseconds, stalling the entire event loop. - Double buffering: The kernel reads data from the hardware controller into kernel page cache buffers, and then copies those bytes across the user-kernel boundary into the user-space destination buffer.
Windows NT adopted a completion model in 1993 via I/O Completion Ports (IOCP). In a completion model, the application requests an operation and supplies the destination buffer immediately. The operating system handles execution asynchronously and notifies the application only when the transfer has finished.
Prior to Linux 5.1, the Linux kernel lacked a unified, high-performance completion model that functioned across both network sockets and block storage filesystems. Linux AIO (io_submit and io_getevents) was limited: it only functioned on file descriptors opened with O_DIRECT, required 64-bit aligned memory buffers, generated synchronous blocking behavior when allocating filesystem extents in ext4 or XFS, and failed completely on network sockets, pipes, and character devices.
Dual-Ring Shared Memory Architecture
io_uring replaces repetitive system call dispatch with a lock-free, shared-memory circular ring buffer architecture. The design decouples submission from completion through two primary rings:
- Submission Queue (SQ): A ring buffer where user space produces requests (Submission Queue Entries, or SQEs) and the kernel consumes them.
- Completion Queue (CQ): A ring buffer where the kernel produces completion results (Completion Queue Entries, or CQEs) and user space consumes them.
+-----------------------------------------------------------------------------------+
| IO_URING SHARED-MEMORY RING LAYOUT |
+-----------------------------------------------------------------------------------+
| |
| USER SPACE APPLICATION |
| | |
| | 1. Writes SQE at index (tail & mask) |
| | 2. sq_ring->array[tail & mask] = sqe_index |
| | 3. smp_store_release(&sq_ring->tail, tail + 1) |
| v |
| +-----------------------------------------------------------------------------+ |
| | SUBMISSION QUEUE (SQ) RING BUFFER (Mapped into User & Kernel Memory) | |
| | | |
| | Head (Kernel reads) Tail (User writes) | |
| | | | | |
| | v v | |
| | +-------+-------+-------+-------+-------+-------+-------+-------+ | |
| | | SQE 0 | SQE 1 | SQE 2 | ... | | | | | | |
| | +-------+-------+-------+-------+-------+-------+-------+-------+ | |
| | [0] [1] [2] [3] [4] [5] [6] [7] | |
| +-----------------------------------------------------------------------------+ |
| |
| KERNEL CONSUMPTION & DISPATCH |
| | |
| | smp_load_acquire(&sq_ring->tail) |
| | Advances sq_ring->head |
| | Executes Block / Network I/O |
| v |
| +-----------------------------------------------------------------------------+ |
| | COMPLETION QUEUE (CQ) RING BUFFER (Mapped into User & Kernel Memory) | |
| | | |
| | Head (User reads) Tail (Kernel writes) | |
| | | | | |
| | v v | |
| | +-------+-------+-------+-------+-------+-------+-------+-------+ | |
| | | CQE 0 | CQE 1 | ... | | | | | | | |
| | +-------+-------+-------+-------+-------+-------+-------+-------+ | |
| | [0] [1] [2] [3] [4] [5] [6] [7] | |
| +-----------------------------------------------------------------------------+ |
| ^ |
| | 4. Reads CQE at index (head & mask) |
| | 5. smp_store_release(&cq_ring->head, head + 1) |
| USER CONSUMPTION |
| |
+-----------------------------------------------------------------------------------+The io_uring_setup() System Call
An application initializes an io_uring instance via a single system call:
#include <linux/io_uring.h>
#include <sys/syscall.h>
int io_uring_setup(u32 entries, struct io_uring_params *p);The entries argument dictates the number of submission queue entries the application intends to manage simultaneously. The kernel validates this value, rounding it up to the nearest power of two, bounded by limits defined in /proc/sys/fs/io_uring_entries_max (defaulting to 32,768).
The struct io_uring_params structure passes configuration flags to the kernel and receives memory offset definitions:
struct io_uring_params {
__u32 sq_entries;
__u32 cq_entries;
__u32 flags;
__u32 sq_thread_cpu;
__u32 sq_thread_idle;
__u32 features;
__u32 wq_fd;
__u32 resv[3];
struct io_sqring_offsets sq_off;
struct io_cqring_offsets cq_off;
};Upon return, the kernel provides two critical sets of byte offsets: sq_off and cq_off. These structure fields inform user space where the control variables (head, tail, ring mask, flags) reside within the mapped memory region.
Memory Mapping Rings via mmap()
The io_uring_setup() system call returns a standard file descriptor. However, user space does not read or write this file descriptor with traditional syscalls. Instead, user space calls mmap() three times (or twice in kernels 5.4+ using single-mmap mode) to project the kernel buffers directly into the application process virtual address space:
struct io_uring_params p;
memset(&p, 0, sizeof(p));
int ring_fd = syscall(__NR_io_uring_setup, entries, &p);
// 1. Map Submission Queue Ring control structures
void *sq_ptr = mmap(0, p.sq_off.array + p.sq_entries * sizeof(__u32),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring_fd, IORING_OFF_SQ_RING);
// 2. Map Submission Queue Entries array
struct io_uring_sqe *sqes = mmap(0, p.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring_fd, IORING_OFF_SQES);
// 3. Map Completion Queue Ring control structures and entries
void *cq_ptr = mmap(0, p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring_fd, IORING_OFF_CQ_RING);By mapping these structures into shared memory:
- The application can compose and submit requests by writing directly to
sqesin its own address space. - The kernel reads the requests directly from host physical RAM.
- The kernel writes completion records into
cq_ptr. - The application reads completions directly from memory.
Zero memory copying occurs across the user-kernel boundary for the control path.
Binary Data Structures: SQE and CQE
The protocol between user space and kernel space is defined by two binary data structures: the Submission Queue Entry (struct io_uring_sqe) and the Completion Queue Entry (struct io_uring_cqe).
Submission Queue Entry Layout (64 Bytes)
An SQE represents a single operation command (e.g. read, write, accept, connect, splice). It is engineered to fit into exactly 64 bytes, aligning with an x86-64 and ARM64 L1 cache line:
struct io_uring_sqe {
__u8 opcode; /* Type of operation (e.g. IORING_OP_READV) */
__u8 flags; /* Modifier flags (e.g. IOSQE_IO_LINK) */
__u16 ioprio; /* I/O priority (class and level) */
__s32 fd; /* File descriptor to operate on */
union {
__u64 off; /* Offset into file */
__u64 addr2;
};
union {
__u64 addr; /* Pointer to buffer or iovec array */
__u64 splice_off_in;
};
__u32 len; /* Buffer length or number of iovecs */
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
__u16 poll_events;
__u32 sync_range_flags;
__u32 msg_flags;
__u32 timeout_flags;
__u32 accept_flags;
__u32 cancel_flags;
__u32 open_flags;
__u32 statx_flags;
__u32 fadvise_advice;
__u32 splice_flags;
__u32 rename_flags;
__u32 unlink_flags;
__u32 hardlink_flags;
};
__u64 user_data; /* User-defined 64-bit cookie passed to CQE */
union {
struct {
union {
__u16 buf_index; /* Index into fixed buffer table */
__u16 buf_group; /* Buffer group for provided buffers */
};
__u16 personality; /* Credentials personality */
__s32 splice_fd_in;
};
__u64 optval;
__u8 cmd[0]; /* Raw pass-through commands */
};
};Key fields in io_uring_sqe:
opcode: A 1-byte identifier specifying the operation. Examples includeIORING_OP_NOP(0),IORING_OP_READV(1),IORING_OP_WRITEV(2),IORING_OP_FSYNC(3),IORING_OP_POLL_ADD(6),IORING_OP_SENDMSG(9),IORING_OP_RECVMSG(10),IORING_OP_ACCEPT(13), andIORING_OP_SPLICE(25).flags: Modifiers that govern execution.IOSQE_IO_LINKlinks this SQE to the next SQE, forming an atomic chain.IOSQE_ASYNCforces the kernel to dispatch the operation to an asynchronous worker thread immediately.IOSQE_FIXED_FILEinstructs the kernel to look up the file descriptor in a pre-registered array rather than callingfget()on the process file descriptor table.user_data: An arbitrary 64-bit integer. The kernel does not parse or modify this value. When the operation completes, this exact 64-bit integer is copied into the resulting CQE. Applications use this field to store memory pointers to state machines, connection contexts, or C++ coroutine frames.
Completion Queue Entry Layout (16 Bytes)
A CQE represents the completion status of a submitted request. It is compact, occupying exactly 16 bytes:
struct io_uring_cqe {
__u64 user_data; /* Mirrored directly from the corresponding SQE */
__s32 res; /* Result code: bytes transferred or -errno */
__u32 flags; /* Completion flags (e.g. IORING_CQE_F_MORE) */
};Key fields in io_uring_cqe:
user_data: Identifies which operation finished by matching theuser_datafield from the initiating SQE.res: Replaces the return value of a classical system call. For a read or write operation, a non-negative integer indicates the exact number of bytes transferred. A negative integer indicates failure, representing-errno(e.g.-EAGAIN,-EBADF,-ECONNRESET). The application checksres < 0rather than inspecting thread-localerrno.flags: Operational metadata. For multishot operations (such as multishot accept or multishot poll), the kernel sets theIORING_CQE_F_MOREbit to notify user space that this single SQE will continue producing additional CQEs without requiring re-submission.
Because CQEs are only 16 bytes while SQEs are 64 bytes, the Completion Queue is typically configured with twice as many entries as the Submission Queue (cq_entries = sq_entries * 2) to prevent completion ring overflow during high-density concurrent operations.
Lock-Free Concurrency and Memory Barriers
Because both user space and the Linux kernel read and write the ring buffers simultaneously across separate CPU cores, the subsystem must synchronize without acquiring hardware-level bus locks or kernel mutexes.
Single Producer, Single Consumer Ring Model
Each ring operates on a Single-Producer Single-Consumer (SPSC) discipline:
- Submission Queue: User space is the exclusive Producer (updating
sq_ring->tail). The kernel is the exclusive Consumer (updatingsq_ring->head). - Completion Queue: The kernel is the exclusive Producer (updating
cq_ring->tail). User space is the exclusive Consumer (updatingcq_ring->head).
+-----------------------------------------------------------------------------------+
| SPSC RING POINTER OWNERSHIP & DISCIPLINE |
+-----------------------------------------------------------------------------------+
| |
| RING PRODUCER (Writes tail) CONSUMER (Reads/Writes head) |
| ------------------------------------------------------------------------------- |
| Submission (SQ) User Space Application Linux Kernel |
| Completion (CQ) Linux Kernel User Space Application |
| |
| RULE: The Producer ONLY increments tail. |
| RULE: The Consumer ONLY increments head. |
| RULE: Available items to consume = tail - head |
| RULE: Free space to produce = ring_entries - (tail - head) |
| |
+-----------------------------------------------------------------------------------+Because only one entity ever writes to each pointer, no atomic compare-and-swap (lock cmpxchg) instructions are necessary. The synchronization relies entirely on memory ordering barriers.
Submission Indirection Array
Unlike the Completion Queue, where entries are stored contiguously in the ring, the Submission Queue incorporates an indirection array:
sq_ring->array[tail & ring_mask] = sqe_index;This indirection layer allows applications to allocate SQEs in arbitrary internal orders while presenting them sequentially to the kernel. It also allows complex frameworks to submit operations without rearranging underlying 64-byte blocks in memory.
Acquire-Release Memory Ordering Invariants
On modern out-of-order processors (such as x86 with store buffers or ARM64 with relaxed memory consistency models), the CPU and optimizing compilers may reorder memory reads and writes. If a CPU updates the tail pointer before the writes to the 64-byte SQE structure have flushed to memory, the kernel could read partially initialized data, causing corrupted parameters or kernel page faults.
To guarantee determinism, io_uring enforces strict acquire-release semantics.
User-Space Submission Sequence:
// 1. Calculate next available index in the SQ ring
unsigned int tail = sq_ring->tail;
unsigned int index = tail & sq_ring->ring_mask;
// 2. Populate the 64-byte SQE structure
struct io_uring_sqe *sqe = &sqes[index];
sqe->opcode = IORING_OP_READV;
sqe->fd = target_fd;
sqe->addr = (uintptr_t)&iov;
sqe->len = 1;
sqe->off = file_offset;
sqe->user_data = request_cookie;
// 3. Set the indirection mapping
sq_ring->array[index] = index;
// 4. Publish the write using Release semantics
// Ensures the SQE fields and array updates are globally visible
// BEFORE the incremented tail pointer becomes visible to the kernel.
__atomic_store_n(&sq_ring->tail, tail + 1, __ATOMIC_RELEASE);Kernel Consumption Sequence:
// 1. Read the user tail pointer using Acquire semantics
// Guarantees that any reads of the SQE memory occur AFTER
// reading the updated tail pointer.
unsigned int tail = smp_load_acquire(&sq_ring->tail);
unsigned int head = sq_ring->head;
while (head != tail) {
unsigned int index = sq_ring->array[head & sq_ring->ring_mask];
struct io_uring_sqe *sqe = &sqes[index];
// Process the SQE...
process_sqe(sqe);
head++;
}
// 2. Publish updated head pointer back to user space
smp_store_release(&sq_ring->head, head);On x86-64, where hardware already enforces Total Store Order (TSO), memory stores are never reordered with other stores, making __ATOMIC_RELEASE compile to a simple assembly mov instruction (with a compiler barrier). On weakly ordered architectures like ARM64, this emits a dedicated stlr (store-release) instruction, avoiding heavy global memory pipeline flushes (dmb ish).
Dispatch and Execution Modes
Once requests are staged in the Submission Queue, io_uring provides three operational modes for kernel dispatch.
+-----------------------------------------------------------------------------------+
| IO_URING SUBMISSION MODES |
+-----------------------------------------------------------------------------------+
| |
| 1. DEFAULT MODE (io_uring_enter) |
| User updates SQ tail ---> Calls syscall io_uring_enter() ---> Kernel processes|
| (One syscall batches N operations; context switch amortized over batch) |
| |
| 2. SQPOLL MODE (Kernel Thread Polling) |
| User updates SQ tail ---> Kernel kthread detects update via memory polling |
| (ZERO system calls during steady state; zero context switches) |
| |
| 3. IOPOLL MODE (Hardware Polling) |
| Kernel polls NVMe completion queue registers directly |
| (Bypasses hardware interrupts entirely; lowest possible latency) |
| |
+-----------------------------------------------------------------------------------+1. Default Mode: Batched Entry via io_uring_enter()
In default mode, the application enqueues one or more SQEs into shared memory and then calls the io_uring_enter() system call:
int io_uring_enter(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,
sigset_t *sig);Parameters:
to_submit: Informs the kernel how many new entries are ready in the SQ ring.min_complete: Instructs the kernel whether to block the calling process until a minimum number of completions have posted to the CQ ring. Settingmin_complete = 0causesio_uring_enter()to consume the SQEs and return immediately without sleeping.flags: Operational bits, such asIORING_ENTER_GETEVENTS.
Even in default mode, io_uring delivers significant efficiency improvements over epoll. If an application enqueues 64 disk read requests, a single call to io_uring_enter(fd, 64, 0, 0, NULL) dispatches all 64 requests simultaneously. The 64 system calls of the classical model collapse into one.
2. SQPOLL Mode: The Zero-Syscall Engine
For ultra-low latency workloads, io_uring provides Submission Queue Polling (IORING_SETUP_SQPOLL).
When initialized with this flag:
struct io_uring_params p;
memset(&p, 0, sizeof(p));
p.flags = IORING_SETUP_SQPOLL;
p.sq_thread_idle = 2000; // Sleep after 2000ms of inactivity
p.sq_thread_cpu = 3; // Pin to CPU core 3
int ring_fd = syscall(__NR_io_uring_setup, entries, &p);The kernel spawns a dedicated kernel thread named io_uring-sq. This thread runs continuously on the designated CPU core, executing a tight polling loop on sq_ring->tail.
The moment user space executes __atomic_store_n(&sq_ring->tail, tail + N, __ATOMIC_RELEASE), the io_uring-sq thread immediately detects the change in shared memory, reads the SQEs, and submits them to the underlying storage or network driver.
No system call is invoked. The user process and the kernel thread communicate purely via shared memory bus transactions.
The Idle Sleep State and Wakeup Recovery
If user space stops submitting requests, running the kernel thread in a tight loop indefinitely would waste CPU energy and starve other processes. To prevent this, the kernel thread monitors how long the queue remains empty. If the queue is inactive for more than sq_thread_idle milliseconds, the kernel thread transitions itself to sleep (TASK_INTERRUPTIBLE) and sets the IORING_SQ_NEED_WAKEUP bit in sq_ring->flags.
User space detects this transition:
// User space submission check with SQPOLL
__atomic_store_n(&sq_ring->tail, tail + 1, __ATOMIC_RELEASE);
// Check if kernel thread is sleeping
if (__atomic_load_n(&sq_ring->flags, __ATOMIC_ACQUIRE) & IORING_SQ_NEED_WAKEUP) {
// Wake up the kernel thread via io_uring_enter
syscall(__NR_io_uring_enter, ring_fd, 0, 0, IORING_ENTER_SQ_WAKEUP, NULL);
}Under sustained load, the thread never sleeps, and the system call count remains at exactly zero.
3. IOPOLL Mode: Eliminating Hardware Interrupts
In classical storage I/O, an NVMe device signals request completion by generating an MSI-X interrupt. The interrupt controller routes this interrupt to a CPU core, interrupting whatever instructions were executing, saving registers, and running the device driver Interrupt Service Routine (ISR). The ISR reads controller registers, decodes the completion queue entry, and schedules a softirq (NET_RX_SOFTIRQ or block layer completion worker) to wake up waiting threads.
For random reads on high-end NVMe drives that complete in under 8 microseconds, interrupt handling consumes a large fraction of the total round-trip time.
When configured with IORING_SETUP_IOPOLL, io_uring disables hardware completion interrupts entirely. Instead of waiting for an interrupt, the kernel directly polls the NVMe controller hardware Completion Queue doorbells. When user space calls io_uring_enter() with IORING_ENTER_GETEVENTS, the kernel actively queries the PCIe peripheral registers until the hardware indicates completion. This provides the lowest possible latency for high-IOPS storage applications.
Asynchronous Execution: io-wq and Kernel Workers
Not all I/O operations can execute immediately or without blocking. For instance, reading from a file when data is not cached in RAM requires allocating disk blocks and waiting for physical media.
To prevent blocking user space or the primary SQPOLL thread, io_uring incorporates a specialized asynchronous workqueue subsystem known as io-wq.
+-----------------------------------------------------------------------------------+
| IO-WQ WORKER POOL ARCHITECTURE |
+-----------------------------------------------------------------------------------+
| |
| Submission Ring (SQ) |
| | |
| v |
| io_issue_sqe() |
| | |
| +---> Fast Path: Non-blocking attempt (e.g. page cache hit / socket ready)|
| | | |
| | +---> Success? Post CQE immediately to CQ Ring |
| | |
| `---> Blocked? (e.g. Page cache miss, filesystem lock contention) |
| | |
| v |
| +----------------------------------------------------------------------+ |
| | IO-WQ ASYNCHRONOUS WORKER SUBSYSTEM | |
| | | |
| | BOUND WORKERS (I/O Bound) UNBOUND WORKERS (CPU Bound) | |
| | - Pinned to submitting CPU core - Floats across any CPU core | |
| | - Handles disk block reads - Handles hashing, compression, | |
| | - Preserves NUMA locality zero-copy page pinning | |
| | | |
| | Worker Thread Pool: [kworker/u32:0, kworker/u32:1, ...] | |
| +----------------------------------------------------------------------+ |
| | |
| v (Performs blocking execution in background) |
| Posts CQE to Completion Queue (CQ) Ring |
| |
+-----------------------------------------------------------------------------------+The Non-Blocking Fast Path
When an SQE is processed, the kernel first executes io_issue_sqe(). This function attempts to satisfy the request immediately without blocking:
- Sockets: If
IORING_OP_RECVis called, the kernel issues a non-blockingrecvmsg()withMSG_DONTWAIT. If data is present in the socket receive buffer, it is copied directly to the user buffer, and a CQE is posted immediately. - Disk Files: If
IORING_OP_READis called on a regular file, the kernel checks the page cache radix tree (XArray). If the requested pages are cached in RAM, they are copied to the destination buffer immediately without scheduling background tasks.
The Slow Path: Offloading to io-wq
If the non-blocking attempt returns -EAGAIN, the operation cannot complete without waiting for physical media or network packets. Instead of returning -EAGAIN to user space, io_uring transfers the internal request context (struct io_kiocb) to io-wq.
The io-wq framework maintains two categories of worker threads:
- Bound Workers: Pinned to the same CPU core that submitted the request. These workers handle I/O-bound operations (such as disk reads) to preserve processor cache locality and NUMA memory affinity.
- Unbound Workers: Allowed to migrate across any CPU core on the system. These workers execute computationally expensive tasks (such as copying large buffers, calculating checksums, or registering memory).
The worker thread executes the blocking system call inside the kernel on behalf of the application. When the underlying driver signals completion, the worker thread writes the resulting struct io_uring_cqe into the shared completion ring, updates cq_ring->tail, and wakes up user space if it was sleeping in io_uring_enter().
Advanced Performance Optimizations
io_uring includes several advanced primitives designed to eliminate overheads that persist even after eliminating system calls.
1. Fixed Files (Pre-Registered File Descriptors)
Whenever a classical system call executes on a file descriptor, the kernel must validate that descriptor. This involves:
- Accessing the process file descriptor table (
struct files_struct). - Acquiring an internal Read-Copy-Update (RCU) read lock.
- Fetching the underlying
struct filepointer. - Incrementing the reference counter on the
struct file(fget()) to prevent concurrent close operations from freeing the file struct during execution. - Decrementing the counter (
fput()) when the operation finishes.
Under high concurrency across multiple threads, incrementing and decrementing atomic reference counters on shared file structures causes cache-line bouncing across CPU cores.
io_uring eliminates this overhead via pre-registered fixed files:
// Register an array of file descriptors with the kernel once
int fds[2] = { socket_fd, disk_fd };
io_uring_register(ring_fd, IORING_REGISTER_FILES, fds, 2);
// Subsequent submissions reference the index directly
sqe->flags |= IOSQE_FIXED_FILE;
sqe->fd = 0; // References fds[0] directly in the kernel tableBy pre-registering files, the kernel checks permissions and grabs file references once during setup. During subsequent I/O dispatch, the kernel accesses the internal array via direct pointer indexing, bypassing table lookups, RCU locking, and atomic reference counting entirely.
2. Fixed Buffers (Eliminating Page Pinning and GUP)
When an application performs direct I/O (O_DIRECT), the storage device uses Direct Memory Access (DMA) to transfer bytes directly between physical device controllers and user-space memory buffers.
However, the operating system kernel manages virtual memory via paging. A user-space virtual memory pointer (void *buf) is not physical hardware RAM: it is a virtual mapping mapped by page tables. Before a DMA controller can write to that buffer, the kernel must:
- Translate the virtual address to physical pages.
- Call
get_user_pages()(GUP) to lock those physical pages in RAM, preventing the kernel virtual memory subsystem from paging them out to swap or migrating them during memory compaction. - Construct a scatter-gather list of physical addresses for the DMA controller.
- Release the page references (
put_user_page()) once the transfer finishes.
Executing GUP on every I/O operation introduces substantial page table lock contention.
With io_uring, applications can pre-register their I/O buffers:
struct iovec iov[1];
iov[0].iov_base = malloc(65536);
iov[0].iov_len = 65536;
// Pre-pin memory buffers once
io_uring_register(ring_fd, IORING_REGISTER_BUFFERS, iov, 1);
// Submit read using pre-pinned buffer
sqe->opcode = IORING_OP_READ_FIXED;
sqe->addr = (uintptr_t)iov[0].iov_base;
sqe->len = 65536;
sqe->buf_index = 0; // Refers to buffer index 0 in registered tableThe kernel pins the physical pages into memory once during registration. Subsequent read and write operations use the pre-calculated physical addresses, enabling zero-overhead DMA transfers directly into user space.
3. Linked Submissions (IOSQE_IO_LINK)
Complex application workflows often require dependent operations executed in strict sequential order. For example, a database engine might need to write a dirty page to disk, execute an fsync on the file descriptor, and then send a network acknowledgment to the client.
In classical systems, this requires multiple round-trips:
write() -> wait completion -> fsync() -> wait completion -> send()With io_uring, the application chains these operations atomically into a single batch using the IOSQE_IO_LINK flag:
+-----------------------------------------------------------------------------------+
| ATOMIC LINKED SQE EXECUTION |
+-----------------------------------------------------------------------------------+
| |
| SQE 0: IORING_OP_WRITEV (flags |= IOSQE_IO_LINK) |
| | |
| v (Success) |
| SQE 1: IORING_OP_FSYNC (flags |= IOSQE_IO_LINK) |
| | |
| v (Success) |
| SQE 2: IORING_OP_SENDMSG (flags = 0, End of Link Chain) |
| |
| ERROR BEHAVIOR: |
| If SQE 0 fails (e.g. -ENOSPC): |
| - SQE 1 is aborted immediately with res = -ECANCELED |
| - SQE 2 is aborted immediately with res = -ECANCELED |
| |
+-----------------------------------------------------------------------------------+When user space submits this chain:
- The kernel executes SQE 0.
- If and only if SQE 0 completes with a successful return code (
res >= 0), the kernel dispatches SQE 1 automatically. - If SQE 0 fails (for example, with
-ENOSPC), the kernel cancels SQE 1 and SQE 2 immediately, posting completion events for each withres = -ECANCELED.
The entire sequence executes inside the kernel without returning control to user space between steps.
Concrete Zero-Syscall Network Server Example
The following code illustrates a complete, minimal event loop that accepts incoming TCP connections and handles read operations without invoking synchronous system calls inside the steady-state loop.
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <linux/io_uring.h>
#define QUEUE_DEPTH 64
#define READ_BUFFER_SIZE 4096
enum OpType {
OP_ACCEPT = 1,
OP_READ = 2,
OP_WRITE = 3,
};
struct ConnState {
int fd;
enum OpType type;
char buffer[READ_BUFFER_SIZE];
};
static inline void submit_accept(struct io_uring_sqe *sqe, int server_fd,
struct sockaddr_in *client_addr, socklen_t *addr_len) {
memset(sqe, 0, sizeof(*sqe));
sqe->opcode = IORING_OP_ACCEPT;
sqe->fd = server_fd;
sqe->addr = (uintptr_t)client_addr;
sqe->addr2 = (uintptr_t)addr_len;
sqe->flags = 0;
struct ConnState *state = malloc(sizeof(*state));
state->fd = server_fd;
state->type = OP_ACCEPT;
sqe->user_data = (uintptr_t)state;
}
static inline void submit_read(struct io_uring_sqe *sqe, int client_fd, struct ConnState *state) {
memset(sqe, 0, sizeof(*sqe));
sqe->opcode = IORING_OP_RECV;
sqe->fd = client_fd;
sqe->addr = (uintptr_t)state->buffer;
sqe->len = READ_BUFFER_SIZE;
sqe->flags = 0;
state->fd = client_fd;
state->type = OP_READ;
sqe->user_data = (uintptr_t)state;
}
int main(void) {
// 1. Initialize TCP listening socket
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in saddr;
memset(&saddr, 0, sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(8080);
saddr.sin_addr.s_addr = INADDR_ANY;
bind(server_fd, (struct sockaddr *)&saddr, sizeof(saddr));
listen(server_fd, 128);
// 2. Setup io_uring with SQPOLL enabled
struct io_uring_params params;
memset(¶ms, 0, sizeof(params));
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // Keep kthread alive for 2 seconds idle
int ring_fd = syscall(__NR_io_uring_setup, QUEUE_DEPTH, ¶ms);
if (ring_fd < 0) {
perror("io_uring_setup");
exit(EXIT_FAILURE);
}
// 3. Map shared memory rings
uint32_t sq_ring_size = params.sq_off.array + params.sq_entries * sizeof(uint32_t);
uint32_t cq_ring_size = params.cq_off.cqes + params.cq_entries * sizeof(struct io_uring_cqe);
void *sq_ptr = mmap(0, sq_ring_size, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, ring_fd, IORING_OFF_SQ_RING);
void *cq_ptr = mmap(0, cq_ring_size, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, ring_fd, IORING_OFF_CQ_RING);
struct io_uring_sqe *sqes = mmap(0, params.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring_fd, IORING_OFF_SQES);
uint32_t *sq_head = sq_ptr + params.sq_off.head;
uint32_t *sq_tail = sq_ptr + params.sq_off.tail;
uint32_t *sq_mask = sq_ptr + params.sq_off.ring_mask;
uint32_t *sq_array = sq_ptr + params.sq_off.array;
uint32_t *sq_flags = sq_ptr + params.sq_off.flags;
uint32_t *cq_head = cq_ptr + params.cq_off.head;
uint32_t *cq_tail = cq_ptr + params.cq_off.tail;
uint32_t *cq_mask = cq_ptr + params.cq_off.ring_mask;
struct io_uring_cqe *cqes = cq_ptr + params.cq_off.cqes;
// 4. Submit initial accept request
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
uint32_t tail = *sq_tail;
uint32_t idx = tail & *sq_mask;
submit_accept(&sqes[idx], server_fd, &client_addr, &client_len);
sq_array[idx] = idx;
__atomic_store_n(sq_tail, tail + 1, __ATOMIC_RELEASE);
// 5. Main event loop: completely non-blocking and zero-syscall in steady state
for (;;) {
// Read Completion Queue using Acquire semantics
uint32_t c_head = __atomic_load_n(cq_head, __ATOMIC_ACQUIRE);
uint32_t c_tail = __atomic_load_n(cq_tail, __ATOMIC_ACQUIRE);
while (c_head != c_tail) {
struct io_uring_cqe *cqe = &cqes[c_head & *cq_mask];
struct ConnState *state = (struct ConnState *)(uintptr_t)cqe->user_data;
if (state->type == OP_ACCEPT) {
int client_fd = cqe->res;
if (client_fd >= 0) {
// Queue a read on the newly accepted socket
uint32_t s_tail = *sq_tail;
uint32_t s_idx = s_tail & *sq_mask;
struct ConnState *read_state = malloc(sizeof(*read_state));
submit_read(&sqes[s_idx], client_fd, read_state);
sq_array[s_idx] = s_idx;
__atomic_store_n(sq_tail, s_tail + 1, __ATOMIC_RELEASE);
}
// Re-arm the accept operation for subsequent connections
uint32_t s_tail = *sq_tail;
uint32_t s_idx = s_tail & *sq_mask;
submit_accept(&sqes[s_idx], server_fd, &client_addr, &client_len);
sq_array[s_idx] = s_idx;
__atomic_store_n(sq_tail, s_tail + 1, __ATOMIC_RELEASE);
free(state);
} else if (state->type == OP_READ) {
int bytes_read = cqe->res;
if (bytes_read > 0) {
// Echo data back to client using synchronous write for brevity
// or queue a send SQE into the ring
write(state->fd, state->buffer, bytes_read);
// Re-arm read
uint32_t s_tail = *sq_tail;
uint32_t s_idx = s_tail & *sq_mask;
submit_read(&sqes[s_idx], state->fd, state);
sq_array[s_idx] = s_idx;
__atomic_store_n(sq_tail, s_tail + 1, __ATOMIC_RELEASE);
} else {
// Connection closed or error occurred
close(state->fd);
free(state);
}
}
c_head++;
}
// Release consumed CQEs back to kernel
__atomic_store_n(cq_head, c_head, __ATOMIC_RELEASE);
// Check if SQPOLL kthread went to sleep during idle period
if (__atomic_load_n(sq_flags, __ATOMIC_ACQUIRE) & IORING_SQ_NEED_WAKEUP) {
syscall(__NR_io_uring_enter, ring_fd, 0, 0, IORING_ENTER_SQ_WAKEUP, NULL);
}
}
return 0;
}Failure Modes, Memory Leaks, and Safety Boundaries
While io_uring provides notable performance advantages, it alters fundamental memory safety invariants that systems programmers took for granted under synchronous APIs.
+-----------------------------------------------------------------------------------+
| IO_URING FAILURE MODES & HAZARDS |
+-----------------------------------------------------------------------------------+
| |
| 1. USE-AFTER-FREE VIA STACK BUFFER SUBMISSION |
| void bad_function() { |
| char buf[512]; |
| submit_read(sqe, fd, buf); // SQE queued in shared memory |
| } // Function returns! buf stack frame is deallocated! |
| Kernel DMA arrives later and overwrites whatever now occupies that stack! |
| |
| 2. COMPLETION RING OVERFLOW |
| User produces SQEs faster than reading CQEs |
| Legacy kernels: Dropped CQEs (silent loss of user_data and memory leaks) |
| Modern kernels: IORING_FEAT_NODROP forces internal kernel backlog queuing |
| |
| 3. KERNEL MEMORY PINNING AND RESOURCE LIMITS |
| Registered buffers lock physical pages via RLIMIT_MEMLOCK |
| Exhausting memlock limits causes registration failure with -ENOMEM |
| |
+-----------------------------------------------------------------------------------+1. The Stack Buffer Use-After-Free
In synchronous programming, passing a pointer to a stack-allocated buffer to read() is completely safe:
void read_header(int fd) {
char header[64];
read(fd, header, sizeof(header)); // Thread blocks until buffer is populated
process_header(header);
} // header goes out of scope safelyIn an asynchronous completion architecture like io_uring, doing this causes severe memory corruption:
void broken_read_header(struct io_uring *ring, int fd) {
char header[64];
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, fd, header, sizeof(header), 0);
io_uring_submit(ring);
} // BUG: Function returns immediately! 'header' stack memory is deallocated!When the hardware controller completes the read milliseconds later, the kernel writes bytes into the memory address where header once resided. By this time, that stack address may belong to a completely different function call stack frame, corrupting pointers, return addresses, or cryptographic keys.
Invariant: In io_uring, any memory buffer passed in an SQE must remain valid and unmodified until the corresponding CQE has been reaped from the Completion Queue.
2. Completion Queue Overflow
If user space submits requests rapidly but neglects to process the Completion Queue, the CQ ring fills up (cq_tail - cq_head == cq_entries).
In Linux 5.1 through 5.4, the kernel had no choice but to drop overflowing completion entries, setting the IORING_SQ_CQ_OVERFLOW bit in sq_ring->flags. Dropping CQEs caused silent resource leaks because user space never learned whether operations succeeded, leaving memory buffers pinned indefinitely.
Linux 5.5 resolved this by introducing an internal kernel linked list backlog. When the CQ ring is full, completions are appended to kernel memory. As soon as user space consumes entries and advances cq_ring->head, the kernel flushes the backlog into the ring. Modern kernels advertise this via the IORING_FEAT_NODROP flag in io_uring_params.
3. File Descriptor Table Leaks and Cancellation
If a process closes a file descriptor using standard close(fd) while operations on that file descriptor remain active in the io_uring ring, the kernel does not automatically cancel those in-flight requests. Because io_uring holds an internal reference to the struct file, the file remains open inside the kernel until the in-flight DMA finishes.
To cancel operations explicitly, the application must submit an SQE with the IORING_OP_ASYNC_CANCEL opcode, passing the user_data identifier of the target operation:
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
sqe->opcode = IORING_OP_ASYNC_CANCEL;
sqe->addr = target_user_data; // Cancel matching request
sqe->user_data = cancellation_cookie;Production Observability: Inspecting Rings with bpftrace
Observing an asynchronous shared-memory subsystem with tools like strace is ineffective: if the system uses SQPOLL, strace observes zero system calls during normal operation.
Engineers rely on eBPF to inspect io_uring internals in real time. The kernel exposes tracepoints under the io_uring subsystem:
/sys/kernel/debug/tracing/events/io_uring/
|-- io_uring_create
|-- io_uring_submit_sqe
|-- io_uring_queue_async_work
|-- io_uring_complete
|-- io_uring_fail_link
`-- io_uring_poll_armUsing bpftrace, one can measure the latency between SQE submission and CQE generation across the system:
bpftrace -e '
tracepoint:io_uring:io_uring_submit_sqe {
@start[args->user_data] = nsecs;
}
tracepoint:io_uring:io_uring_complete {
$t = @start[args->user_data];
if ($t != 0) {
@latency_us = hist((nsecs - $t) / 1000);
delete(@start[args->user_data]);
}
}
'This script captures the start time when the kernel parses an SQE and records the elapsed microseconds when the matching CQE is written, producing a hardware execution latency distribution without modifying application code or incurring system call overhead.
Architectural Comparison Matrix
| Mechanism | Classical read()/write() | POSIX AIO (glibc) | Linux AIO (io_submit) | io_uring |
|---|---|---|---|---|
| I/O Model | Synchronous blocking | Simulated async (user threads) | Async (O_DIRECT only) | True async completion |
| System Call Cost | 1 syscall per I/O | 0-1 per I/O (thread pool overhead) | 1 submit + 1 reap syscall | 0 syscalls steady-state (SQPOLL) |
| Ring Buffer Structure | None | None | Kernel ring (io_getevents) | Shared user-kernel dual rings |
| Storage Support | Buffered & Direct I/O | Buffered & Direct I/O | Direct I/O (O_DIRECT) only |
Buffered, Direct, & Sockets |
| Network Support | Yes (via epoll) | Poor | No (files only) | Full socket support |
| Memory Pinning (DMA) | Per-syscall GUP | Per-syscall GUP | Per-syscall GUP | Pre-registered fixed buffers |
| File Table Lookup | Every syscall | Every syscall | Every syscall | Pre-registered fixed files |
| Chained Requests | No (user space sequencing) | No | No | Atomic (IOSQE_IO_LINK) |
| Kernel Thread Model | Application thread | glibc pthreads | Kernel aio workqueue | Dedicated io-wq + SQPOLL |
Summary
io_uring shifts the Linux I/O paradigm from reactive readiness polling to a proactive, shared-memory completion architecture:
- Shared-Memory Circular Queues: Submissions and completions circulate through lock-free SPSC rings mapped into both user and kernel address spaces, eliminating context switching overhead.
- Strict Memory Ordering: Single producer and single consumer roles permit thread-safe operation using acquire-release memory barriers without hardware bus locks.
- Pervasive Batching and Polling: SQPOLL mode decouples application threads from system call execution, allowing millions of I/O operations to execute without a single transition into Ring 0.
- Hardware-Aligned Data Structures: 64-byte SQEs and 16-byte CQEs fit cache-line hierarchies cleanly, avoiding false sharing across concurrent processing cores.
- Zero-Copy Foundations: Pre-registered fixed buffers and pre-opened fixed files eliminate repeated page table translations, kernel page pinning, and file reference count contention.
By addressing the hardware constraints of modern NVMe drives and high-bandwidth network fabrics, io_uring provides the foundational runtime architecture for modern Linux high-performance systems engineering.