A storage service needs 64 independent file reads. With ordinary system calls, it can issue them from many threads, but each waiting thread carries a stack and scheduler state. With a readiness interface, regular files do not provide the same useful readiness behavior as sockets.
Linux io_uring offers a completion-based alternative. The application describes actual operations in a shared submission structure. The kernel performs or dispatches those operations and places their results in a shared completion structure.
The application can submit a batch of reads and later process whichever ones finish:
The application submits reads A, B, C, and D, performs unrelated work, and receives completions in the order C, A, D, B.
The design has two goals:
io_uring does not make every workload faster, eliminate all data copies, or guarantee that hardware performs every operation natively. Its value comes from a flexible submission/completion model and careful control of per-operation overhead.
An io_uring instance has a submission queue, abbreviated SQ, and a completion queue, abbreviated CQ. The queues are shared between the application and the kernel.
Neither side calls the other directly. Both sides read and write shared ring memory, which is what lets a batch of operations cross the boundary without a system call each.
The application produces submissions and consumes completions. The kernel consumes submissions and produces completions.
The word ring comes from the circular data structures used for these queues. Head and tail indexes advance as entries are produced and consumed. When an index reaches the physical end of an array, masking wraps it back to the beginning.
The application normally uses liburing, which provides helpers for setting up the shared mappings, preparing operations, publishing queue updates with the required memory ordering, and consuming completion entries.
Each submission queue entry, or SQE, describes one requested operation.
For a file read, the important information includes:
With liburing, a read is prepared like this:
io_uring_get_sqe() reserves the next locally available SQE. It does not submit the operation. The application can prepare several SQEs before notifying the kernel.
If io_uring_get_sqe() returns NULL, the local submission queue is full. The application must submit or otherwise make progress on existing entries before reserving more.
The SQE is a fixed-format kernel interface record. An application usually has a richer request object containing buffers, protocol state, a client identity, and other bookkeeping.
The application can associate a pointer or 64-bit value with the SQE:
That value is returned unchanged with the completion. It lets out-of-order results find the correct application state.
When an ordinary one-shot operation reaches a terminal result, the kernel places a completion queue entry, or CQE, on the completion queue.
A CQE contains three central fields:
user_data identifies the corresponding submission. res contains the operation's result.
For a read:
res > 0 is the number of bytes read.res == 0 represents end-of-file.res < 0 is a negated error number such as -EIO.This differs from a normal read() failure:
The application must not inspect the thread-local errno to interpret a failed CQE. Each in-flight operation carries its own result, so failures can be processed in any completion order.
The flags field carries operation-specific information. Basic one-shot reads often need only user_data and res; advanced operations may use flags to identify selected buffers or indicate that more completions can follow.
A minimal io_uring program follows six steps.
The queue depth controls how many submission entries the ring can hold. The kernel can round sizes according to its supported layout.
io_uring_queue_init() returns 0 on success or a negative error value. Like many liburing functions, it returns -errno rather than setting errno as its primary error channel.
The pointer is valid for preparing the pending queue entry. A NULL result means no local SQE is currently available.
The buffer and request object must remain valid according to the operation's lifetime rules.
The return value reports how many entries were submitted, or a negative error. Successful submission means the kernel accepted those requests for processing. It does not mean their I/O succeeded.
If a CQE is already available, the call can return without waiting. Otherwise it waits for one. A successful return from io_uring_wait_cqe() only means a CQE was obtained; the operation's result is in cqe->res.
io_uring_cqe_seen() tells liburing that the application has finished with that entry so completion-queue space can be reused. The application should finish reading the CQE before marking it seen.
When all operations and completions are finished:
Without a ring-based interface, each operation commonly requires its own system call to cross into the kernel, and a separate wait call may be needed to discover completion.
io_uring separates preparation from submission:
The application prepares four submission queue entries and then submits them as one batch.
A single io_uring_enter() system call can tell the kernel about multiple queued operations. It can also combine submission with waiting for a requested number of completions. Liburing helpers decide when the underlying transition is necessary.
Completion records already present in the shared CQ can be consumed without copying a separate result array from the kernel. Waiting for unavailable completions may still require entering the kernel.
The important optimization is amortization, not “io_uring uses no system calls.” In the ordinary mode, applications still enter the kernel to publish work or wait. They can do so once for a batch instead of once for every operation.
Loading simulation...
An io_uring instance can be created with submission-queue polling, requested through IORING_SETUP_SQPOLL.
In this mode, a kernel thread polls for newly published SQ entries. While that thread remains active, the application can submit work without a system call. If the polling thread becomes idle and sleeps, the application may need to wake it; liburing handles the normal wakeup protocol.
SQPOLL spends CPU time to reduce submission transitions. It is not an automatic performance switch:
Applications should measure the default mode first and enable polling only when its latency/CPU trade-off matches the workload.
I/O completion polling is a different option. It targets supported storage devices and filesystems and trades CPU time for completion latency. It has stricter device and file-access requirements and should not be confused with polling the submission queue.
An SQE can contain pointers to application memory. The lifetime depends on what the pointed-to data is used for.
The data buffer for a read or write must remain valid until that operation completes:
This code is unsafe:
The object stored in user_data must also remain valid until the completion is consumed if it is a pointer. Storing a pointer to a short-lived stack object creates the same bug.
Some operation metadata passed by pointer only needs to remain stable through submission because the kernel copies it while accepting the SQE. The exact prep-function documentation defines that lifetime. Data buffers used by in-flight reads and writes are the important longer-lived case.
io_uring_prep_read() accepts a file offset. Supplying an explicit offset makes independent file reads easier to reason about:
The operations can complete in any order without competing to update one shared current file position.
Some file types and APIs permit an offset value representing “use the current file position.” Concurrent asynchronous operations using that shared position can produce ordering that is difficult to predict unless the application serializes access.
For known file regions, explicit offsets make each request self-contained.
Suppose an application submits:
The completion order might be 1, 3, 2. Device queues, cache state, worker scheduling, and operation size can all affect when results become available.
The application must match every CQE through user_data rather than relying on queue position.
When operations truly depend on one another, io_uring provides ways to link submissions. A linked chain can express relationships such as “perform this operation only as part of this sequence.” Linking is an explicit constraint; ordinary adjacent SQEs do not acquire execution or completion ordering merely because they were prepared next to each other.
io_uring Operationsio_uring is a kernel-native asynchronous interface, but the work underneath depends on the operation and target.
Some operations can proceed through a genuinely asynchronous kernel or device path. Others can encounter code that may block. Linux can route suitable work through its io-wq worker infrastructure so that the submitting application thread does not block for the entire operation.
This distinction matters:
The application sees completion semantics in all three cases, but their CPU, scheduling, and scalability costs can differ.
An asynchronous API therefore does not prove that every filesystem, device, or operation has native hardware-level asynchrony.
The ring has finite submission and completion capacity.
When no SQE is available, io_uring_get_sqe() returns NULL. The application must submit pending entries, consume completions, or otherwise make queue space available. It must not dereference the null pointer.
The completion queue also needs prompt attention. Forgetting to mark CQEs as seen prevents their slots from being reused. Producing completions faster than the application consumes them increases queue pressure and can harm performance even when the kernel supports overflow handling.
Beyond ring entries, each in-flight request can consume:
Queue depth should be sized and bounded from measurements. More in-flight I/O can improve device utilization up to a point, then increase latency and memory use without adding throughput.
io_uring can register resources for repeated use.
Registered files let the ring maintain a table of file references. Operations can use indexes into that table, reducing repeated file-descriptor lookup overhead.
Registered buffers establish long-term kernel mappings for reusable memory. Fixed-buffer operations can avoid repeating some validation, page-pinning, and mapping work for every I/O.
Registration is an optimization, not a requirement for basic io_uring:
Start with ordinary file descriptors and buffers. Registration is justified when profiling shows that per-operation resource setup matters.
Registering a buffer also does not automatically make an operation zero-copy. It reduces mapping overhead; the data path and operation still determine whether bytes are copied elsewhere.
io_uring LayersAn io_uring application must keep three result channels separate.
Creating the ring can fail because the kernel lacks support, a security policy denies the operation, parameters are invalid, or resources are unavailable. Liburing reports a negative error value.
io_uring_submit() can fail before queued entries are accepted or can report how many entries were submitted. A robust application checks the count instead of assuming that every prepared SQE was accepted.
An accepted SQE later produces a CQE. Its res field can contain a negative error even though ring setup and submission succeeded.
The ring is created successfully, the read entry is submitted successfully, and the completion queue entry returns -EIO.
Only the CQE answers whether that read succeeded.
Cancellation can race with normal completion.
The application submits a cancellation request identifying the target operation. That cancellation request receives its own completion, while the target operation also reaches a terminal state.
Possible outcomes include:
Requesting cancellation does not immediately release the target's buffer. The application waits for the operation's terminal result according to the API contract before reusing its memory.
Timeouts can also be represented as ring operations or linked with other work. They provide structured deadlines, but a timeout notification and target cancellation are still separate state transitions that must be handled deliberately.
io_uring vs. epoll Responsibilitiesepoll primarily reports readiness:
epoll_wait() reports the descriptor readable, the application calls read(), and read() returns the result.
io_uring can submit the operation itself:
The application submits read(fd, buffer, length), the kernel performs or dispatches the operation, and the completion queue entry contains the result.
The comparison is:
| Property | epoll readiness | io_uring completion |
|---|---|---|
| Registered object | Descriptor interest | Concrete operation |
| Data buffer supplied | During later I/O call | During submission |
| Result delivered | By later read()/write() | In CQE |
| Main batching unit | Ready event array | SQEs and CQEs |
| Regular-file I/O | Not usefully readiness-driven | Explicit operations supported |
| State per outstanding operation | Created after readiness | Created before submission |
io_uring can also represent polling and other non-data operations, so it is broader than “asynchronous file read.” Its defining shape remains operation submission through SQEs and result delivery through CQEs.
The following program submits one read for each file named on the command line, up to sixteen files. Each request owns its descriptor, buffer, path, and completion identity.
Compile it on Linux with liburing development files installed:
Create a few inputs and run it:
A representative result is:
The completion order is deliberately not part of the expected output. Each CQE finds its request through user_data, so any order is correct.
The program uses explicit offset 0 for every file, checks liburing's negative error convention, inspects cqe->res separately, and marks every CQE as seen. All request objects and buffers remain allocated until all completions are consumed.
io_uring is Linux-specific. Kernel support, liburing headers, and security policy all affect which programs can use it.
A newer liburing can run with an older kernel, but newer operations or flags may not be supported by that kernel. Robust software probes for required operations and features rather than assuming that a header definition guarantees runtime support.
Containers and sandboxed environments may deny the io_uring setup system call through seccomp or another policy. A setup failure such as EPERM, EACCES, or ENOSYS can therefore describe the execution environment rather than a bug in the program.
Portable software needs a fallback path or an explicit platform requirement.
io_uring is a strong candidate when a workload has:
It may provide little benefit when:
The right comparison measures throughput, tail latency, CPU time, memory per in-flight request, and operational complexity under the real workload.
io_uring is Linux's shared-ring interface for submitting operations and receiving their completions. Applications prepare SQEs on the submission side, and the kernel produces CQEs containing per-operation results.
The user_data field connects out-of-order completions to application request objects. cqe->res contains a byte count, zero, or a negative error number; it is separate from setup and submission results.
Batching amortizes kernel transitions, while optional polling modes trade CPU time for fewer transitions or lower completion latency. io_uring does not inherently remove all system calls, copies, worker execution, or resource limits.
Buffers and request objects must remain valid until completion. Applications must bound in-flight work, consume CQEs promptly, handle cancellation races, and probe runtime support for required features.
The central mental model is:
SQEs describe work the application wants done; CQEs report what happened to that work.
5 quizzes