The io_uring Submission and Completion Queues

At the heart of io_uring are two single-producer/single-consumer ring buffers shared between userspace and the kernel: the submission queue (SQ), into which the application (producer) places submission queue entries (SQEs) describing I/O to perform, and the completion queue (CQ), into which the kernel (producer) places completion queue entries (CQEs) carrying results. Because both rings live in memory mapped into both address spaces, the application can fill submissions and reap completions without copying buffers and, in the common case, without any system call — exactly the property that lets io_uring amortize the syscall cost across many operations. The price of sharing lock-free memory with the kernel is discipline: each ring is governed by free-running 32-bit head and tail indices, a power-of-two size with a bitmask for wrapping, and carefully placed acquire/release memory barriers so that neither side ever observes a half-written entry. This note dissects that machinery — the mmap layout, the SQE and CQE structures, the index protocol, the indirection array on the submission side, the barriers, and the registered (fixed) buffers and files that strip overhead off the data path. The why — syscall batching and the three driving syscalls — lives in the sibling io_uring as a Syscall Batching Mechanism.

Uncertain

All struct field names and #define constants here are verified against Jens Axboe’s design document (kernel.dk/io_uring.pdf) and the Linux v6.12 LTS uapi header (include/uapi/linux/io_uring.h). The design PDF shows the original (5.1-era) io_uring_sqe/io_uring_cqe layout; both structs have grown unions and trailing fields since (e.g. buf_index/personality, larger SQE variants, CQE big-cqe extensions). The core fields shown here are ABI-stable, but treat the exact full layout as point-in-time for 6.12 / 6.18 LTS (2025-11-30) and consult the uapi header at your target tag for the complete struct. uncertain


Mental Model

Picture two conveyor belts running in opposite directions between the application and the kernel, each a circular track. On the submission belt the application loads parcels (SQEs) at the tail and the kernel unloads them at the head. On the completion belt the kernel loads result-slips (CQEs) at the tail and the application unloads them at the head. Each belt has exactly one loader and one unloader — single-producer, single-consumer — which is what makes the whole thing lock-free: with one writer and one reader on each shared structure, correctness reduces to ordering (publishing the data before publishing the index that reveals it), and ordering is enforced with memory barriers rather than locks. As Axboe puts it, “an application can’t share locking with the kernel without invoking system calls, and a system call would surely reduce the rate at which we communicate” — so the design “eliminate[s] the need to have shared locking between the application and the kernel, getting away with some clever use of memory ordering and barriers instead” (kernel.dk/io_uring.pdf).

flowchart TB
  subgraph SHARED["mmap'd shared memory (no copy, no syscall to access)"]
    direction TB
    SQR["SQ ring (IORING_OFF_SQ_RING)<br/>head, tail, ring_mask,<br/>ring_entries, flags, dropped,<br/>array[] (indirection)"]
    SQES["SQE array (IORING_OFF_SQES)<br/>io_uring_sqe[entries]"]
    CQR["CQ ring (IORING_OFF_CQ_RING)<br/>head, tail, ring_mask,<br/>ring_entries, overflow,<br/>cqes[] (inline)"]
  end
  APP["Application"]
  KERN["Kernel"]
  APP -->|"1. fill SQE at array[tail&mask]<br/>2. write_barrier<br/>3. publish SQ.tail"| SQR
  SQR -->|"array indexes into"| SQES
  KERN -->|"read SQ.tail (read_barrier),<br/>consume SQEs, advance SQ.head"| SQR
  KERN -->|"post CQE at cqes[tail&mask],<br/>publish CQ.tail"| CQR
  CQR -->|"read CQ.tail (read_barrier),<br/>consume CQEs, advance CQ.head"| APP

The three mmap regions and the producer/consumer roles. What it shows: the SQ ring carries only metadata plus an indirection array[] of indices into the separately mapped SQE array, while the CQ ring contains its CQEs inline; the application owns the SQ tail and CQ head, the kernel owns the SQ head and CQ tail. The insight: every arrow is plain memory access — the boundary is crossed by io_uring_enter only to notify, never to move data; the indirection array on the submission side is what lets the application embed SQEs inside its own data structures yet still submit them in any order.


The Three mmap Regions

io_uring_setup(2) returns a file descriptor and fills struct io_uring_params, including two offset structures that tell the application where each field lives within the mapped rings. The application then performs mmap(2) calls against that fd at three fixed magic offsets, defined in the v6.12 uapi header exactly as in the design doc:

#define IORING_OFF_SQ_RING   0ULL          /* the submission ring metadata + array[] */
#define IORING_OFF_CQ_RING   0x8000000ULL  /* the completion ring metadata + cqes[]  */
#define IORING_OFF_SQES      0x10000000ULL /* the array of io_uring_sqe structures    */

Three regions, not two, because of an asymmetry: the CQ ring contains its CQE array inline (cqes[] lives right in the CQ-ring mapping), but the SQ ring contains only an indirection array[] of indices, so the actual SQE structures must be mapped separately at IORING_OFF_SQES. On kernels ≥ 5.4 the IORING_FEAT_SINGLE_MMAP feature lets the SQ and CQ rings share a single mmap (the SQEs still map separately), reported back in params.features.

To locate each field the application uses the offset structures the kernel filled in. The io_sqring_offsets (verified against the design doc) gives byte offsets into the SQ-ring mapping:

struct io_sqring_offsets {
    __u32 head;         /* offset of ring head index   */
    __u32 tail;         /* offset of ring tail index   */
    __u32 ring_mask;    /* offset of the (size-1) mask */
    __u32 ring_entries; /* offset of the entry count   */
    __u32 flags;        /* offset of ring flags (e.g. IORING_SQ_NEED_WAKEUP) */
    __u32 dropped;      /* count of SQEs the kernel could not submit */
    __u32 array;        /* offset of the indirection array */
    __u32 resv1;
    __u64 user_addr;
};

A typical setup, lifted in spirit from the design doc, maps the SQ ring and then derives pointers to each field by adding the offsets to the mapping base:

void *ptr = mmap(NULL, p.sq_off.array + p.sq_entries * sizeof(__u32),
                 PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
                 ring_fd, IORING_OFF_SQ_RING);
sring.head        = ptr + p.sq_off.head;
sring.tail        = ptr + p.sq_off.tail;
sring.ring_mask   = ptr + p.sq_off.ring_mask;
sring.array       = ptr + p.sq_off.array;
/* SQEs mapped separately: */
sqes = mmap(NULL, p.sq_entries * sizeof(struct io_uring_sqe),
            PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
            ring_fd, IORING_OFF_SQES);

In practice almost nobody writes this by hand — the liburing library provides io_uring_queue_init(3) / io_uring_queue_exit(3) to do the setup, mmaps, and barrier management. But understanding the raw layout is what makes the performance characteristics legible.


The SQE — Submission Queue Entry

The submission queue entry describes one operation. The original layout from the design doc:

struct io_uring_sqe {
    __u8  opcode;     /* operation: IORING_OP_READV, OP_WRITEV, OP_SEND, ... */
    __u8  flags;      /* IOSQE_* flags: IOSQE_IO_LINK, IOSQE_FIXED_FILE, ... */
    __u16 ioprio;     /* request priority (cf. ioprio_set(2))                */
    __s32 fd;         /* file descriptor (or registered-file index)          */
    __u64 off;        /* offset into the file                                */
    __u64 addr;       /* pointer to buffer or iovec array                    */
    __u32 len;        /* byte count, or number of iovecs for vectored ops    */
    union {           /* opcode-specific modifier flags                      */
        __kernel_rwf_t rw_flags;
        __u32 fsync_flags;
        __u16 poll_events;
        __u32 sync_range_flags;
        __u32 msg_flags;
    };
    __u64 user_data;  /* opaque tag, copied verbatim into the matching CQE   */
    union {
        __u16 buf_index;  /* index into registered (fixed) buffers           */
        __u64 __pad2[3];
    };
};

Three fields carry the design’s cleverness. opcode makes the SQE polymorphic — IORING_OP_READV (vectored read), IORING_OP_WRITEV, IORING_OP_FSYNC, IORING_OP_TIMEOUT, network ops, and many more share one struct, which is why io_uring can batch heterogeneous operations where sendmmsg batches only one. user_data is the correlation tag: the kernel “will not touch this field, it’s simply carried straight from submission to completion event,” so the application stamps each SQE with, say, a pointer to its own request object and recognizes the result later by reading the same value back from the CQE — essential because, as the man page stresses, completions “can complete in any order.” buf_index selects a registered buffer for the *_FIXED opcodes (below). The 64-byte size and trailing __pad2/padding exist to keep the SQE cache-line-friendly and leave room for future opcodes.

The flags byte also carries the ordering controls: IOSQE_IO_LINK chains an SQE to its successor (the next SQE will not start until this one completes successfully; a failure breaks the chain with -ECANCELED), and IOSQE_IO_DRAIN is a full pipeline barrier that stalls the whole SQ until all prior submissions complete — the mechanisms for imposing order on an otherwise out-of-order engine, e.g. “a series of writes, followed by an fsync.”


The CQE — Completion Queue Entry

The completion side is deliberately tiny:

struct io_uring_cqe {
    __u64 user_data;  /* the SQE's user_data, copied back verbatim */
    __s32 res;        /* result: like the syscall return, or -errno */
    __u32 flags;      /* metadata flags (e.g. buffer-ring selection) */
};

res is “like the return value from a system call” — for a read/write the byte count on success, or a negated errno on failure (res == -EIO, etc.). user_data is the tag echoed from the SQE so the application can match this completion to the request that produced it. The CQEs live inline in the CQ-ring mapping as a power-of-two array, indexed the same way as the SQ.


The Index Protocol and the Indirection Array

Both rings use free-running 32-bit head and tail integers that wrap naturally; the actual slot is found by masking with ring_mask (which equals ring_entries - 1, hence the power-of-two requirement). The design doc highlights the elegance: “we can utilize the full size of the ring without having to manage a ‘ring is full’ flag on the side” — fullness is tail - head == ring_entries, emptiness is tail == head, and 32-bit wraparound is handled by unsigned arithmetic.

Completion side (application is the consumer, kernel the producer). The application reads its head, checks whether the kernel has advanced tail past it, and if so masks head to index cqes[]:

unsigned head = cqring->head;
read_barrier();                       /* see the kernel's tail + CQE writes */
if (head != cqring->tail) {
    unsigned index = head & (*cqring->ring_mask);
    struct io_uring_cqe *cqe = &cqring->cqes[index];
    /* process completed cqe: cqe->res, cqe->user_data */
    head++;
}
cqring->head = head;                  /* tell kernel this slot is free       */
write_barrier();

Submission side (application is the producer). Here the indirection array appears. The application picks a slot in the separately-mapped SQE array, fills it, then writes that SQE’s index into the SQ ring’s array[] at the tail position, and finally advances the tail:

struct io_uring_sqe *sqe;
unsigned tail = sqring->tail, index;
index = tail & (*sqring->ring_mask);
sqe = &sqring->sqes[index];           /* a slot in the SQE array */
init_io(sqe);                         /* fill opcode, fd, addr, len, ... */
sqring->array[index] = index;         /* indirection: SQ array -> SQE array */
tail++;
write_barrier();                      /* ensure SQE + array write precede... */
sqring->tail = tail;                  /* ...the tail publish */
write_barrier();

Why the extra array[] level of indirection on the submission side, when the completion side indexes its CQEs directly? Axboe explains it lets applications “embed request units inside internal data structures” — the SQE an application uses need not sit at the ring-position slot; the array[] decouples which SQE from what order it is submitted in, “while retaining the ability to submit multiple sqes in one operation.” It is a small flexibility that matters for libraries layering their own object model on top of io_uring. Note one consequence the doc calls out: once the kernel consumes an SQE it makes a stable internal copy, so “the application is free to reuse that sqe entry” immediately — meaning an application can have more requests in flight than the SQ ring is deep (it must just avoid overflowing the CQ ring, which the default 2× CQ-to-SQ sizing guards against).


Memory Ordering — Why the Barriers Are Mandatory

This is the part that bites people who roll their own ring code. Publishing a submission is a two-stage operation: fill the SQE fields and write the index into array[], then advance sqring->tail. The danger, in Axboe’s words: “There’s no guarantee that the write [to tail], which makes the sqe visible to the kernel, will take place as the last write in the sequence… otherwise the kernel could be seeing a half written sqe.” Modern CPUs reorder stores; without a barrier the tail update could become visible before the SQE contents, and the kernel would consume garbage.

The fix reduces to two primitives the design doc names read_barrier() (“ensure previous writes are visible before doing subsequent memory reads”) and write_barrier() (“order this write after previous writes”). The producer issues a write barrier between filling the entry and publishing the tail, and the consumer issues a read barrier before reading the tail it will trust. liburing implements these as io_uring_smp_store_release() / io_uring_smp_load_acquire() — release on the publishing store, acquire on the observing load — and the man page’s pseudocode uses C11 atomic_store_explicit(..., memory_order_release) and atomic_load_explicit(..., memory_order_acquire) to the same effect. On strongly-ordered architectures (x86-64) some of these compile to plain stores/loads (no fence instruction), but on weakly-ordered ones (Arm, PowerPC) they emit real barriers — which is precisely why the application “should understand how to do so” even though “while using io_uring, that doesn’t matter” if you let liburing handle it. Getting this wrong produces rare, data-dependent, near-unreproducible corruption — the worst class of bug — so using liburing’s helpers rather than hand-rolling the barriers is the strong recommendation.

The SQPOLL case (see io_uring as a Syscall Batching Mechanism) makes the barriers even more load-bearing, because there is no io_uring_enter syscall to act as an implicit synchronization point: the kernel poller is reading the shared tail concurrently with the application’s stores, so the release/acquire pairing is the only thing preventing it from consuming a torn SQE.


Fixed Buffers and Fixed Files — Stripping the Data Path

Two io_uring_register(2) facilities optimize what the rings reference, complementing the ring-level batching:

  • Fixed buffers (IORING_REGISTER_BUFFERS): the application pre-registers a set of anonymous (non-file-backed) buffers; the kernel pins their pages and builds durable mappings once. Thereafter, operations using IORING_OP_READ_FIXED / IORING_OP_WRITE_FIXED set buf_index to select a registered buffer (with addr/len pointing within it), and the kernel skips the per-I/O get_user_pages pinning and translation entirely. Constraints per the man page: buffers must be anonymous memory, there is a per-buffer size cap (1 GiB), and a whole huge page is pinned even if only partly used.
  • Fixed files (IORING_REGISTER_FILES): pre-register file descriptors so the kernel does not acquire and release a reference on the (possibly shared) file table for every operation. Operations then set the IOSQE_FIXED_FILE flag and place the registered index — not the real fd — in sqe->fd. The registered set may be sparse (entries of -1) and updated dynamically via IORING_REGISTER_FILES_UPDATE.

Both move fixed costs off the hot path: with batching you stop paying the syscall cost per operation; with registration you stop paying the page-pinning and fd-reference cost per operation.


Failure Modes and Misunderstandings

  • Forgetting the barrier between the SQE write and the tail publish. The classic torn-entry bug; the kernel reads a partially-written SQE. Use liburing’s store_release/load_acquire helpers.
  • Assuming completions arrive in submission order. They do not — “completions may arrive in any order.” Match by user_data, never by position. Use IOSQE_IO_LINK only when you genuinely need ordering.
  • CQ overflow. Driving more in-flight requests than the CQ ring holds; signalled via the SQ-ring IORING_SQ_CQ_OVERFLOW flag and, with IORING_FEAT_NODROP, buffered internally rather than dropped. The default 2× CQ:SQ ratio exists to absorb this.
  • Treating the SQ depth as the in-flight limit. Because the kernel copies the SQE on consumption, you can submit, reap the SQE slot, and submit again — so SQ depth bounds unsubmitted entries, not outstanding ones.
  • Reusing a non-fixed buffer too early under SQPOLL. Without SQPOLL a buffer pointer is safe to free after io_uring_enter returns; under SQPOLL (asynchronous submission) it must live until completion — and a buffer being actively read/written by an in-flight op must always live until completion.

See Also