Linux I/O has always had a per-operation tax: every read(), write(), or io_submit() is a system call, a round trip across the user/kernel boundary. For a busy server doing hundreds of thousands of small operations per second, that boundary crossing — not the disk, not the NIC — can become the dominant cost. io_uring, which entered the kernel in the 5.1 series in 2019, attacks that tax directly: it replaces the syscall-per-operation model with two ring buffers shared between userspace and the kernel, so that in the extreme configuration an application can submit and reap I/O without making any system calls at all.
That design made io_uring the fastest general-purpose I/O interface Linux has ever shipped. It also made it, in Google's words, a source of "strong exploitation primitives" — strong enough that Google disabled it on their production servers and in ChromeOS, and made it unreachable to Android apps. Both halves of that story come from the same architectural decision, and it's worth understanding exactly how.
Why Linux AIO wasn't the answer
Linux already had an asynchronous I/O interface: the io_submit() family, known as Linux AIO. When Jonathan Corbet covered the io_uring patches for LWN in January 2019, he summarized the consensus bluntly: the existing AIO interface was "difficult to use and inefficient," some types of I/O were better supported than others, and buffered I/O had "always been a bit of a sore spot for Linux AIO." In practice, AIO was only really usable for direct I/O on files, and operations that were supposed to be asynchronous could quietly block.
io_uring was Jens Axboe's answer, and part of its pitch was fixing the buffered I/O gap: asynchronous buffered reads that complete without a context switch when the data is already in the page cache.
Two rings, three syscalls
The entire interface rests on three system calls, documented in the io_uring(7) man page:
io_uring_setup(2)— creates an io_uring instance and returns a file descriptorio_uring_enter(2)— tells the kernel there is work to consume, and/or waits for completionsio_uring_register(2)— pre-registers resources (buffers, files) to shave per-operation overhead
io_uring_setup(entries, params) allocates two ring buffers: a submission queue (SQ) and a completion queue (CQ). The entries argument is a minimum — the kernel may round it up. The application then maps the rings into its own address space with mmap() on the returned fd, using magic offsets: IORING_OFF_SQ_RING, IORING_OFF_CQ_RING, and IORING_OFF_SQES for the array of submission entries. On kernels 5.4 and later, the two rings can be jointly mapped in a single mmap() call (advertised via the IORING_FEAT_SINGLE_MMAP feature flag).
From that point on, both sides communicate through shared memory. Each ring is a classic single-producer/single-consumer queue with head and tail indices: if head equals tail, the ring is empty.
- Submission: the application writes a submission queue entry (SQE) at the SQ tail and advances the tail; the kernel consumes entries from the head.
- Completion: the kernel writes a completion queue entry (CQE) at the CQ tail; the application consumes from the head.
An SQE is a fixed-size struct describing one operation: an opcode (IORING_OP_READV, IORING_OP_WRITEV, IORING_OP_FSYNC, and by now dozens more), the target fd, a file offset, a buffer address and length, and a user_data field. A CQE carries just three things back: your user_data echoed verbatim (so you can match completions to requests), a res result code, and a flags field.
Because the rings live in memory shared between userspace and the kernel, ordering is on you. The io_uring(7) man page's reference submission loop publishes the new tail with a release store:
/* fill in the SQE at sqring->array[tail & mask] first, then: */
atomic_store_explicit(sqring->tail, tail, memory_order_release);The release barrier guarantees the kernel never observes the tail advance before the SQE contents are visible. Getting this wrong doesn't fail loudly — it corrupts requests intermittently, which is exactly why almost everyone uses liburing, Axboe's helper library, instead of raw syscalls.
One syscall for many operations — or none
The default operating mode still uses a syscall, but amortized:
int io_uring_enter(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,
sigset_t *sig);You can queue 100 SQEs in shared memory and submit them with a single io_uring_enter(fd, 100, ...). Per the man page, "a single call can both submit new I/O and wait for completions of I/O initiated by this call or previous calls" — pass IORING_ENTER_GETEVENTS and a min_complete, and the one syscall does the job that previously took a submit call plus an epoll_wait() plus N read() calls.
Two setup flags push further:
IORING_SETUP_SQPOLLstarts a kernel thread that polls the submission queue for you. While it's awake, submission requires no syscall at all — you write SQEs, bump the tail, and the kernel thread picks them up. If the thread sees no work forsq_thread_idlemilliseconds it sleeps and setsIORING_SQ_NEED_WAKEUPin the SQ ring flags; the application must check for that flag and issue anio_uring_enter()to wake it. Before kernel 5.11, SQPOLL also required all files to be pre-registered. The tradeoff is a kernel thread burning CPU on your behalf.IORING_SETUP_IOPOLLbusy-waits for completions instead of relying on device interrupts. It gives lower latency at the cost of CPU, and it's restrictive: the block device and filesystem must support polling, and files must be opened withO_DIRECT.
The third syscall, io_uring_register(2), exists because per-operation setup costs add up too. Registering buffers up front (IORING_REGISTER_BUFFERS) lets the kernel map and pin them once; subsequent IORING_OP_READ_FIXED/IORING_OP_WRITE_FIXED operations skip the per-I/O mapping work. Since registration effectively locks memory into RAM, it counts against the same resource limit as mlock().
The bill for all that kernel surface
Every opcode io_uring gained — and it grew from file reads to sockets, timeouts, openat, sendmsg, and beyond — is code reachable from an unprivileged process through a fast, complex, asynchronous path. In June 2023, Google's security team published what that surface had cost them through kCTF, their kernel exploit bounty program: 60% of the submissions exploited the io_uring component, and "we paid out around 1 million USD for io_uring alone."
Their response, from the same announcement:
it is still affected by severe vulnerabilities and it also provides strong exploitation primitives. For these reasons, we currently consider it safe only for use by trusted components.
Concretely: io_uring is disabled on production Google servers, disabled in ChromeOS "while we explore new ways to sandbox it," blocked for Android apps via a seccomp-bpf filter (with SELinux restrictions planned for later releases), and Google was investigating disabling it by default in GKE AutoPilot.
Note what the objection is not. It's not that io_uring's fixes are slow to arrive — Google explicitly credits the developers with reacting quickly. The problem is structural: a young, rapidly growing subsystem with shared-memory communication and deep hooks into nearly every kernel I/O path is a gift to exploit developers, because even unrelated kernel bugs become easier to exploit with the primitives io_uring provides.
Where that leaves you
The engineering takeaway is a genuine tradeoff, not a verdict:
- If you run trusted, first-party workloads that are I/O-bound at high queue depths — storage engines, proxies, log shippers — io_uring is the mechanism the kernel offers for escaping syscall-per-operation overhead, and liburing makes it usable without hand-rolling memory barriers.
- If you execute untrusted or multi-tenant code, the calculus Google published applies to you too: the same shared rings that remove syscalls from the data path also remove your ability to filter that data path with seccomp the way you filter syscalls. Blocking
io_uring_setup(2)in your sandbox profile is a one-line mitigation precisely because everything else flows through memory.
The rings are the whole story either way. Everything io_uring gives you — batching, zero-syscall submission, fixed buffers — and everything it costs you comes from the same decision to turn the user/kernel boundary from a function call into a shared data structure.
