Systems Programming · OS Interfaces

io_uring: Shared Rings, Submission Entries, and Completion Ordering

How a submission queue and a completion queue are mapped into the process, what the two barriers protect, and why a batched interface changes the cost of a system call.

Every interface described so far has the same shape: the program makes a call, the kernel does the work, the call returns. io_uring removes the call from the common path. The program writes a description of the operation into memory that the kernel also maps, advances a counter, and the kernel reads the description when it is ready. The result appears in a second shared region. A system call is still involved, but it is a notification rather than the mechanism, and one call can carry a batch of operations or none at all.

Two rings and one array

io_uring_setup takes a requested number of entries and returns a descriptor referring to a kernel object. It also fills a parameters structure describing the layout the kernel chose. The program then maps three regions: the submission queue ring, the completion queue ring, and the array of submission entries.

The submission queue is a ring buffer of indices. It has a head, which the kernel advances as it consumes entries, and a tail, which the program advances as it produces them. The entries themselves are not in the ring; the ring holds 32-bit indices into a separate array of submission queue entries, each 64 bytes. The separation exists so that the same entry slot can be reused while older indices are still in flight, and so that the array of entries can be mapped once and written without touching the ring’s cache line.

The completion queue is the mirror image. It holds completion queue entries, each 16 bytes: a 64-bit user data field, a 32-bit result, and a 32-bit flags field. The program consumes from the head and the kernel produces at the tail.

#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>

int main(void) {
    struct io_uring ring;
    if (io_uring_queue_init(64, &ring, 0) < 0) return 1;

    int fd = open("input.bin", O_RDONLY | O_DIRECT);
    static char buf[4096] __attribute__((aligned(4096)));

    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, sizeof buf, 0);
    io_uring_sqe_set_data(sqe, buf);        /* opaque tag, returned in the CQE */

    io_uring_submit(&ring);                 /* publish and notify the kernel */

    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);
    printf("result=%d tag=%p\n", cqe->res, io_uring_cqe_get_data(cqe));
    io_uring_cqe_seen(&ring, cqe);
    io_uring_queue_exit(&ring);
    return 0;
}

The user data field is the design’s most useful affordance. It is copied from the submission entry to the completion entry untouched, so a program can put a pointer to its own request object there and skip a lookup table indexed by operation. It survives the operation being reordered, cancelled, or completed after the submitting code has moved on.

Why the two barriers are not optional

The rings are shared memory written by two parties that do not share a lock. Ordering is established by two explicit barriers, and omitting either produces failures that appear only on weakly ordered hardware or under compiler optimisation.

Before advancing the submission tail, the program must ensure the entry contents and the index array write are visible before the tail value. On x86 a compiler barrier suffices because stores are not reordered with stores; on AArch64 the barrier must also prevent hardware reordering. The library issues the correct one. If the entry write becomes visible after the tail advance, the kernel reads a stale entry and performs an operation the program did not intend — a read into an old buffer, or a write of a length from a previous request.

After reading a completion entry, the program must ensure it reads the entry’s fields before advancing the completion head. The kernel may overwrite the slot as soon as the head moves, so a program that advances first and reads second can observe the next operation’s result under the current operation’s tag. This is the more dangerous of the two, because it produces a plausible result attached to the wrong request rather than an obvious failure.

The completion head advance is also what tells the kernel the slot is free. A program that never advances the head fills the completion queue, and once full the kernel stops producing completions; submissions then stall, because the kernel will not start an operation whose completion has nowhere to go. This is a deadlock the interface cannot detect, and it is the most common structural mistake in a hand-written ring loop.

The system call that remains

Submitting is not free, but it is optional per operation. io_uring_enter takes the number of entries to submit and, optionally, a minimum number of completions to wait for. A program that submits twenty operations calls it once. A program that has already published entries and only wants to collect results calls it with zero submissions and a wait count.

The number of system calls per operation therefore approaches zero as batching increases. The remaining cost per operation is the work the kernel does to fetch the entry, which for a simple read is comparable to the work it did when the operation arrived as a syscall argument. The saving is the transition: the ring buffer replaces the register-based argument passing and the mode switch, and the kernel can process a batch while staying on the same CPU.

For a program that submits one operation at a time and waits for it, the interface is slower than a plain blocking call, because it pays the ring bookkeeping in addition to the notification. The gain requires either batching or a program that keeps several operations in flight.

Polling modes and the trade they make

IORING_SETUP_SQPOLL starts a kernel thread that polls the submission queue. The program then needs no system call at all to submit: it writes the entry, advances the tail, and the kernel thread picks it up. The cost is a core dedicated to polling, and the thread goes to sleep after an idle interval and must be woken with a system call when it does, which reintroduces the cost exactly when the program was least active.

IORING_SETUP_IOPOLL changes the completion path instead: the kernel polls the device for completion rather than taking an interrupt, which requires the underlying device to support polling and the file to be opened with O_DIRECT. The result is lower and more predictable latency per operation, at the cost of a busy CPU and a restriction on which files can be used.

Both modes trade a core for latency. Neither is a default, and both are inappropriate for a workload with many idle periods, where the polling thread’s cost is paid continuously for occasional work.

Registration: buffers, files, and the indirection they remove

By default each operation’s entry carries a buffer address and a descriptor number, and the kernel must translate both: pin the buffer’s pages for the duration of the operation, and look up the descriptor in the process’s table. Both translations cost work per operation, and the descriptor lookup carries a correctness hazard, because a descriptor can be closed and its number reused while the operation is in flight.

Registration removes both. IORING_REGISTER_BUFFERS pins a set of buffers once, and entries then refer to them by index. IORING_REGISTER_FILES records a set of descriptors once, and entries refer to them by index, with the operation marked to use the fixed table. A registered file cannot be closed and reused under the operation, because the registration holds a reference to the open file description. The same reasoning as descriptor passing applies: the object is held by a reference count, and the number is not the identity.

The trade is memory. Registered buffers are pinned for as long as the registration lasts, which means they cannot be swapped, moved, or freed, and a program that registers a large buffer reduces the memory available to the rest of the system. The pinning is also a resource limit: the kernel bounds the total pinned memory per user, and exceeding it fails the registration rather than an individual operation.

Registered files behave differently from ordinary descriptors in one respect worth knowing: the registration holds its own references to the open file descriptions, so closing the original descriptor does not invalidate the entry. The operation continues to use the object it was registered with, and the reference is released on unregister or when the ring is destroyed. That is the property that makes the fixed-file form race-free, and it is also a source of confusion when a program closes a descriptor expecting the registered operation to start failing.

The ring accepts more than reads and writes. Timeouts, accepts, connects, and fsync are all operations, and each is described by an entry with an operation code and an arguments union. Three composition features make the model more than a queue of independent calls.

Linking: consecutive entries can be marked with a link flag, so that the next is started only if the previous succeeded. The chain is how a program expresses read-then-write without a round trip through user space between them. A failed link cancels the rest of the chain, and each cancelled entry gets a completion with a negative error code, so the program sees one completion per submitted entry regardless.

Timeouts: a timeout operation completes after a duration or an absolute time, and it can be linked to another operation so that the timeout applies to that operation rather than running concurrently with it. This is how a per-request deadline is expressed without a separate timer heap.

Cancellation: an entry can request cancellation of another entry by its user data tag. The cancellation is best-effort; an operation that has already completed is not undone, and an operation that cannot be interrupted completes normally. The cancellation request itself produces a completion, so a program must distinguish the cancelled operation’s completion from the cancellation request’s.

What the interface does not fix

The ring makes submission cheap. It does not make the work cheap. A read from a device still costs what the device costs, and a program that submits a thousand reads to a saturated disk has moved the queueing from the kernel’s block layer to the ring without changing the throughput.

It also does not remove the short-count and partial-completion rules described in short counts and partial I/O. The completion entry’s result field is a byte count or a negative error code, and a positive count less than the requested length is a partial transfer that the program must handle by resubmitting from the new offset. A vector read returns the total across the vector, with the same ambiguity about where it stopped.

Ordering between operations is not guaranteed unless links enforce it. Two writes submitted back to back may complete in either order, and may be executed concurrently by the kernel. A program that needs ordering expresses it as a link or waits for the first completion before submitting the second. The queue is a description of work, not a sequence of steps.

The readiness model described in readiness notification and the completion model here are different answers to the same question. Readiness asks what will not block; completion says what has finished. The first fits a program that reads small amounts on many descriptors and can tolerate a call per event. The second fits a program that issues large or numerous operations and wants the kernel to manage the queue.

Finally, the interface is Linux-specific and version-sensitive. Operation codes, flags, and features were added over several kernel releases, and a program that uses a feature without checking the returned parameters can fail in ways that look like a bug in its own logic. The parameters structure reports feature flags precisely so that a program can decide what to use; ignoring them is the one mistake that the interface cannot protect against.

References

  1. io_uring(7) — Linux manual page — man7.org
  2. Efficient IO with io_uring — Jens Axboe
  3. io_uring_setup(2) — Linux manual page — man7.org
  4. Linux Kernel Documentation — io_uring — Linux kernel documentation