Linux io_uring Shared Rings and Batching
Linux io_uring is an asynchronous I/O interface in which an application and the kernel exchange requests and results through shared memory rings. The application places work in a submission queue, and the kernel places finished results in a completion queue.
The problem io_uring solves
Traditional blocking I/O is simple: a program calls read, write, or accept, and the thread waits until the operation can proceed. The waiting thread may sleep, and the kernel must later wake it.
Nonblocking I/O avoids blocking, but usually requires a loop around system calls and an event mechanism such as epoll. The application asks which file descriptors are ready, performs operations, and repeats the process. Each operation still involves entering the kernel, and an application handling many small requests can spend significant time on syscall transitions, wakeups, and scheduling rather than useful I/O.
io_uring moves much of that exchange into shared memory. One setup operation creates rings that both sides can access. Afterward, the application can describe many operations at once, submit them with one syscall—or let a kernel polling thread notice them without a syscall—and collect many results together.
The important distinction is that the rings are shared memory, not a separate kernel queue copied through a syscall for every request.
The two rings and the request records
The application first calls io_uring_setup, asking the kernel for a ring with a chosen number of entries. It then maps the ring memory and a separate region containing submission queue entries, or SQEs. An SQE is a fixed-format record describing one operation: for example, a read, write, accept, timeout, or file-open request. It includes fields such as the operation type, file descriptor, buffer address, length, and an application-chosen user_data value.
The submission queue (SQ) is a ring of references to SQEs. It does not usually contain the full request records itself. Its entries are indexes into the SQE array. This extra level lets the application prepare SQEs and then publish their indexes in the order the kernel should consume them.
The completion queue (CQ) is a ring of completion queue entries, or CQEs. Each CQE reports one finished operation. Its important fields include:
user_data, copied from the SQE so the application can identify the request;res, the result, which is a byte count on success or a negative error value; andflags, which describe additional completion state.
Both rings have producer and consumer positions. For the SQ, the application advances the tail to publish new entries, and the kernel advances the head as it consumes them. For the CQ, the kernel advances the tail to publish results, and the application advances the head as it consumes them. The indexes wrap around the ring size; the implementation uses the distance between head and tail to determine whether entries are available or space remains.
The kernel and application use the ring’s shared control fields with the required memory ordering. In practical terms, the producer must finish writing an entry before publishing the new tail, and the consumer must observe that publication before reading the entry. This ordering is what makes ordinary shared memory safe for the queue protocol.
A request’s path
A typical request follows this sequence:
- The application obtains a free SQE and fills in the operation details.
- It puts that SQE’s index into the SQ and advances the SQ tail.
- It enters the kernel, commonly with
io_uring_enter, to tell the kernel to consume submissions, wait for completions, or do both. - The kernel reads the published SQEs and starts or performs the requested operations.
- When an operation finishes, the kernel writes a CQE and advances the CQ tail.
- The application reads CQEs, matches each one using
user_data, processesres, and advances the CQ head to release the entries.
The SQE remains an input description; the CQE is the result. An application should not assume that completions arrive in submission order. A fast operation can finish before an earlier slow operation, and linked operations or dependencies can add further ordering rules.
The application can submit several SQEs before entering the kernel. It can also ask io_uring_enter to wait until a specified number of completions is available. That makes one syscall useful for both a batch of submissions and a batch of results.
Why batching helps
With one syscall per I/O operation, a program repeatedly pays for the user-to-kernel transition and the associated bookkeeping. It may also wake the kernel and itself for every small item of work.
Batching changes the ratio. The application prepares, for example, dozens of SQEs, publishes them together, and enters the kernel once. Later, it reaps multiple CQEs in a loop. The fixed costs of entering the kernel, checking queues, and scheduling are spread across many operations.
Batching is not free: it can add latency if the application waits too long to fill a batch, and a CQ can overflow if completions are not consumed promptly. Good batch size is therefore a throughput-versus-latency decision, not a universal constant.
Polling instead of repeated wakeups
io_uring can also use SQPOLL, a mode in which the kernel creates a kernel thread that repeatedly checks the submission ring. Once that thread is running, the application can publish SQEs by writing shared memory; it often does not need an io_uring_enter syscall for every batch. If the polling thread goes idle, the application may need to wake it explicitly through io_uring_enter.
This trades syscall and scheduling overhead for a CPU that remains active while polling. It is most useful when the workload is steady and low latency matters. It can waste CPU for an idle or lightly used service, so it is a configuration choice rather than an automatic optimization.
There is also device-level polling, commonly enabled with IORING_SETUP_IOPOLL, for supported storage and operation types. That is different from SQPOLL: SQPOLL polls for new submissions, while I/O polling checks a device for operation completion. Device polling has hardware, file-type, and operation constraints and is not a general replacement for ordinary completion waiting.
For ordinary applications, the usual design is simpler: batch SQEs, submit them together, reap CQEs in batches, and wait only when there is not enough work to keep going. When an incident log mentions io_uring, SQPOLL, ring-full errors, or a completion with a negative res, it is describing this shared-queue protocol and its tradeoffs—not a hidden thread per request.