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.

Scope — and the boundary with its near-title sibling

This note is about the data structures: the shared memory layout, the entry formats, the index protocol, the barriers, and the setup flags that reshape those structures. The motivation — why batching a syscall is worth this much machinery, what io_uring_setup/io_uring_enter/io_uring_register do as system calls, how the SQPOLL kernel thread and the io-wq worker pool schedule work, and the security history that led to kernel.io_uring_disabled — lives in io_uring as a Syscall Batching Mechanism. The two notes are deliberately separate: read that one for why and when, this one for what is actually in the memory.

Uncertain

Version claims here were established by existence-checking the uapi header at successive release tags (fetch include/uapi/linux/io_uring.h at each tag, record whether the symbol is present) rather than from release notes, because io_uring’s feature surface moves faster than any prose summary of it. The struct layouts and kernel-side logic are read from the Linux v6.12 tree — v6.12 is this vault’s pin, and it is a longterm kernel, not the only one (per kernel.org/releases.json on 2026-09-11, mainline is 7.3-rc2 and the longterm list includes 6.18 alongside 6.12). The measured program output in this note comes from a 7.1.13 kernel, so a few numbers (notably params.features) are larger than v6.12 would produce; those places say so. Anything about a release later than 7.1 is not verified here. uncertain

Uncertain

Jens Axboe’s design document is cited throughout as https://www.kernel.dk/io_uring.pdf. As of 2026-09-11 the bare host kernel.dk is not fetchable: it presents a TLS certificate for *.one.com whose subjectAltName does not match kernel.dk (so curl fails with an SSL hostname error), and plain HTTP to it returns 404. The www. host serves the real document (HTTP 200, 247,968 bytes, 8 pages, “Version: 0.4, 2019-10-15”). Older notes and papers citing the bare-host URL are therefore now broken links, not missing documents. Note also that the PDF is frozen at 2019 — it describes the 5.x-era interface, so where it and the v6.12 source disagree, the source wins and this note says so.


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” (Axboe, Efficient IO with io_uring, v0.4).

flowchart TB
  subgraph SHARED["Memory mapped into BOTH address spaces — no copy, no syscall to touch"]
    direction TB
    subgraph RINGS["allocation 1: struct io_rings — mapped at IORING_OFF_SQ_RING (0)<br/>AND at IORING_OFF_CQ_RING (0x8000000): the same pages, twice"]
      META["sq.head sq.tail cq.head cq.tail<br/>sq_ring_mask cq_ring_mask<br/>sq_ring_entries cq_ring_entries<br/>sq_dropped sq_flags cq_flags cq_overflow"]
      CQES["cqes[] — the CQEs, INLINE<br/>(16 B each, 32 B with CQE32)"]
      ARR["array[] — SQ indirection<br/>(absent with NO_SQARRAY)"]
    end
    subgraph SQEARR["allocation 2: IORING_OFF_SQES (0x10000000)"]
      SQES["io_uring_sqe[sq_entries]<br/>(64 B each, 128 B with SQE128)"]
    end
  end
  APP["Application"]
  KERN["Kernel"]
  APP -->|"1. fill sqes[i]<br/>2. array[tail &amp; mask] = i<br/>3. RELEASE store sq.tail"| ARR
  ARR -->|"holds an index into"| SQES
  KERN -->|"ACQUIRE load sq.tail,<br/>read array[], read sqes[i],<br/>RELEASE store sq.head"| ARR
  KERN -->|"write cqes[tail &amp; mask],<br/>RELEASE store cq.tail"| CQES
  CQES -->|"ACQUIRE load cq.tail, read CQEs,<br/>RELEASE store cq.head"| APP
  META -.->|"masks and counts;<br/>sq_flags carries NEED_WAKEUP,<br/>CQ_OVERFLOW, TASKRUN"| APP

The shared memory and the producer/consumer roles, as a v6.12 kernel actually lays it out. What it shows: two allocations, not three — the struct io_rings object carries both rings’ control words, the CQEs inline, and the SQ indirection array, and it is reachable at two different magic mmap offsets that resolve to the same pages; only the SQE array is genuinely separate. The application owns the SQ tail and the CQ head; the kernel owns the SQ head and the CQ tail. The insight: every arrow inside the shaded box is a plain memory access. io_uring_enter(2) is crossed only to notify the other side, never to move data — and under IORING_SETUP_SQPOLL it is not crossed at all in the steady state.


The Shared Memory — Regions, Offsets, and What Is Actually Allocated

io_uring_setup(2) takes a requested number of submission entries and a struct io_uring_params, and returns a file descriptor. Everything else the application needs — how big the rings really are, which kernel features this build supports, and where every individual field sits inside the mapping — comes back written into that same params structure. The application never hard-codes a field offset; it adds the kernel-supplied byte offsets to the base address returned by mmap(2). That indirection is the ABI: the kernel is free to reorder struct io_rings between releases as long as it keeps reporting honest offsets.

The magic mmap offsets

There are four magic mmap offsets in the v6.12 uapi header (include/uapi/linux/io_uring.h L205–L210):

ConstantValueWhat it mapsSince
IORING_OFF_SQ_RING0x00000000the struct io_rings allocation (SQ and CQ metadata, cqes[], and the SQ indirection array[])5.1
IORING_OFF_CQ_RING0x08000000the same allocation — in v6.12 this is an alias, not a second region5.1
IORING_OFF_SQES0x10000000the separate array of struct io_uring_sqe5.1
IORING_OFF_PBUF_RING0x80000000a provided-buffer ring; the buffer-group id is packed into the offset, bgid << IORING_OFF_PBUF_SHIFT (shift = 16)6.4
IORING_OFF_MMAP_MASK0xf8000000the mask the kernel applies to separate “which region” from “which buffer group”6.4

The “which region” dispatch is a five-line switch in io_uring/memmap.c, io_uring_validate_mmap_request(), and it is worth reading literally, because it settles a question that most tutorials get wrong:

switch ((pgoff << PAGE_SHIFT) & IORING_OFF_MMAP_MASK) {
case IORING_OFF_SQ_RING:
case IORING_OFF_CQ_RING:
        if (ctx->flags & IORING_SETUP_NO_MMAP)
                return ERR_PTR(-EINVAL);
        return ctx->rings;                 /* <-- one pointer, both cases */
case IORING_OFF_SQES:
        ...
        return ctx->sq_sqes;

IORING_OFF_SQ_RING and IORING_OFF_CQ_RING fall through to the same return ctx->rings. In a modern kernel the two “rings” are not two objects: they are two views into one allocation. That is exactly what IORING_FEAT_SINGLE_MMAP (introduced in 5.4) advertises, and since v6.12 unconditionally ORs that bit into p->features (io_uring/io_uring.c L3665), every current kernel reports it. The historical picture — genuinely separate struct io_sq_ring and struct io_cq_ring objects requiring three mmap calls — is what the January-2019 pre-merge review described (LWN, “Ringing in a new asynchronous I/O API”) and it was true only for 5.1 through 5.3. Code that still issues a third mmap for the CQ ring is not wrong, merely wasteful: it gets a second mapping of the same pages.

So the accurate modern statement is two allocations, three (or four) offsets:

  1. ctx->rings — one struct io_rings, holding both head/tail pairs, both masks, both entry counts, the flags and counters, the inline cqes[] array, and (unless IORING_SETUP_NO_SQARRAY) the SQ indirection array[] tacked on the end.
  2. ctx->sq_sqes — the struct io_uring_sqe array, allocated and mapped separately.

struct io_rings, field by field

The kernel-internal definition (include/linux/io_uring_types.h) carries, in its own comments, the single most important sentence about the whole design: “The kernel controls head of the sq ring and the tail of the cq ring, and the application controls tail of the sq ring and the head of the cq ring.”

struct io_rings {
        struct io_uring         sq, cq;          /* each is { u32 head; u32 tail; } */
        u32                     sq_ring_mask, cq_ring_mask;
        u32                     sq_ring_entries, cq_ring_entries;
        u32                     sq_dropped;      /* kernel-written */
        atomic_t                sq_flags;        /* kernel-written */
        u32                     cq_flags;        /* application-written */
        u32                     cq_overflow;     /* kernel-written */
        struct io_uring_cqe     cqes[] ____cacheline_aligned_in_smp;
};
packet-beta
0-3: "sq.head (kernel writes)"
4-7: "sq.tail (app writes)"
8-11: "cq.head (app writes)"
12-15: "cq.tail (kernel writes)"
16-19: "sq_ring_mask"
20-23: "cq_ring_mask"
24-27: "sq_ring_entries"
28-31: "cq_ring_entries"
32-35: "sq_dropped"
36-39: "sq_flags"
40-43: "cq_flags"
44-47: "cq_overflow"
48-63: "pad to cache line"

The first 64 bytes of struct io_rings on x86-64, drawn at byte accuracy. What it shows: the twelve __u32 control words, in the exact order the v6.12 struct declares them, with cqes[] starting at offset 64 because it is ____cacheline_aligned_in_smp. The insight: the four indices that matter are packed into the first 16 bytes, interleaved SQ/CQ rather than grouped by ring — so a single cache line carries both producers’ and both consumers’ cursors. These offsets are not ABI; the numbers here are what a real kernel reported (below), and an application must always read them out of params.sq_off / params.cq_off instead of assuming them.

Running io_uring_setup(4, &p) on the test machine (Linux 7.1.13, x86-64) produced exactly those offsets:

sq_off: head=0 tail=4 ring_mask=16 ring_entries=24 flags=36 dropped=32 array=192
cq_off: head=8 tail=12 ring_mask=20 ring_entries=28 overflow=44 cqes=64 flags=40
sizeof(struct io_uring_sqe)=64  sizeof(struct io_uring_cqe)=16

Read those two lines against the struct and every number falls out. sq.head at 0 and sq.tail at 4 are the two u32s of the first embedded struct io_uring; cq.head at 8 and cq.tail at 12 are the second. The masks, entry counts, sq_dropped, sq_flags, cq_flags and cq_overflow follow in declaration order through byte 47. cqes begins at 64 — the cache-line alignment. And array at 192 is 64 + 8 × 16: the eight 16-byte CQEs, then the SQ indirection array immediately after them, inside the same allocation.

Ring geometry: what the kernel actually gives you

The size arithmetic is rings_size() in io_uring/io_uring.c L2675, and it explains sq_off.array completely:

off = struct_size(rings, cqes, cq_entries);      /* 64 + cq_entries * 16 */
if (ctx->flags & IORING_SETUP_CQE32)
        check_shl_overflow(off, 1, &off);        /* CQE32 doubles the WHOLE offset */
off = ALIGN(off, SMP_CACHE_BYTES);
if (ctx->flags & IORING_SETUP_NO_SQARRAY) { *sq_offset = SIZE_MAX; return off; }
*sq_offset = off;                                 /* array[] starts here */
off += array_size(sizeof(u32), sq_entries);

That is directly falsifiable, and it holds. Probing io_uring_setup with different flags on the same machine returned:

Setupreported sq_entriesreported cq_entriesreported sq_off.arraycheck
entries=4, no flags4819264 + 8×16
entries=4, SQE128|CQE3248384(64 + 8×16) × 2 — the shift doubles the header too
entries=4, CQSIZE, cq_entries=64464108864 + 64×16
entries=33000, CLAMP3276865536104864064 + 65536×16
entries=4, NO_SQARRAY480no array is allocated, so the offset is left unset

The sizing rules themselves, from io_uring_create() (L3506 onwards):

  • entries == 0 → -EINVAL.
  • entries > IORING_MAX_ENTRIES (32768, L107) → -EINVAL, unless IORING_SETUP_CLAMP, which silently clamps.
  • sq_entries = roundup_pow_of_two(entries). Asking for 5 gets you 8.
  • Without IORING_SETUP_CQSIZE, cq_entries = 2 × sq_entries. Axboe’s stated reason: because an SQE’s lifetime ends at submission, an application can have more requests outstanding than the SQ is deep, and “by default, the CQ ring is twice the size of the SQ ring. This allows the application some amount of flexibility in managing this aspect, but it doesn’t completely remove the need to do so” (design doc §, p. 5).
  • With IORING_SETUP_CQSIZE, cq_entries must be non-zero, is capped at IORING_MAX_CQ_ENTRIES (65536, i.e. 2 × IORING_MAX_ENTRIES), is rounded up to a power of two, and is rejected with -EINVAL if it ends up less than sq_entries.

Uncertain — a man-page error, measured

io_uring_setup(2) says of IORING_SETUP_CQSIZE: “The value must be greater than entries.” The v6.12 source says if (p->cq_entries < p->sq_entries) return -EINVAL; — i.e. greater than or equal. Tested directly on 7.1.13 with entries=4: cq_entries=1 → EINVAL, cq_entries=2 → EINVAL, cq_entries=4 → accepted (sq=4 cq=4), cq_entries=8 → accepted. Equal sizes are legal; the man page is wrong on this point. Resolving it properly would mean a patch to man-pages; until then, trust the source. uncertain

The SQE array is sized separately and does not live in struct io_rings: p->sq_entries × sizeof(struct io_uring_sqe), doubled under IORING_SETUP_SQE128. On the test machine that was 4 × 64 = 256 bytes, mapped at its own address one page below the rings.

The SQE — Submission Queue Entry

A submission queue entry describes exactly one operation. It is 64 bytes — verified by sizeof() on the test machine and by the BUILD_BUG_ON the kernel carries — and it is aggressively overloaded: nearly every field is a union whose meaning is selected by opcode. This is the single structural decision that makes io_uring a general batching interface rather than a read/write one. Where sendmmsg batches many instances of one operation, one SQE array batches openat, read, send, futex_wait, waitid and statx in arbitrary interleaving.

Here is the v6.12 layout with the unions collapsed to their most common member, annotated with byte offsets (include/uapi/linux/io_uring.h L30–L108). Every offset in this table was confirmed with offsetof() compiled against the real header, not read off by eye:

OffsetSizeFieldMeaningNotable alternate union members
01opcodewhich operation (IORING_OP_*); v6.12 defines 58 of them, IORING_OP_NOP (0) through IORING_OP_LISTEN (57), with IORING_OP_LAST = 58 as the sentinel—
11flagsthe seven IOSQE_* bits (table below)—
22ioprioI/O priority as in ioprio_set(2) — and the per-opcode modifier space for send/recv/accept (IORING_RECV_MULTISHOT, IORING_ACCEPT_MULTISHOT, IORING_RECVSEND_BUNDLE, …)—
44fdfile descriptor — or an index into the registered file set when IOSQE_FIXED_FILE is set—
88offfile offsetaddr2; or { __u32 cmd_op; __u32 __pad1; } for IORING_OP_URING_CMD
168addruser pointer to a buffer or struct iovec arraysplice_off_in; { __u32 level; __u32 optname; } for socket-option ops
244lenbyte count, or number of iovecs for vectored ops—
284op flagsa 21-member union: rw_flags, fsync_flags, poll32_events, timeout_flags, accept_flags, cancel_flags, open_flags, statx_flags, msg_ring_flags, uring_cmd_flags, waitid_flags, futex_flags, nop_flags, …this is the union
328user_dataopaque 64-bit tag copied verbatim into the CQE—
402buf_indexindex into registered buffersbuf_group — the provided-buffer group id when IOSQE_BUFFER_SELECT is set
422personalitycredentials id registered with IORING_REGISTER_PERSONALITY, so one ring can issue work as different users—
444splice_fd_insource fd for splice/teefile_index (direct-descriptor slot, or IORING_FILE_INDEX_ALLOC = ~0U to let the kernel pick); optlen; { __u16 addr_len; }
4816addr3 + __pad2[1]third address operandoptval; or __u8 cmd[0] — the start of 80 bytes of command payload when the ring was created with IORING_SETUP_SQE128

Three fields carry the design’s real cleverness.

opcode makes the SQE polymorphic. One struct, 64 operations. The consequence for the ring is that the submission queue is heterogeneous by construction: the kernel does not need one queue per operation class, and an application’s batch can mix a socket read, a file fsync and a timeout in three adjacent slots.

user_data is the correlation tag, and the kernel promises never to touch it. The design document is explicit: it “is simply carried straight from submission to completion event” (kernel.dk). This matters because completions arrive out of order — the io_uring(7) man page states plainly that requests “may complete in any order” — so slot position tells you nothing. The idiom is to store a pointer to your own request object, or a generation-tagged index, and recover it from cqe->user_data. In the worked example later in this note the tag is 0xC0FFEE and it comes back byte-identical.

buf_index / buf_group is where the ring stops copying. As buf_index it selects a pre-registered fixed buffer (see below); as buf_group — under IOSQE_BUFFER_SELECT — it names a provided-buffer group from which the kernel picks a buffer at completion time and reports the chosen id back in the CQE’s upper 16 flag bits. That second mode is what lets a server arm ten thousand receives without dedicating ten thousand buffers to them (LWN, “Automatic buffer selection for io_uring”).

The IOSQE_* flags — imposing order on an unordered engine

The flags byte is one byte with seven defined bits, and it is where all per-request control lives:

FlagBitWhat it doesThe trap
IOSQE_FIXED_FILE0sqe->fd is an index into the registered file set, not a real descriptorforgetting it after registering files gives you operations on whatever real fd happens to have that number
IOSQE_IO_DRAIN1a full pipeline barrier: this SQE does not start until every previously submitted request has completedserializes the whole ring, not just a chain — expensive, and easy to reach for when IO_LINK was what you wanted
IOSQE_IO_LINK2link to the next SQE: the successor does not start until this one completes successfully; a failure fails the rest of the chain with -ECANCELEDa short read counts as success, so “read then write what was read” needs care
IOSQE_IO_HARDLINK3like IO_LINK, but the chain survives a failing linkthe successor runs even after an error — that is the point, and the hazard
IOSQE_ASYNC4force the request onto the io-wq worker pool instead of attempting an inline non-blocking issue firstcosts a thread hand-off; only helps when you know the op will block
IOSQE_BUFFER_SELECT5pick a buffer from the group named in buf_group at completion timethe CQE must be checked for IORING_CQE_F_BUFFER and the buffer id extracted from flags >> 16, or you leak the buffer back to nobody
IOSQE_CQE_SKIP_SUCCESS6post no CQE if the request succeededbreaks any scheme that counts completions to know when the ring is quiescent; errors still post

IO_LINK and IO_DRAIN are the answer to the obvious question “if completions are unordered, how do I write a file and then fsync it?” — you link them, and the kernel refuses to start the fsync until the write has completed. IO_DRAIN is the heavier hammer: it waits for everything in flight, not just the predecessor.

stateDiagram-v2
    [*] --> AppOwned: slot index is between<br/>SQ head and SQ tail? no —<br/>slot is free
    AppOwned --> Filled: application writes<br/>opcode, fd, addr, len,<br/>user_data into sqes[i]
    Filled --> Published: array[tail &amp; mask] = i<br/>then RELEASE store to SQ tail
    Published --> Consumed: io_get_sqe() — kernel<br/>ACQUIRE-loads SQ tail,<br/>reads array[], reads sqes[i]
    Consumed --> AppOwned: kernel has made a stable copy;<br/>slot is reusable IMMEDIATELY,<br/>long before completion
    Consumed --> InFlight: request object (io_kiocb)<br/>now owns the parameters
    InFlight --> Completed: CQE posted with the<br/>same user_data
    Completed --> [*]
    note right of AppOwned
        The SQE slot and the request
        have DIFFERENT lifetimes.
        SQ depth bounds unsubmitted
        entries, not outstanding ones.
    end note

The lifetime of one SQE slot. What it shows: a slot passes from application-owned to published (a release store to the tail), to consumed by io_get_sqe(), and then straight back to application-owned — the return edge fires at consumption, not at completion. The insight to take: this is the most commonly misread property of the interface. Because the kernel copies what it needs into an internal io_kiocb request, a 4-entry SQ ring can sustain far more than 4 in-flight requests; what it cannot do is sustain more in-flight requests than the CQ ring can hold completions. The SQ ring bounds your batch size; the CQ ring bounds your concurrency.

The data the SQE points at has a different lifetime again, and the kernel advertises which rule applies via IORING_FEAT_SUBMIT_STABLE: when that bit is set in params.features (it is, on every current kernel), any data the kernel needs from application memory has been consumed by the time io_uring_enter() returns, so an iovec array on the stack is safe to drop afterwards. The buffer the iovec points to is not — it must stay valid until the CQE arrives.

The CQE — Completion Queue Entry

The completion side is deliberately tiny. Where the SQE is 64 bytes of unions, the CQE is 16 bytes with three fields and no ambiguity:

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;       /* IORING_CQE_F_* metadata, plus a buffer id */
        __u64   big_cqe[];   /* 16 more bytes if the ring was made with CQE32 */
};
OffsetSizeFieldSemantics
08user_databyte-for-byte the value the application put in the SQE. The only way to correlate a completion with a request
84res“like the return value from a system call”: a non-negative count/fd on success, a negated errno on failure (-EIO, -ECANCELED, -ENFILE)
124flagslow 16 bits are IORING_CQE_F_*; the upper 16 bits (IORING_CQE_BUFFER_SHIFT = 16) carry the selected buffer id
1616big_cqe[]present only with IORING_SETUP_CQE32; carries the extra result words that NVMe passthrough (IORING_OP_URING_CMD) needs

The flags word is the part people skip and then get wrong:

FlagBitMeaning
IORING_CQE_F_BUFFER0the upper 16 bits of flags are the id of the provided buffer that was used. Without checking this you cannot return the buffer to its group
IORING_CQE_F_MORE1the SQE that produced this CQE will produce more CQEs — multishot poll, accept, recv, timeout. Its absence is how you learn a multishot request has terminated
IORING_CQE_F_SOCK_NONEMPTY2after a socket receive: there is more data waiting, so it is worth issuing another receive immediately
IORING_CQE_F_NOTIF3this is the notification half of a zero-copy send (IORING_OP_SEND_ZC): the data has left the buffer and the buffer may be reused. A zero-copy send produces two CQEs for one SQE
IORING_CQE_F_BUF_MORE4(new in 6.12 — absent at v6.11, present at v6.12, by header existence-check) the provided buffer named in this CQE is only partially consumed and the kernel will use it again — only for incrementally-consumed buffer rings (IOU_PBUF_RING_INC)

Two consequences follow, and both routinely surprise people. First, the CQE-to-SQE relationship is not one-to-one: multishot requests and zero-copy sends produce several CQEs per SQE, and IOSQE_CQE_SKIP_SUCCESS produces zero. Any loop that “reaps N completions for N submissions” is wrong on a modern ring. Second, the whole 16 bytes are written by the kernel fresh each time a slot is reused — the kernel’s own comment on cqes[] notes that “the kernel writes completion events fresh every time they are produced, so the application is allowed to modify pending entries” (io_uring_types.h) — so there is no need to clear a slot after consuming it.

IORING_SETUP_CQE32 doubles the CQE to 32 bytes for the entire ring, which doubles the CQ ring’s memory even for the 99% of completions that do not need the extra words. Linux 6.18 added IORING_SETUP_CQE_MIXED to let a single ring post both sizes, tagging the large ones with a new IORING_CQE_F_32 flag and inserting skip-marked 16-byte filler CQEs where a 32-byte entry would straddle the ring wrap (io_uring_setup(2)). That flag does not exist in v6.12.

The Index Protocol — Who Owns Which Cursor

Both rings use free-running 32-bit head and tail counters that are never reduced modulo the ring size; the slot is found by masking with ring_mask, which the kernel sets to ring_entries - 1 (hence the power-of-two requirement). The design document highlights why this beats a wrapped index: “we can utilize the full size of the ring without having to manage a ‘ring is full’ flag on the side” (kernel.dk). With free-running counters, empty is tail == head, full is tail - head == ring_entries, and count is tail - head — all three computed with plain unsigned subtraction that stays correct across 32-bit wraparound, because (u32)(tail - head) is the true distance even when tail has wrapped and head has not. The kernel’s own predicate is literally that:

static inline bool io_sqring_full(struct io_ring_ctx *ctx)
{
        struct io_rings *r = ctx->rings;
        return READ_ONCE(r->sq.tail) - READ_ONCE(r->sq.head) == ctx->sq_entries;
}

The four cursors have four distinct owners, and confusing them is the single fastest way to corrupt a ring. This is the table to memorise:

CursorProducer / consumer roleWritten byRead byThe store that publishes itThe load that observes it
sq.tailsubmission producer cursorapplicationkernelsmp_store_release (app)smp_load_acquire(&rings->sq.tail) in io_sqring_entries()
sq.headsubmission consumer cursorkernelapplicationsmp_store_release(&rings->sq.head, ctx->cached_sq_head)smp_load_acquire (app), before overwriting slots
cq.tailcompletion producer cursorkernelapplicationsmp_store_release(&ctx->rings->cq.tail, ctx->cached_cq_tail) in io_commit_cqring()smp_load_acquire (app)
cq.headcompletion consumer cursorapplicationkernelsmp_store_release (app), after reading the CQEscontrol dependency / smp_store_release on the kernel side

Note the asymmetry with the other shared words: sq_flags, sq_dropped and cq_overflow are kernel-written, application-read-only, while cq_flags is application-written, kernel-read-only (it is how the application sets IORING_CQ_EVENTFD_DISABLED to suppress eventfd notifications without a syscall). Writing a field you do not own is not merely rude; the kernel caches its own copies (ctx->cached_sq_head, ctx->cached_cq_tail) and will overwrite you.

The indirection array

Completion side — the application is the consumer, so it reads its own head, acquires the kernel’s tail, and masks:

unsigned head = cqring->head;                 /* we own it; plain load */
read_barrier();                               /* see the kernel's tail + CQE stores */
while (head != cqring->tail) {
    struct io_uring_cqe *cqe = &cqring->cqes[head & (*cqring->ring_mask)];
    /* cqe->res, cqe->user_data, cqe->flags */
    head++;
}
write_barrier();
cqring->head = head;                          /* tell the kernel these slots are free */

Submission side — the application is the producer, and here the extra level of indirection appears. The application picks any slot in the separately mapped SQE array, fills it, writes that slot’s index into the SQ ring’s array[] at the tail position, and only then advances tail:

unsigned tail = sqring->tail;
unsigned index = tail & (*sqring->ring_mask);
struct io_uring_sqe *sqe = &sqes[index];      /* need not be this index! */
init_io(sqe);
sqring->array[index] = index;                 /* indirection: ring slot -> SQE slot */
tail++;
write_barrier();                              /* SQE + array stores must land first */
sqring->tail = tail;

Why does the submission side get an indirection array when the completion side indexes cqes[] directly? Axboe’s stated reason is application ergonomics, not performance: “Some applications may embed request units inside internal data structures, and this allows them the flexibility to do so while retaining the ability to submit multiple sqes in one operation. That in turn allows for easier conversion of said applications to the io_uring interface” (design doc, §SQ ring). An application with its own free-list of SQEs can submit slot 7, then slot 2, then slot 7 again, in that order, by writing 7, 2, 7 into consecutive array[] positions.

The kernel side is io_get_sqe() (io_uring/io_uring.c L2261), and it is worth reading because it shows the validation and the failure counter:

unsigned mask = ctx->sq_entries - 1;
unsigned head = ctx->cached_sq_head++ & mask;
 
if (!(ctx->flags & IORING_SETUP_NO_SQARRAY)) {
        head = READ_ONCE(ctx->sq_array[head]);
        if (unlikely(head >= ctx->sq_entries)) {
                /* drop invalid entries */
                ...
                WRITE_ONCE(ctx->rings->sq_dropped,
                           READ_ONCE(ctx->rings->sq_dropped) + 1);
                return false;
        }
}
if (ctx->flags & IORING_SETUP_SQE128)
        head <<= 1;                  /* 128-byte SQEs: two slots per entry */
*sqe = &ctx->sq_sqes[head];

Three things fall out of those fifteen lines. First, sq_dropped is not about a full ring — it counts submissions the kernel refused because the value in array[] was out of range, i.e. an application bug. A non-zero sq_dropped means you wrote garbage into the indirection array, nothing else. Second, the READ_ONCE is load-bearing in a way the file’s header comment spells out: the SQ array “is mapped by userspace… we cannot rely on userspace always being a good citizen”, so any value read from shared memory that will later be used must be read exactly once, or a malicious (or merely racy) application could change it between the bounds check and the dereference — a classic time-of-check-to-time-of-use hole. Third, IORING_SETUP_SQE128 is implemented as nothing more than head <<= 1 over an array of 64-byte structs; the 128-byte SQE is literally two adjacent slots.

Since Linux 6.6, IORING_SETUP_NO_SQARRAY removes the indirection entirely: the SQ ring is indexed directly by tail & mask, no array[] is allocated, and sq_off.array is reported as zero (measured: 0, versus 192 on the same ring without the flag). Applications that never wanted the flexibility — which is most of them, including liburing’s normal path — save a cache line’s worth of pointer chasing per submission. Linux 7.0 went further with IORING_SETUP_SQ_REWIND, which ignores head and tail altogether and always submits from index 0 so the same SQEs stay hot in L1; it requires NO_SQARRAY and is incompatible with SQPOLL (verified present in the v7.0 uapi header and absent in v6.18).

Finally, the property that catches everyone: once the kernel consumes an SQE, the slot is immediately reusable. The design document is unambiguous — “As soon as an sqe is consumed by the kernel, the application is free to reuse that sqe entry. This is true even for cases where the kernel isn’t completely done with a given sqe yet. If the kernel does need to access it after the entry has been consumed, it will have made a stable copy of it.” The consequence is that SQ depth is a batch size, not a concurrency limit, and the real limit is the CQ ring, which is the subject of a later section.

Memory Ordering — Why the Barriers Are Mandatory

This is the part that bites anyone who rolls their own ring code, and it is the reason the whole design works without a lock. Axboe’s framing: “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).

With exactly one producer and one consumer on each ring, correctness reduces to a single obligation: publish the data before you publish the index that reveals it, and observe the index before you read the data it reveals. Everything else is a consequence.

Publishing a submission is a two-stage operation — fill the SQE and write array[], then advance sq.tail. The hazard, in the design doc’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.” Store buffers and out-of-order commit mean the tail update can become globally visible before the SQE payload; the kernel then reads a slot containing whatever was there from three requests ago, with a fresh user_data attached to it.

The kernel side states its half of the contract in a comment at the very top of io_uring/io_uring.c L6–L28, and this is the authoritative specification of what an application must do:

After the application reads the CQ ring tail, it must use an appropriate smp_rmb() to pair with the smp_wmb() the kernel uses before writing the tail (using smp_load_acquire to read the tail will do). It also needs a smp_mb() before updating CQ head (ordering the entry load(s) with the head store), pairing with an implicit barrier through a control-dependency in io_get_cqe (smp_store_release to store head will do). Failure to do so could lead to reading invalid CQ entries.

Likewise, the application must use an appropriate smp_wmb() before writing the SQ tail (ordering SQ entry stores with the tail store), which pairs with smp_load_acquire in io_get_sqring (smp_store_release to store the tail will do). And it needs a barrier ordering the SQ head load before writing new SQ entries (smp_load_acquire to read head will do).

When using the SQ poll thread (IORING_SETUP_SQPOLL), the application needs to check the SQ flags for IORING_SQ_NEED_WAKEUP after updating the SQ tail; a full memory barrier smp_mb() is needed between.

Collapsed into a table — four cursors, four pairings, and one place where acquire/release is not enough:

EventApplication doesKernel doesWhy
Publish a submissionfill SQE + array[], then RELEASE store sq.tailACQUIRE load sq.tail in io_sqring_entries()kernel must never see the tail without the SQE behind it
Reuse an SQ slotACQUIRE load sq.head before overwriting slotsRELEASE store sq.head in io_commit_sqring()app must not overwrite a slot the kernel has not yet read
Post a completion—RELEASE store cq.tail in io_commit_cqring()CQE payload must land before the tail that reveals it
Observe a completionACQUIRE load cq.tail, then read cqes[]—app must not read a CQE slot before its payload is visible
Free a CQ slotread CQEs, then RELEASE store cq.headcontrol dependency in io_get_cqe()kernel must not overwrite a slot the app is still reading
SQPOLL wakeup checkFULL smp_mb() between storing sq.tail and loading sq_flagsclears/sets IORING_SQ_NEED_WAKEUP around its sleepa store→load pair; release/acquire does not order store-then-load, only x86’s TSO accidentally does
sequenceDiagram
    autonumber
    participant A as Application (submitter)
    participant SQ as SQ ring (shared)
    participant SQE as SQE array (shared)
    participant K as Kernel (io_uring_enter)
    participant CQ as CQ ring (shared)

    Note over A,SQE: 1. produce — payload first, index last
    A->>SQE: plain stores: opcode, fd, addr, len, user_data
    A->>SQ: plain store array[tail & mask] = i
    rect rgb(240,225,210)
    A->>SQ: RELEASE store sq.tail = tail + 1
    Note right of A: smp_store_release —<br/>everything above is<br/>visible before this
    end
    A->>K: io_uring_enter(fd, to_submit, min_complete, GETEVENTS)
    rect rgb(215,232,240)
    K->>SQ: ACQUIRE load sq.tail
    Note right of K: smp_load_acquire in<br/>io_sqring_entries()
    end
    K->>SQ: READ_ONCE(array[head & mask]) then bounds-check
    K->>SQE: read sqes[index] — copy into io_kiocb
    K->>SQ: RELEASE store sq.head (slot now reusable)
    Note over K,CQ: 2. complete — payload first, index last
    K->>CQ: write cqes[tail & mask]: user_data, res, flags
    rect rgb(240,225,210)
    K->>CQ: RELEASE store cq.tail (io_commit_cqring)
    end
    K-->>A: syscall returns
    rect rgb(215,232,240)
    A->>CQ: ACQUIRE load cq.tail
    end
    A->>CQ: read cqes[head & mask]
    A->>CQ: RELEASE store cq.head (slot freed)

One request through both rings, with every barrier marked. What it shows: the identical shape on both sides — plain stores to the payload, then a single release store to the index; an acquire load of the index, then plain loads of the payload. Shaded orange is a release store, shaded blue an acquire load. The insight to take: there is no lock anywhere in this diagram, and there does not need to be, because each shared word has exactly one writer. The barriers do not provide mutual exclusion; they provide visibility ordering, which is all a single-producer/single-consumer queue needs.

Under IORING_SETUP_SQPOLL the shape changes in a way that makes the barriers strictly more load-bearing, because the syscall that used to sit between the two sides is gone:

sequenceDiagram
    autonumber
    participant A as Application
    participant SQ as SQ ring (shared)
    participant P as SQPOLL kernel thread
    Note over A,P: no io_uring_enter in the fast path at all
    A->>SQ: fill SQE + array[]
    A->>SQ: RELEASE store sq.tail
    rect rgb(245,215,215)
    A->>A: smp_mb() — FULL barrier, not release
    A->>SQ: load sq_flags
    Note right of A: store(tail) then load(flags)<br/>is a store-load pair,<br/>only smp_mb() orders it
    end
    alt sq_flags has IORING_SQ_NEED_WAKEUP
        A->>P: io_uring_enter(fd, 0, 0, IORING_ENTER_SQ_WAKEUP)
        Note right of P: poller had idled past<br/>sq_thread_idle msec
    else poller still spinning
        Note right of A: nothing — zero syscalls
    end
    loop while work or within sq_thread_idle
        P->>SQ: ACQUIRE load sq.tail
        P->>SQ: consume SQEs, RELEASE store sq.head
    end
    P->>SQ: set IORING_SQ_NEED_WAKEUP, then sleep

The SQPOLL variant of the same protocol. What it shows: with a kernel poller thread reading sq.tail concurrently, the application’s release store is the only synchronisation between the two — there is no io_uring_enter to act as an implicit fence. The extra obligation is the smp_mb() before reading sq_flags. The insight to take: smp_mb() is required here and release/acquire is not sufficient, because the pattern is store-then-load (store the tail, load the flags), and release/acquire semantics order store-store and load-load but say nothing about a store followed by a load. On x86-64’s total-store-order model a missing smp_mb() here is nearly invisible; on Arm or PowerPC it is a hang — the application sees a stale “poller is awake” flag and never sends the wakeup, and the poller sees a stale tail and goes to sleep. The scheduling and cost model of the poller thread itself belongs to io_uring as a Syscall Batching Mechanism.

In practice you do not write smp_store_release by hand; liburing wraps it. Its src/include/liburing/barrier.h defines the three primitives directly on top of C11 atomics:

#define io_uring_smp_store_release(p, v)                          \
        atomic_store_explicit((_Atomic __typeof__(*(p)) *)(p), (v), \
                              memory_order_release)
#define io_uring_smp_load_acquire(p)                              \
        atomic_load_explicit((_Atomic __typeof__(*(p)) *)(p),     \
                             memory_order_acquire)
#define io_uring_smp_mb()                                         \
        atomic_thread_fence(memory_order_seq_cst)

That is the whole synchronisation layer: two casts and a fence. On x86-64 the release store and acquire load compile to ordinary mov instructions with no fence at all — which is precisely why hand-rolled ring code appears to work on a developer’s laptop and then corrupts data on an Arm server. The failure is rare, data-dependent, and does not reproduce under a debugger: the worst class of bug there is. Use liburing’s helpers, or C11 memory_order_release/memory_order_acquire explicitly, and never a plain assignment to sq.tail.

Setup Flags That Reshape the Rings

Most IORING_SETUP_* flags are not knobs on a fixed structure — several of them change what memory is allocated and who is allowed to touch it, which is why they belong in a note about the rings rather than only in the note about the syscalls. The v6.12 uapi header defines seventeen of them (bits 0–16). Here are the ones that alter ring shape or ring access, with the constraint the kernel actually enforces:

FlagBitWhat it changes about the ringsConstraint enforced by io_uring_create()Since
IORING_SETUP_IOPOLL0no interrupt-driven completion: the CQ is filled by the application’s call into the kernel, which busy-polls the block deviceonly meaningful on O_DIRECT file descriptors whose driver supports polling; otherwise operations return -EOPNOTSUPP5.1
IORING_SETUP_SQPOLL1a kernel thread consumes sq.tail asynchronously, so no syscall is needed to submit; adds the IORING_SQ_NEED_WAKEUP protocol on sq_flagsmutually exclusive with COOP_TASKRUN, TASKRUN_FLAG and DEFER_TASKRUN — all three → -EINVAL5.1
IORING_SETUP_CQSIZE3decouples CQ depth from SQ depthcq_entries != 0, rounded to a power of two, ≤ 65536, and not less than sq_entries5.5
IORING_SETUP_CLAMP4silently clamps oversize requests instead of failing—5.5
IORING_SETUP_COOP_TASKRUN8completions no longer force an inter-processor interrupt to preempt the submitter; task work runs at the next kernel/user transitionsets notify_method = TWA_SIGNAL_NO_IPI5.19
IORING_SETUP_TASKRUN_FLAG9adds IORING_SQ_TASKRUN to sq_flags so a peek at the CQ ring can tell there is deferred work pendingrequires COOP_TASKRUN or DEFER_TASKRUN; alone → -EINVAL5.19
IORING_SETUP_SQE12810SQEs become 128 bytes (two slots); the SQE mapping doublesneeded by IORING_OP_URING_CMD NVMe passthrough5.19
IORING_SETUP_CQE3211CQEs become 32 bytes; the whole io_rings offset doubles, including the 64-byte header (measured: sq_off.array 192 → 384)—5.19
IORING_SETUP_SINGLE_ISSUER12pins the ring to one submitting task (ctx->submitter_task), which lets the kernel skip synchronisation on the issue pathany other task submitting gets -EEXIST from __io_uring_add_tctx_node_from_submit()6.0
IORING_SETUP_DEFER_TASKRUN13completion work is deferred until an io_uring_enter(…, IORING_ENTER_GETEVENTS), so CQEs land in a batch at a moment the application choserequires SINGLE_ISSUER (alone → -EINVAL); also forces the waiter to be the submitter (io_allowed_run_tw() → -EEXIST)6.1
IORING_SETUP_NO_MMAP14the application allocates the ring memory and passes it in sq_off.user_addr / cq_off.user_addr; mmap() on the ring fd then fails with -EINVALmust be contiguous memory, typically one huge page6.5
IORING_SETUP_REGISTERED_FD_ONLY15no ordinary file descriptor is ever installed — only a registered ring index is returnedrequires NO_MMAP; alone → -EINVAL6.5
IORING_SETUP_NO_SQARRAY16no indirection array is allocated; the SQ is indexed directly by tail & mask, and sq_off.array is reported as 0—6.6

Those version columns were not taken from release notes. Each was established by fetching include/uapi/linux/io_uring.h at successive release tags and recording whether the symbol is present — the technique matters here because io_uring’s user-visible surface changes almost every cycle and prose summaries of it go stale within months:

Symbolv5.1v5.4v5.5v5.18v5.19v6.0v6.1v6.4v6.5v6.6v6.11v6.12v6.18v7.0
IORING_SETUP_IOPOLL / SQPOLL✅✅✅✅✅✅✅✅✅✅✅✅✅✅
IORING_FEAT_SINGLE_MMAP❌✅✅✅✅✅✅✅✅✅✅✅✅✅
IORING_SETUP_CQSIZE, IORING_FEAT_NODROP❌❌✅✅✅✅✅✅✅✅✅✅✅✅
COOP_TASKRUN, TASKRUN_FLAG, SQE128, CQE32❌❌❌❌✅✅✅✅✅✅✅✅✅✅
IORING_SETUP_SINGLE_ISSUER❌❌❌❌❌✅✅✅✅✅✅✅✅✅
IORING_SETUP_DEFER_TASKRUN❌❌❌❌❌❌✅✅✅✅✅✅✅✅
IORING_OFF_PBUF_RING❌❌❌❌❌❌❌✅✅✅✅✅✅✅
NO_MMAP, REGISTERED_FD_ONLY❌❌❌❌❌❌❌❌✅✅✅✅✅✅
IORING_SETUP_NO_SQARRAY❌❌❌❌❌❌❌❌❌✅✅✅✅✅
IORING_CQE_F_BUF_MORE, IOU_PBUF_RING_INC❌❌❌❌❌❌❌❌❌❌❌✅✅✅
IORING_SETUP_CQE_MIXED, IORING_CQE_F_32❌❌❌❌❌❌❌❌❌❌❌❌✅✅
IORING_SETUP_SQ_REWIND❌❌❌❌❌❌❌❌❌❌❌❌❌✅

Existence-check matrix, built by fetching the same header path at each tag and grepping for the symbol (HTTP 200 with the symbol present = ✅). The insight: the “since” column in any flag documentation is a claim that can be checked mechanically in about a minute, and doing so caught two errors while this note was written — IORING_CQE_F_BUF_MORE is a 6.12 addition (absent at v6.11), not the 6.10 it is often attributed to, and IORING_SETUP_CQE_MIXED really is 6.18 while SQ_REWIND really is 7.0.

Every constraint in the first table above was then verified by calling io_uring_setup() with each combination on a live kernel and printing errno:

plain, entries=4                               -> ok  sq=4      cq=8      sq_off.array=192
NO_SQARRAY                                     -> ok  sq=4      cq=8      sq_off.array=0  (no sq array!)
SQE128|CQE32                                   -> ok  sq=4      cq=8      sq_off.array=384
CQSIZE cq=64                                   -> ok  sq=4      cq=64     sq_off.array=1088
CQSIZE cq=2 (< sq_entries=4)                   -> FAIL Invalid argument
CQSIZE cq=0                                    -> FAIL Invalid argument
entries=0                                      -> FAIL Invalid argument
entries=33000 (> IORING_MAX_ENTRIES)           -> FAIL Invalid argument
entries=33000 | CLAMP                          -> ok  sq=32768  cq=65536  sq_off.array=1048640
DEFER_TASKRUN alone                            -> FAIL Invalid argument
DEFER_TASKRUN|SINGLE_ISSUER                    -> ok  sq=4      cq=8      sq_off.array=192
SQPOLL|DEFER_TASKRUN|SINGLE_ISSUER             -> FAIL Invalid argument
SQPOLL|COOP_TASKRUN                            -> FAIL Invalid argument
TASKRUN_FLAG alone                             -> FAIL Invalid argument
TASKRUN_FLAG|COOP_TASKRUN                      -> ok  sq=4      cq=8      sq_off.array=192
REGISTERED_FD_ONLY alone                       -> FAIL Invalid argument
SQPOLL (needs no privilege since 5.13)         -> ok  sq=4      cq=8      sq_off.array=192
IOPOLL                                         -> ok  sq=4      cq=8      sq_off.array=192
bogus flag bit 31                              -> FAIL Invalid argument

Uncertain — a kernel header comment that contradicts the kernel

The v6.12 uapi header’s comment on IORING_SETUP_TASKRUN_FLAG reads: “If COOP_TASKRUN is set, get notified if task work is available for running and a kernel transition would be needed to run it. This sets IORING_SQ_TASKRUN in the sq ring flags. Not valid with COOP_TASKRUN.” The last sentence contradicts the first two. The code in io_uring_create() accepts the combination (else if (ctx->flags & IORING_SETUP_COOP_TASKRUN) { ... } falls through without the TASKRUN_FLAG check), the man page says the flag is “used in conjunction with IORING_SETUP_COOP_TASKRUN or IORING_SETUP_DEFER_TASKRUN”, and the live test above confirms TASKRUN_FLAG|COOP_TASKRUN succeeds while TASKRUN_FLAG alone fails. The header comment is stale and wrong; the man page and the code agree. To resolve properly: a patch to the uapi comment. uncertain

flowchart TD
  START["io_uring_setup(entries, params)"] --> E0{"entries == 0?"}
  E0 -->|yes| EINVAL["-EINVAL"]
  E0 -->|no| EMAX{"entries &gt; 32768<br/>(IORING_MAX_ENTRIES)?"}
  EMAX -->|"yes, no CLAMP"| EINVAL
  EMAX -->|"yes, CLAMP"| CL["entries = 32768"]
  EMAX -->|no| RFD
  CL --> RFD{"REGISTERED_FD_ONLY<br/>without NO_MMAP?"}
  RFD -->|yes| EINVAL
  RFD -->|no| POW["sq_entries =<br/>roundup_pow_of_two(entries)"]
  POW --> CQS{"CQSIZE set?"}
  CQS -->|no| DEF["cq_entries = 2 * sq_entries"]
  CQS -->|yes| CQV{"cq_entries == 0<br/>or &gt; 65536 (no CLAMP)<br/>or &lt; sq_entries?"}
  CQV -->|yes| EINVAL
  CQV -->|no| CQR["cq_entries =<br/>roundup_pow_of_two(cq_entries)"]
  DEF --> SQP
  CQR --> SQP{"SQPOLL set?"}
  SQP -->|"yes + any of COOP_TASKRUN,<br/>TASKRUN_FLAG, DEFER_TASKRUN"| EINVAL
  SQP -->|"no, and TASKRUN_FLAG set<br/>without COOP or DEFER"| EINVAL
  SQP -->|otherwise| DTR{"DEFER_TASKRUN without<br/>SINGLE_ISSUER?"}
  DTR -->|yes| EINVAL
  DTR -->|no| ALLOC["allocate io_rings + sqes,<br/>fill sq_off / cq_off / features,<br/>install fd"]
  ALLOC --> OK["ring fd"]

The validation order inside io_uring_create() in v6.12, drawn as the decision tree it actually is. What it shows: every -EINVAL an application can get out of io_uring_setup for reasons other than memory, and the order in which the checks fire. The insight to take: the flags are not independent bits — three pairs are hard-coupled (DEFER_TASKRUN→SINGLE_ISSUER, REGISTERED_FD_ONLY→NO_MMAP, TASKRUN_FLAG→COOP_TASKRUN-or-DEFER_TASKRUN) and one flag (SQPOLL) excludes a whole family. An application that probes flags by feature-detection must therefore probe combinations, not individual bits.

SINGLE_ISSUER and DEFER_TASKRUN, measured

IORING_SETUP_SINGLE_ISSUER is documented as “a hint to the kernel that only a single task (or thread) will submit requests”, but the kernel enforces it, and the enforcement point is precise — it is the submit path, not io_uring_enter as such. Running the same three-step experiment (main thread submits, then a second thread submits, then that second thread waits) against three ring configurations:

plain ring (no SINGLE_ISSUER):
  main   thread submits 1 SQE -> 1
  second thread submits 1 SQE -> 1 (ok)
  second thread waits GETEVENTS -> 0 (ok)
IORING_SETUP_SINGLE_ISSUER:
  main   thread submits 1 SQE -> 1
  second thread submits 1 SQE -> -1 (File exists)      <- EEXIST
  second thread waits GETEVENTS -> 0 (ok)
SINGLE_ISSUER|DEFER_TASKRUN:
  main   thread submits 1 SQE -> 1
  second thread submits 1 SQE -> -1 (File exists)
  second thread waits GETEVENTS -> -1 (File exists)    <- EEXIST on the WAIT too

The middle block shows SINGLE_ISSUER rejecting a foreign submitter with EEXIST (an odd choice of errno, but that is what __io_uring_add_tctx_node_from_submit() returns) while still allowing any thread to wait. The bottom block shows the extra restriction DEFER_TASKRUN adds: because the deferred completion work must run in the submitting task’s context, io_cqring_wait() calls io_allowed_run_tw() and refuses a foreign waiter too. That is the practical meaning of the man page’s “also enforces that the call to io_uring_enter(2) is called from the same thread that submitted requests” — and it is why a thread-pool design that submits on worker threads and reaps on a dedicated reaper thread cannot use DEFER_TASKRUN at all. SINGLE_ISSUER was originally added purely as the precondition for DEFER_TASKRUN; work to make it earn its keep on its own — eliding uring_lock on the issue path — was still being posted as recently as December 2025 (LWN, “io_uring: avoid uring_lock for IORING_SETUP_SINGLE_ISSUER”).


CQ Overflow — The Real Concurrency Limit

Because an SQE slot is reusable the instant the kernel consumes it, the submission ring does not bound how many requests can be outstanding. The completion ring does. If the kernel finishes more requests than the CQ ring has free slots, something has to give, and what gives has changed over the interface’s life.

In the 5.1 interface, excess completions were dropped and counted in cq_overflow; the application was simply told it had lost results. From 5.5, IORING_FEAT_NODROP changed the policy: a CQE that will not fit is kmalloc’d onto an internal ctx->cq_overflow_list, the IORING_SQ_CQ_OVERFLOW bit is set in sq_flags, and the entries are flushed back into the ring as the application frees slots. Only if that allocation itself fails — i.e. the machine is out of memory — is anything actually lost (io_cqring_event_overflow(), io_uring/io_uring.c L725).

Driving a 4-entry SQ / 8-entry CQ ring with sixteen IORING_OP_NOPs and never reaping shows the whole mechanism:

ring: sq_entries=4 cq_entries=8
submitting 16 NOPs in batches of 4, NEVER reaping a CQE...
  batch 0: enter=4 cq_head=0 cq_tail=4 (in ring: 4/8)  sq_flags=0x0 cq_overflow=0
  batch 1: enter=4 cq_head=0 cq_tail=8 (in ring: 8/8)  sq_flags=0x0 cq_overflow=0
  batch 2: enter=4 cq_head=0 cq_tail=8 (in ring: 8/8)  sq_flags=0x2 cq_overflow=0
       ^ IORING_SQ_CQ_OVERFLOW is SET
  batch 3: enter=4 cq_head=0 cq_tail=8 (in ring: 8/8)  sq_flags=0x2 cq_overflow=0
  sq_dropped=0
after freeing 4 CQ slots + enter(GETEVENTS): cq_head=4 cq_tail=12 sq_flags=0x2

Read that trace carefully, because four separate facts are visible in it. cq_tail stops at 8 — it cannot advance past cq_head + cq_entries, so the ring never overwrites an unread CQE. sq_flags becomes 0x2 = IORING_SQ_CQ_OVERFLOW, which the application can poll from shared memory with no syscall; this is the only in-band signal that results are being held back. cq_overflow stays 0 — nothing was dropped, confirming IORING_FEAT_NODROP in practice. And after freeing four slots and calling io_uring_enter with IORING_ENTER_GETEVENTS, cq_tail jumps from 8 to 12: the four held CQEs were flushed from the overflow list back into the ring. Note also sq_dropped=0 throughout — overflow is a completion-side condition and does not touch the submission-side drop counter at all.

flowchart TD
  DONE["request completes"] --> GET["io_get_cqe():<br/>is there a free CQ slot?<br/>(cq_tail - cq_head &lt; cq_entries)"]
  GET -->|yes| FILL["write cqes[tail &amp; mask]<br/>RELEASE store cq.tail"]
  FILL --> APP["application sees it on its<br/>next ACQUIRE load of cq.tail"]
  GET -->|no| KM["kmalloc a struct io_overflow_cqe<br/>GFP_ATOMIC"]
  KM -->|success| LIST["append to ctx-&gt;cq_overflow_list<br/>set IORING_SQ_CQ_OVERFLOW in sq_flags"]
  KM -->|"allocation failed (OOM)"| DROP["increment rings-&gt;cq_overflow<br/>set IO_CHECK_CQ_DROPPED_BIT<br/>RESULT IS LOST"]
  LIST --> FLUSH["later: application frees slots and<br/>enters the kernel, or the kernel flushes;<br/>held CQEs move back into the ring"]
  FLUSH --> APP
  DROP --> BAD["application must treat this as<br/>a hard error: a completion<br/>it will never be told about"]

What happens to a completion that does not fit. What it shows: three outcomes, not two — the fast path, the held-in-kernel path that IORING_FEAT_NODROP added in 5.5, and the genuine-loss path that survives only for allocation failure. The insight to take: IORING_SQ_CQ_OVERFLOW is a performance and correctness warning, not a fatal error — you are now paying a kmalloc per completion and a flush per drain, which destroys the interface’s whole point. Size the CQ ring (via IORING_SETUP_CQSIZE) to your real in-flight ceiling, or throttle submissions against cq_tail - cq_head, rather than relying on the overflow list as a safety net.


Running the Rings Without liburing

Almost nobody writes this by hand — liburing’s io_uring_queue_init(3) does the setup, the mmaps and the barriers. But doing it once, with no library at all, is what makes the layout concrete. The following is a complete, working submission and completion cycle in about seventy lines: two raw syscall() wrappers, three mmap calls, one IORING_OP_READV, and hand-written release/acquire stores.

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/uio.h>
#include <linux/io_uring.h>
#include <stdatomic.h>
 
/* glibc ships no wrappers for these two — go straight to the syscall. */
static int io_uring_setup(unsigned entries, struct io_uring_params *p)
{ return (int)syscall(__NR_io_uring_setup, entries, p); }
static int io_uring_enter(int fd, unsigned to_submit, unsigned min_complete, unsigned flags)
{ return (int)syscall(__NR_io_uring_enter, fd, to_submit, min_complete, flags, NULL, 0); }
 
/* The two primitives the whole protocol rests on. */
#define LOADA(p)     atomic_load_explicit((_Atomic unsigned *)(p), memory_order_acquire)
#define STOREL(p, v) atomic_store_explicit((_Atomic unsigned *)(p), (v), memory_order_release)
 
int main(int argc, char **argv)
{
    struct io_uring_params p;
    memset(&p, 0, sizeof(p));            /* MANDATORY: p.resv[] must be zero */
    int ring_fd = io_uring_setup(4, &p); /* ask for 4 SQ entries */
 
    /* One mapping for the rings. IORING_OFF_SQ_RING and IORING_OFF_CQ_RING
     * resolve to the same kernel object, so with IORING_FEAT_SINGLE_MMAP we
     * map the larger of the two computed sizes once and use it for both. */
    size_t sring_sz = p.sq_off.array + p.sq_entries * sizeof(unsigned);
    size_t cring_sz = p.cq_off.cqes  + p.cq_entries * sizeof(struct io_uring_cqe);
    if (p.features & IORING_FEAT_SINGLE_MMAP)
        sring_sz = cring_sz = (cring_sz > sring_sz ? cring_sz : sring_sz);
    void *sq_ptr = mmap(NULL, sring_sz, PROT_READ | PROT_WRITE,
                        MAP_SHARED | MAP_POPULATE, ring_fd, IORING_OFF_SQ_RING);
    void *cq_ptr = (p.features & IORING_FEAT_SINGLE_MMAP) ? sq_ptr
                 : mmap(NULL, cring_sz, PROT_READ | PROT_WRITE,
                        MAP_SHARED | MAP_POPULATE, ring_fd, IORING_OFF_CQ_RING);
    /* The SQEs are a SEPARATE allocation and always need their own mapping. */
    struct io_uring_sqe *sqes = mmap(NULL, p.sq_entries * sizeof(struct io_uring_sqe),
                        PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
                        ring_fd, IORING_OFF_SQES);
 
    /* Derive every field pointer from the kernel-supplied byte offsets.
     * Never hard-code these — they are not ABI. */
    unsigned *sq_head = sq_ptr + p.sq_off.head,  *sq_tail = sq_ptr + p.sq_off.tail;
    unsigned *sq_mask = sq_ptr + p.sq_off.ring_mask, *sq_array = sq_ptr + p.sq_off.array;
    unsigned *cq_head = cq_ptr + p.cq_off.head,  *cq_tail = cq_ptr + p.cq_off.tail;
    unsigned *cq_mask = cq_ptr + p.cq_off.ring_mask;
    struct io_uring_cqe *cqes = cq_ptr + p.cq_off.cqes;
 
    int fd = open(argv[1], O_RDONLY);
    static char buf[4096];
    struct iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) };
 
    /* ---- produce one SQE ---- */
    unsigned tail  = *sq_tail;              /* we own the tail: plain load is fine */
    unsigned index = tail & *sq_mask;       /* free-running counter -> slot */
    struct io_uring_sqe *sqe = &sqes[index];
    memset(sqe, 0, sizeof(*sqe));           /* unions: clear before filling */
    sqe->opcode    = IORING_OP_READV;
    sqe->fd        = fd;
    sqe->off       = 0;
    sqe->addr      = (unsigned long)&iov;   /* addr = iovec array for vectored ops */
    sqe->len       = 1;                     /* len = NUMBER OF IOVECS, not bytes */
    sqe->user_data = 0xC0FFEEUL;            /* our tag, echoed into the CQE */
    sq_array[index] = index;                /* the indirection array */
    STOREL(sq_tail, tail + 1);              /* RELEASE: publishes everything above */
 
    /* ---- one syscall does submit + wait ---- */
    io_uring_enter(ring_fd, 1, 1, IORING_ENTER_GETEVENTS);
 
    /* ---- consume ---- */
    unsigned head = *cq_head;               /* we own the head */
    unsigned t    = LOADA(cq_tail);         /* ACQUIRE: makes the CQEs visible */
    while (head != t) {
        struct io_uring_cqe *cqe = &cqes[head & *cq_mask];
        printf("CQE user_data=0x%llX res=%d flags=0x%x\n",
               (unsigned long long)cqe->user_data, cqe->res, cqe->flags);
        head++;
    }
    STOREL(cq_head, head);                  /* RELEASE: the slots are free again */
    return 0;
}

Six lines deserve emphasis. memset(&p, 0, sizeof(p)) is not hygiene — io_uring_setup() rejects the call with -EINVAL if any word of p.resv[3] is non-zero. sqe->len = 1 for IORING_OP_READV is a count of iovecs, not a byte count; getting that wrong is the most common first-program bug. memset(sqe, 0, …) matters because the struct is almost entirely unions and a stale addr3 or personality from a previous use of the slot will be interpreted. The plain load of *sq_tail is correct precisely because the application owns that word; the STOREL on the next line is what makes it visible. And the while (head != t) loop compares free-running counters directly — never head < t, which breaks on wraparound.

Compiled with gcc -O2 -Wall (GCC 16.2.1, glibc 2.43, Fedora 44) and run on Linux 7.1.13-200.fc44.x86_64, it prints:

== io_uring_params returned by io_uring_setup(4, &p) ==
sq_entries      = 4   (rounded up to a power of two)
cq_entries      = 8   (default: 2 * sq_entries)
features        = 0x0003ffff
  SINGLE_MMAP=1 NODROP=1 SUBMIT_STABLE=1 EXT_ARG=1
sq_off: head=0 tail=4 ring_mask=16 ring_entries=24 flags=36 dropped=32 array=192
cq_off: head=8 tail=12 ring_mask=20 ring_entries=28 overflow=44 cqes=64 flags=40
sizeof(struct io_uring_sqe)=64  sizeof(struct io_uring_cqe)=16
 
== mapping ==
SQ_RING mapping len=208 at 0x7f23c3b27000
CQ_RING mapping len=208 at 0x7f23c3b27000  (same pointer as SQ: YES)
SQES    mapping len=256 at 0x7f23c3b26000
 
== submit ==
SQ before enter: head=0 tail=1  (mask=0x3) array[0]=0
io_uring_enter(to_submit=1, min_complete=1, GETEVENTS) = 1
SQ after  enter: head=1 tail=1 (kernel advanced head)
 
== reap ==
CQ head=0 tail=1 -> 1 completion(s) available
CQE user_data=0xC0FFEE res=700 flags=0x0
first 700 bytes read: "NAME="Fedora Linux"
VERSION="44 (Worksta"
CQ head published back as 1

Five things in that output are worth pausing on. The SQ and CQ mappings landed at the identical address (0x7f23c3b27000 for both) — not a coincidence of the allocator, but the direct consequence of io_uring_validate_mmap_request() returning ctx->rings for both offsets. The ring mapping is 208 bytes (sq_off.array 192 + 4 × 4 bytes of array) while the SQE mapping is 256 (4 × 64); both get rounded up to a page, and the SQE mapping happened to land on the page immediately below. sq.head advanced from 0 to 1 during io_uring_enter — the kernel’s release store, observed from userspace with no further syscall. The tag came back byte-identical as 0xC0FFEE, and res=700 is the byte count, exactly as a readv(2) would have returned it.

Finally, features = 0x0003ffff is eighteen bits — but v6.12 only defines sixteen (IORING_FEAT_SINGLE_MMAP through IORING_FEAT_MIN_TIMEOUT). Bits 16 and 17 are IORING_FEAT_RW_ATTR and IORING_FEAT_NO_IOWAIT, added after v6.12 and present in the 7.1 kernel this ran on. That number is the cleanest possible illustration of why feature bits exist: an application built against v6.12 headers sees two bits it has no name for, ignores them, and keeps working.

What the Rings Point At — Registered Resources and the Third Ring

The rings carry descriptions of work; the work itself touches buffers and file descriptors. Three io_uring_register(2) facilities strip per-operation cost off those references, and one of them adds an entire additional shared ring.

Fixed buffers (IORING_REGISTER_BUFFERS, opcode 0). The application pre-registers an array of struct iovec; the kernel pins those pages once and keeps the translations. Thereafter IORING_OP_READ_FIXED / IORING_OP_WRITE_FIXED (and IORING_RECVSEND_FIXED_BUF for sockets) set sqe->buf_index to the registered index and let addr/len name a sub-range, and the kernel skips get_user_pages() entirely on every operation. The v6.12 limits, read from io_uring/rsrc.c: at most IORING_MAX_REG_BUFFERS = 1 << 14 = 16,384 buffers (L36, L1038), and each iov_len must be ≤ SZ_1G — 1 GiB — or registration fails with -EINVAL (L104). Per io_uring_register(2) the memory “must be anonymous, non-file-backed”; huge pages work, but “the entire huge page will be pinned” even if only part of it is registered, which is a real memory-accounting surprise on a 1 GiB page.

Fixed files (IORING_REGISTER_FILES, opcode 2). Registering descriptors lets the kernel skip the fget/fput reference churn on a possibly-shared file table for every request. An SQE then sets IOSQE_FIXED_FILE and puts the registered index in sqe->fd. The set may be sparse (-1 entries, or IORING_RSRC_REGISTER_SPARSE) and updated in place with IORING_REGISTER_FILES_UPDATE; the ceiling is IORING_MAX_FIXED_FILES = 1 << 20 = 1,048,576, further bounded by RLIMIT_NOFILE (rsrc.c L35, L698–700). Opcodes that create descriptors (openat, accept, socket) can write straight into a registered slot via sqe->file_index, or pass IORING_FILE_INDEX_ALLOC (~0U) to let the kernel choose a free slot and report it in cqe->res — so a connection can be accepted, used and closed without a real file descriptor ever existing.

Provided buffer rings (IORING_REGISTER_PBUF_RING, opcode 22, since 5.19) are the interesting one for this note, because they add a third shared ring. The problem they solve: a server arming ten thousand multishot receives must not dedicate ten thousand buffers to connections that may be idle for hours. With a buffer ring, the application publishes a pool and the kernel picks a buffer at the moment data actually arrives (LWN, “Automatic buffer selection for io_uring”).

The structure is a small masterpiece of space thrift:

struct io_uring_buf {
        __u64   addr;
        __u32   len;
        __u16   bid;      /* buffer id, reported back in cqe->flags >> 16 */
        __u16   resv;
};
 
struct io_uring_buf_ring {
        union {
                struct { __u64 resv1; __u32 resv2; __u16 resv3; __u16 tail; };
                __DECLARE_FLEX_ARRAY(struct io_uring_buf, bufs);
        };
};

The ring’s tail is overlaid on the resv field of entry zero — the header comment says why: “To avoid spilling into more pages than we need to, the ring tail is overlaid with the io_uring_buf->resv field.” Entry 0 is sacrificed to hold the cursor so that a 4 KiB page holds a full power-of-two number of usable entries rather than one less. The application is the producer (it adds buffers and advances tail); the kernel is the consumer (it takes buffers and advances a head it keeps privately). Same SPSC discipline, same release/acquire obligation, third ring.

Mapping it uses the fourth magic offset with the buffer-group id packed in: IORING_OFF_PBUF_RING | (bgid << IORING_OFF_PBUF_SHIFT), i.e. 0x80000000 | (bgid << 16). An SQE opts in by setting IOSQE_BUFFER_SELECT and putting the group id in buf_group; the completion reports which buffer was used via IORING_CQE_F_BUFFER plus cqe->flags >> 16. Since 6.12, IOU_PBUF_RING_INC allows a buffer to be consumed incrementally — several completions can draw from one large registered range, each flagged IORING_CQE_F_BUF_MORE until the buffer is finally released.

All three facilities point in the same direction. Batching removes the syscall cost per operation; registration removes the page-pinning and reference-counting cost per operation; buffer rings remove the memory reservation cost per idle connection. The rings are what makes the first possible; the registration APIs are what makes the other two possible without leaving the ring model.


Alternatives — Other Ways to Move Work Across the Kernel Boundary

The SQ/CQ pair is one point in a design space, and the alternatives are instructive precisely because they each give up something specific.

MechanismData crossing per operationOrdering guaranteeBatchingWhere the memory lives
read(2) / write(2)copy in + copy out, one trap each waystrict: the call returns when donenoneapplication buffer, copied
readv / writev (Vectored IO readv and writev)one trap, N segmentsstrictN segments of one operationapplication buffers, copied per segment
sendmmsg / recvmmsg (sendmmsg and recvmmsg Batched Socket Calls)one trap, N messagesstrict, in array orderN instances of one operationapplication buffers, copied
Linux AIO (io_submit)104 bytes copied per op (64 + 8 submit, 32 complete)unorderedyes, but each submit copiesapplication buffers, copied; ring is read-only to userspace
io_uring ringszero in the steady stateunordered by default; IOSQE_IO_LINK / IO_DRAIN on requestarbitrary, heterogeneousshared mapping, no copy
epoll readiness (epoll and Scalable Readiness Notification)one trap per wait, then one trap per I/On/a — reports readiness, not completionN events, but the I/O still costs a syscall eachkernel-owned interest set

The row that explains io_uring’s existence is the AIO one. Axboe’s own criticism of Linux AIO in the design document is not that it is asynchronous badly, but that its ABI copies: “Each IO submission ends up needing to copy 64 + 8 bytes and each completion copies 32 bytes. That’s 104 bytes of memory copy, for IO that’s supposedly zero copy… The exposed completion event ring buffer mostly gets in the way by making completions slower, and is hard (impossible?) to use correctly from an application.” The SQ/CQ design is the direct answer: make the ring writable by userspace so submission is a store rather than a copy, and give it a barrier protocol so that is safe.

The epoll row is the one most often confused with io_uring, and the distinction is completion-versus-readiness. epoll tells you that a descriptor would not block; you then perform the I/O, which costs a syscall. io_uring tells you the I/O has finished. For a workload of many small operations on ready descriptors, io_uring’s advantage is not that it waits better — it is that the operations themselves stopped being syscalls. See epoll and Scalable Readiness Notification for that model in full.


Production Notes

Nobody hand-rolls the barriers, and that is correct. liburing exists because the barrier protocol is easy to get subtly wrong and impossible to test for on x86. io_uring_queue_init(3) performs the setup and the mmaps; io_uring_get_sqe(3) / io_uring_submit(3) / io_uring_wait_cqe(3) / io_uring_cqe_seen(3) wrap the index protocol with the right release/acquire pairs; io_uring_submit(3) even checks the IORING_SQ_NEED_WAKEUP flag for you so SQPOLL code looks identical to non-SQPOLL code. The raw program in this note is a teaching device, not a recommendation. Write the raw version once to understand the layout, then use the library.

Ring sizing is a real capacity-planning decision. The default cq_entries = 2 × sq_entries is a convenience, not an answer. Because the SQ bounds batch size while the CQ bounds concurrency, a design that submits small batches but keeps thousands of requests in flight — the usual shape for a network server — wants a small SQ and a large CQ, which means IORING_SETUP_CQSIZE. Leaving it at the default and then discovering IORING_SQ_CQ_OVERFLOW in production means every completion past the limit is taking a GFP_ATOMIC allocation on the completion path.

Check sq_flags from shared memory, not by syscall. All three signals the kernel raises there — IORING_SQ_NEED_WAKEUP (the SQPOLL thread has idled out), IORING_SQ_CQ_OVERFLOW (completions are being held), IORING_SQ_TASKRUN (deferred work is pending) — are readable with a plain load. An application that only learns about them by entering the kernel has given back the property it adopted io_uring for.

Shared writable memory is an attack surface, and the kernel treats it as one. Every read of a userspace-controlled word in the ring goes through READ_ONCE() precisely so a value cannot change between validation and use; io_get_sqe() bounds-checks the indirection array on every single submission. That defensive posture is not paranoia — io_uring has been a rich source of privilege-escalation bugs, enough that the kernel gained a kernel.io_uring_disabled sysctl (0 = allowed, 1 = privileged only, 2 = off) so administrators can turn the whole interface off (LWN, “Add a sysctl to disable io_uring system-wide”). The security history proper, and what it means for whether you should enable io_uring at all, belongs to io_uring as a Syscall Batching Mechanism.

The interface is still moving. Between v6.12 and v7.1 the ring gained IORING_SETUP_CQE_MIXED (variable-size CQEs, 6.18), IORING_SETUP_SQ_REWIND (non-circular submission, 7.0), two more feature bits, and seven more opcodes. Anything written about io_uring carries a shorter shelf life than most kernel documentation; pin your claims to a tag and re-check them.


Failure Modes and Misunderstandings

Forgetting the barrier between the SQE stores and the tail publish. The classic torn-entry bug: the kernel reads a slot whose opcode is new but whose addr is three requests old. It will not reproduce on x86-64, where release stores are ordinary stores; it will corrupt data on Arm and PowerPC. Use io_uring_smp_store_release() or C11 memory_order_release, never sq->tail = tail.

Assuming completions arrive in submission order. They do not, and io_uring(7) says so outright. Slot position carries no information. Match on user_data. If you genuinely need ordering, IOSQE_IO_LINK for a pairwise dependency, IOSQE_IO_DRAIN for a full barrier — and understand that both cost you the parallelism you adopted the interface for.

Assuming one CQE per SQE. Multishot poll/accept/recv set IORING_CQE_F_MORE and post many; zero-copy send posts two (the send result and the IORING_CQE_F_NOTIF buffer-release notification); IOSQE_CQE_SKIP_SUCCESS posts none on success. A reap loop that counts down from the submission count will hang or over-run.

Treating SQ depth as the in-flight limit. The kernel copies what it needs at consumption time, so the slot is free immediately and you can have far more requests outstanding than the SQ is deep. The binding constraint is the CQ ring, and exceeding it costs you a GFP_ATOMIC allocation per completion.

Misreading sq_dropped. It is not a “ring was full” counter. It counts submissions the kernel refused because the value it read from the indirection array[] was >= sq_entries — an application bug, always. In the overflow experiment above it stayed at 0 while the CQ ring was saturated.

Treating IORING_SQ_CQ_OVERFLOW as harmless because nothing is lost. Nothing is lost — until the kmalloc fails, at which point cq_overflow increments and a result vanishes with no way to recover it. Meanwhile every held completion costs an allocation and a later flush. It is a correctness cliff with a performance slope leading up to it.

Hard-coding field offsets. sq.head is at byte 0 today. It is not ABI, and the kernel goes to the trouble of reporting sq_off/cq_off precisely so it can be reordered. Read the offsets from params.

Forgetting memset on struct io_uring_params. io_uring_setup() rejects the call with -EINVAL if any of p.resv[3] is non-zero — a stack-garbage bug that presents as “io_uring is not supported on this kernel”.

Forgetting memset on a reused SQE. The struct is overwhelmingly unions. A stale personality, file_index or addr3 from the slot’s previous occupant will be interpreted according to the new opcode.

Reusing a buffer too early under SQPOLL. Without SQPOLL, IORING_FEAT_SUBMIT_STABLE guarantees the kernel has consumed anything it needs from application memory (an iovec array, a msghdr) by the time io_uring_enter() returns. Under SQPOLL there is no such return to synchronise against, so those structures must live until completion. The data buffer must live until completion in either case.

Mapping the CQ ring separately and assuming it is separate memory. It is the same pages. Writing through one mapping is visible through the other, and unmapping one leaves the other valid. Harmless, but it confuses debugging.


See Also

  • io_uring as a Syscall Batching Mechanism — the near-title sibling, and the boundary is deliberate. That note covers the why and when: the cost model that makes batching worth it, the three system calls as system calls, how SQPOLL and the io-wq worker pool schedule work, and io_uring’s security history and the kernel.io_uring_disabled sysctl. This note covers the what: the shared memory layout, the SQE and CQE formats, the index protocol, the barrier pairings, and the setup flags that reshape the rings. Neither duplicates the other; read that one first if you are deciding whether to use io_uring, this one if you are implementing against it.
  • Why System Calls Are Expensive — the fixed trap cost, and the post-Spectre mitigations that inflated it, which is what the shared rings exist to amortize
  • epoll and Scalable Readiness Notification — readiness notification versus completion notification, the distinction that io_uring is most often confused with
  • sendmmsg and recvmmsg Batched Socket Calls — batching N instances of one operation, the contrast that shows what the polymorphic opcode byte buys
  • Vectored IO readv and writev — the scatter/gather model that IORING_OP_READV/WRITEV mirror, and where sqe->len gets its “count of iovecs” meaning
  • The Multi-Queue Block Layer blk-mq — the per-CPU submission queues underneath IORING_SETUP_IOPOLL; the same “one producer per queue, no shared lock” idea one layer down
  • The Generic Syscall Entry and Exit Layer — what io_uring_enter itself pays on the way in and out, and where TWA_SIGNAL task work actually runs
  • Drawing Wire Formats with Mermaid Packet Diagrams — the house style used for the struct io_rings byte-layout diagram above
  • Linux System Call Interface MOC — parent map (§9, Batching Syscalls)
  • Linux Block Layer and Storage MOC — parent map; io_uring as the modern storage submission path
  • Linux Memory Management MOC — mmap, MAP_POPULATE and page pinning, the substrate the shared rings and fixed buffers are built on