io_uring and the File Path
This note looks at io_uring not as a ring mechanism but as a file-I/O interface — the lens of the VFS. io_uring (merged in kernel 5.1, May 2019, by Jens Axboe) exposes a growing set of file operations as ring opcodes:
IORING_OP_READ/WRITE, the vectoredREADV/WRITEV, the registered-bufferREAD_FIXED/WRITE_FIXED, plusFSYNC,SYNC_FILE_RANGE,FALLOCATE,OPENAT/OPENAT2,CLOSE,STATX, and the namespace operationsRENAMEAT/UNLINKAT/MKDIRAT(all verified present in the v6.12 uapi header). The defining trick of the file path is its two-phase execution: io_uring first attempts every operation inline and non-blocking with the kernel’sIOCB_NOWAITflag, and only if that attempt would block — returning-EAGAIN— does it either arm a poll-based retry or offload the operation to a kernel worker-thread pool calledio-wq, where the thread is allowed to block. That is what lets one ring uniformly handle data already in the page cache (served inline, with the result waiting in the completion queue the instant submission returns) and data that must come off a slow disk (offloaded, completed later) — the property the design document calls being “just as efficient for IO that is already in the page cache as the regular synchronous interfaces” (kernel.dk/io_uring.pdf). The ring mechanics this note rests on — the submission/completion queues, the SQE/CQE structures, the indices and barriers — belong to The io_uring Submission and Completion Queues; the why-batching argument and the three driving syscalls belong to io_uring as a Syscall Batching Mechanism; the comparative landscape of async-I/O models belongs to Asynchronous IO Models in Linux.
Uncertain
Every opcode name, flag, and execution-path claim here is pinned to the Linux v6.12 LTS source tree (
io_uring/opdef.c,io_uring/rw.c,io_uring/io_uring.c,io_uring/io-wq.c,io_uring/tctx.c) and the v6.12 uapi header. io_uring’s opcode surface grows almost every release; “the operations it issues” is a point-in-time claim as of 6.12 LTS / 6.18 LTS (2025-11-30). The internal function names (__io_read,io_queue_async,io_prep_async_work) are implementation details that can be renamed between releases; the ABI (opcode constants,IOSQE_*flags) is stable. Re-verify against the uapi header at the exact tag you target. uncertain
Mental Model
The mental model for the file path is “try it cheaply, and only pay for a thread if you must.” A traditional asynchronous-I/O design has to decide up front whether an operation will block, and it usually cannot know — whether a read() blocks depends on whether the data happens to be in the page cache right now, which userspace cannot reliably query (kernel.dk/io_uring.pdf, §9.2). So thread-pool designs (libuv, glibc POSIX AIO) bounce every file read to a worker thread, paying two context switches even when the data was cached and the read could have completed in nanoseconds. io_uring inverts this: it issues the operation in the submitting task’s context with a “do not block” flag set, and the common case — cached data, a socket with data already buffered — completes inline, with zero thread hops. Only the genuinely-blocking minority (a page-cache miss that needs a disk read, a metadata fetch, a full request queue) gets handed to io-wq, a pool of in-kernel worker threads that are allowed to sleep. The cost of asynchrony is thus paid in proportion to how much I/O actually blocks, not per operation.
flowchart TB SQE["SQE arrives<br/>(IORING_OP_READ, fd, buf, off, len)"] --> PREP["prep: validate, import iovec,<br/>set up struct kiocb"] PREP --> INLINE["issue inline with<br/>IOCB_NOWAIT set<br/>(submitter's context)"] INLINE -->|"data in page cache /<br/>socket ready"| DONE["completes inline<br/>post CQE immediately"] INLINE -->|"-EAGAIN<br/>(would block)"| BRANCH{"file pollable?"} BRANCH -->|"yes (socket, pipe)"| POLL["arm poll handler<br/>(FAST_POLL): retry<br/>when fd is ready"] BRANCH -->|"no (regular file<br/>page-cache miss)"| PUNT["punt to io-wq<br/>worker thread<br/>(allowed to block)"] POLL -->|"ready"| RETRY["re-issue inline"] PUNT --> BLOCKDONE["worker blocks on disk,<br/>completes, posts CQE"] RETRY --> DONE BLOCKDONE --> DONE
The two-phase file-path execution model in io_uring. What it shows: every file operation is first attempted inline with IOCB_NOWAIT; a success posts its completion immediately, while an -EAGAIN is routed either to a poll-based retry (for pollable file descriptors like sockets) or to an io-wq worker thread (for regular files that need to block on disk). The insight to take: the page-cache-hit case never leaves the submitting task — io_uring is as cheap as a synchronous read() when data is already in memory, and only spends a worker thread on the operations that truly block.
The Operations io_uring Issues on Files
Every io_uring operation is identified by an opcode byte in its submission queue entry (SQE), and the kernel dispatches it through a table, io_issue_defs[], in io_uring/opdef.c. Each table entry pairs the opcode with two function pointers — a .prep (validate and stage the SQE) and an .issue (actually perform it) — plus a set of boolean properties that drive the execution machinery. The file-relevant opcodes present in v6.12 are:
IORING_OP_READ/IORING_OP_WRITE— the simple single-buffer read and write, equivalents ofpread(2)/pwrite(2). Both route to theio_read/io_writeissue functions and carry.needs_file = 1,.plug = 1(block-layer plugging), and.iopoll = 1(eligible for polled completion).IORING_OP_READV/IORING_OP_WRITEV— the scatter/gather variants, equivalents ofpreadv2(2)/pwritev2(2)(io_uring(7)); the SQE’saddrpoints to aniovecarray andlenis the array count. They share the sameio_read/io_writeissue functions, with.vectored = 1set. See Vectored IO readv and writev for the scatter/gather model these mirror.IORING_OP_READ_FIXED/IORING_OP_WRITE_FIXED— reads and writes against a pre-registered buffer, selected by the SQE’sbuf_index(covered below).IORING_OP_FSYNC— flush a file’s data and metadata, an equivalent offsync(2); with theIORING_FSYNC_DATASYNCmodifier flag (1U << 0in the v6.12 uapi header) it becomesfdatasync(2), flushing data but not non-essential metadata. See fsync fdatasync and Durability.IORING_OP_SYNC_FILE_RANGE— flush a byte range, equivalent ofsync_file_range(2).IORING_OP_FALLOCATE— preallocate or punch holes, equivalent offallocate(2).IORING_OP_OPENAT/IORING_OP_OPENAT2— open a file by path relative to a directory fd, equivalents ofopenat(2)/openat2(2). The resulting descriptor is delivered in the completion’sresfield. This is the operation that lets a fully asynchronous program never make a synchronousopen()syscall, which itself can block on path resolution and disk metadata.IORING_OP_CLOSE— close a descriptor, equivalent ofclose(2).IORING_OP_STATX— stat a file, equivalent ofstatx(2); delivers thestruct statxinto a user buffer.IORING_OP_RENAMEAT/IORING_OP_UNLINKAT/IORING_OP_MKDIRAT— the directory-mutation operations, equivalents ofrenameat2(2)/unlinkat(2)/mkdirat(2).
The breadth is the point: where the legacy native AIO interface (io_submit(2)) issues essentially only reads, writes, and fsyncs, io_uring covers the whole file-operation surface — open, close, stat, rename, unlink, fallocate — so an event loop can drive every filesystem interaction asynchronously through one ring, never falling back to a blocking syscall. This is what the Lord of the io_uring guide means by “io_uring presents a uniform interface whether dealing with sockets or with regular files” (unixism.net).
How a File Operation Actually Executes — Inline, Poll, or io-wq
The two-phase model is implemented in io_uring/rw.c and io_uring/io_uring.c, and tracing it concretely is the most important thing in this note. When the kernel submits an SQE during io_uring_enter, it calls io_issue_sqe with the flag IO_URING_F_NONBLOCK set — telling the issue handler “you may not sleep.” For a read, that lands in __io_read, where the first thing it does after staging the request is:
bool force_nonblock = issue_flags & IO_URING_F_NONBLOCK;
...
if (force_nonblock) {
/* If the file doesn't support async, just async punt */
if (unlikely(!io_file_supports_nowait(req, EPOLLIN)))
return -EAGAIN;
kiocb->ki_flags |= IOCB_NOWAIT;
}Two things happen here. First, io_file_supports_nowait asks whether this file can honor a non-blocking attempt at all: it returns true if the file has FMODE_NOWAIT set (the underlying filesystem promised it can do non-blocking I/O — modern filesystems on the page cache do), or if the file is pollable and currently reports ready via vfs_poll; otherwise it returns -EAGAIN immediately (rw.c). Second, if the file does support it, io_uring sets the VFS-level IOCB_NOWAIT flag on the struct kiocb (the kernel’s per-operation I/O descriptor) and calls the filesystem’s normal read path, io_iter_do_read.
Now the filesystem’s read_iter runs with IOCB_NOWAIT set. If the data is in the page cache, it copies it out and returns the byte count — the operation is done, inline, in the submitting task, with no thread hop. The completion is posted to the CQ ring before io_uring_enter even returns. If the data is not cached, the filesystem sees IOCB_NOWAIT and refuses to start a blocking disk read, returning -EAGAIN instead. Back in __io_read, that -EAGAIN triggers the second phase:
if (ret == -EAGAIN || (req->flags & REQ_F_REISSUE)) {
req->flags &= ~REQ_F_REISSUE;
/* If we can poll, just do that. */
if (io_file_can_poll(req))
return -EAGAIN;
...
}The -EAGAIN propagates up to io_queue_sqe, whose handler io_queue_async makes the routing decision (io_uring.c):
switch (io_arm_poll_handler(req, 0)) {
case IO_APOLL_READY: io_req_task_queue(req); break; /* already ready, retry */
case IO_APOLL_ABORTED: io_queue_iowq(req); break; /* punt to worker pool */
case IO_APOLL_OK: break; /* poll armed, wait */
}For a pollable file descriptor — a socket, a pipe, a tty — io_arm_poll_handler succeeds (IO_APOLL_OK): io_uring registers interest in readiness and re-issues the operation only when the fd becomes ready, so no worker thread is needed. This is the IORING_FEAT_FAST_POLL feature (1U << 5 in the v6.12 uapi header): as the Lord of the io_uring guide puts it, “requests that cannot read or write data to a file no longer need to be punted to an async thread for handling, instead they will begin operation when the file is ready” (unixism.net).
For a regular file — which is not pollable, because, as the next note explains, regular files always report “ready” and so readiness polling is useless for them — io_arm_poll_handler returns IO_APOLL_ABORTED, and io_queue_iowq hands the request to the io-wq worker pool. There a worker thread runs the same io_read issue function, but this time without IO_URING_F_NONBLOCK, so IOCB_NOWAIT is cleared and the filesystem is free to block on the disk read. When it completes, the worker posts the CQE. This is exactly the design-document promise: io_uring “handles this condition like it would for other resources that potentially could block the application,” while “for operations that will not block, the data is served inline” (kernel.dk/io_uring.pdf, §9.2).
The io-wq worker pool
io-wq is, per its source header, a “Basic worker thread pool for io_uring” (io-wq.c). Each io_uring instance that ever punts work gets a per-task io_wq, and it maintains two classes of workers (io-wq.c, the IO_WQ_ACCT_BOUND / IO_WQ_ACCT_UNBOUND accounting):
- Bounded workers handle work that is expected to complete in a bounded time — regular-file and block-device reads and writes. The disk will answer; the operation cannot hang forever. The default cap on bounded workers is computed in
tctx.casmin(ctx->sq_entries, 4 * num_online_cpus())— at most four bounded workers per CPU, but never more than the ring is deep. - Unbounded workers handle work that may never complete — operations on sockets and pipes, where a peer might never send data. These are flagged with
IO_WQ_WORK_UNBOUNDinio_prep_async_work, and their default cap is the task’sRLIMIT_NPROClimit (io-wq.c). The split exists so a flood of stuck socket operations cannot starve the bounded disk-I/O workers.
Both caps are tunable at runtime via io_uring_register(2) with IORING_REGISTER_IOWQ_MAX_WORKERS, which calls io_wq_max_workers to overwrite the per-class limits (io-wq.c).
Uncertain
The bounded-worker default
min(sq_entries, 4 * num_online_cpus())is read directly fromio_uring_alloc_task_contextin the v6.12tctx.c. The unbounded default ofRLIMIT_NPROCis fromio_wq_createin v6.12io-wq.c. These formulas have changed across the io_uring lifetime (early versions used different caps) — they are accurate for 6.12 but should be re-verified for any other kernel. The4 * nr_cpusfactor in particular is an implementation constant, not a stable ABI. uncertain
Hashed work — preserving write ordering
There is a subtlety the source reveals that catches people: if two buffered writes to the same file are both punted to io-wq, they must not run concurrently, or they could interleave and corrupt the file’s contents or ordering. io_uring solves this with hashed work. In io_prep_async_work (io_uring.c), a write to a regular file (def->hash_reg_file is set for the WRITE / WRITEV / WRITE_FIXED opcodes in opdef.c) is hashed by its inode:
if (req->file && (req->flags & REQ_F_ISREG)) {
bool should_hash = def->hash_reg_file;
/* don't serialize this request if the fs doesn't need it */
if (should_hash && (req->file->f_flags & O_DIRECT) &&
(req->file->f_op->fop_flags & FOP_DIO_PARALLEL_WRITE))
should_hash = false;
if (should_hash || (ctx->flags & IORING_SETUP_IOPOLL))
io_wq_hash_work(&req->work, file_inode(req->file));
}io-wq guarantees that “work items that hash to the same value will not be done in parallel” — “used to limit concurrent writes, generally hashed by inode” (io-wq.c). So all offloaded buffered writes to one inode are serialized, preserving ordering. The clever exception in the snippet above: if the file is opened O_DIRECT and the filesystem advertises FOP_DIO_PARALLEL_WRITE (it can safely do parallel direct writes — XFS, for instance), the hashing is skipped and the writes run in parallel, because direct I/O does not go through the shared page cache where interleaving would corrupt state. This is a direct illustration of how the O_DIRECT-vs-buffered distinction (see Direct IO and O_DIRECT) reaches all the way down into io_uring’s concurrency model.
Registered (Fixed) Buffers and Files — the File-Path Payoff
CQ note already explains the registration mechanism — io_uring_register(2) with IORING_REGISTER_BUFFERS and IORING_REGISTER_FILES, the buf_index field, the IOSQE_FIXED_FILE flag. What matters for file I/O is why they pay off, and the design document is explicit (kernel.dk/io_uring.pdf, §8.1):
When O_DIRECT is used, the kernel must map the application pages into the kernel before it can do IO to them, and subsequently unmap those same pages when IO is done. This can be a costly operation. If an application reuses IO buffers, then it’s possible to do the mapping and unmapping once, instead of per IO operation.
So fixed buffers matter specifically for O_DIRECT file I/O. Direct I/O reads and writes straight between the device and userspace pages, which the kernel must pin (get_user_pages) and map on every operation. Registering the buffers once with IORING_REGISTER_BUFFERS does that pinning a single time; thereafter IORING_OP_READ_FIXED / IORING_OP_WRITE_FIXED reference the registered buffer by buf_index and the kernel skips the per-I/O pin/unmap entirely. For buffered I/O the win is smaller (the page cache is the staging area, not the user buffer), which is why fixed buffers are most associated with high-IOPS O_DIRECT database and storage workloads.
Fixed files strip a different per-operation cost: every operation on a normal fd makes the kernel take and drop a reference on the (possibly shared) file-descriptor table. Pre-registering descriptors with IORING_REGISTER_FILES and referencing them by index with IOSQE_FIXED_FILE skips that reference churn — a measurable win in multithreaded servers issuing millions of operations against a small set of long-lived files or sockets.
O_DIRECT versus Buffered — Why io_uring Handles Both Cleanly
The legacy native AIO interface (io_submit) “only supports async IO for O_DIRECT (or un-buffered) accesses… For normal (buffered) IO, the interface behaves in a synchronous manner” (kernel.dk/io_uring.pdf). io_uring’s whole file-path design is the answer to that limitation, and it handles the two modes differently but uniformly:
- Buffered I/O (the default). The inline attempt hits the page cache. A cache hit completes inline, with zero thread hops — the case the design doc highlights as making io_uring “just as efficient for IO that is already in the page cache as the regular synchronous interfaces.” A cache miss returns
-EAGAINand is punted to a bounded io-wq worker that blocks on the disk read. Either way the application sees the same completion-based interface; it never has to guess whether the data was cached. - Direct I/O (
O_DIRECT). The operation bypasses the page cache (see Direct IO and O_DIRECT) and goes straight to the block layer. WithIORING_SETUP_IOPOLLand a polling-capable device, completions can be reaped by busy-polling the driver rather than waiting for an interrupt — the lowest-latency path, which the design doc benchmarks at “1.7M 4k IOPS with polling” versus native AIO’s ceiling. Fixed buffers (above) remove the per-I/O page-pinning that direct I/O would otherwise incur.
The key point for the VFS reader: io_uring did not invent a new data path. A buffered io_uring read calls the same read_iter the page cache always used (see The Page Cache and address_space, Readahead and Read Path); a direct io_uring read calls the same O_DIRECT path. io_uring’s contribution is the non-blocking-attempt-then-offload wrapper around those existing paths, plus the shared rings that batch the boundary crossing.
Ordering: IOSQE_IO_LINK Chains
Because io_uring completes operations out of order by design, a program that needs ordering — “write this data, then fsync it” — must say so explicitly. The IOSQE_IO_LINK flag (1U << IOSQE_IO_LINK_BIT in the v6.12 uapi header) on an SQE means “the next SQE in the submission will not start until this one completes successfully.” Chaining a IORING_OP_WRITE to a following IORING_OP_FSYNC with IOSQE_IO_LINK on the write guarantees the fsync runs only after the write lands — the canonical durable-write pattern. If a linked operation fails, the remainder of the chain is cancelled with -ECANCELED. The man page notes the same mechanism is used “to enforce an execution order in the kernel” for dependent socket operations (io_uring(7)). The full chaining and IOSQE_IO_DRAIN barrier semantics are detailed in the SQE discussion of The io_uring Submission and Completion Queues; here the file-path takeaway is simply that write-then-fsync durability requires an explicit link, because nothing else orders the two.
Failure Modes and Misunderstandings
- “Every io_uring file read uses a thread.” No — the whole design avoids that. A page-cache hit completes inline with no thread; only a blocking operation (cache miss, metadata fetch) is offloaded to io-wq. Conflating io_uring with a thread-pool emulation misses its central efficiency claim.
- Saturating the io-wq pool. A workload that punts many simultaneously-blocking regular-file operations can hit the bounded-worker cap (
min(sq_entries, 4*nr_cpus)on 6.12), after which further offloaded work queues behind the busy workers. Symptom: completion latency rises under a burst of cold (uncached) reads even though CPU is idle. Tune viaIORING_REGISTER_IOWQ_MAX_WORKERSor front the workload with readahead. - Expecting buffered writes to the same file to parallelize. They are deliberately serialized by inode hashing once offloaded (above), to preserve ordering. If you need parallel writes, use
O_DIRECTon a filesystem withFOP_DIO_PARALLEL_WRITE(e.g. XFS), where hashing is skipped. - Forgetting the link for durability. Submitting a write and an fsync without
IOSQE_IO_LINKlets them complete in either order — the fsync may run before the write, defeating durability. Always link write→fsync. O_DIRECTalignment violations. Direct I/O inheritsO_DIRECT’s alignment and size constraints (see Direct IO and O_DIRECT); an unalignedIORING_OP_READagainst anO_DIRECTfd fails the same way a synchronous one would (-EINVAL), surfaced as a negativeresin the CQE — not an io_uring-specific error.- Assuming
OPENAT/STATXare free. They too can block on path resolution and disk metadata, and are punted to io-wq when they would. Async open is genuinely async, but it still spends a worker on a cold-cache path lookup.
Alternatives and When to Choose Them
- Synchronous
pread/pwrite/fsyncon a thread pool — simplest; the right choice for low-concurrency file access. io_uring’s ring setup and barrier discipline are pure overhead for a handful of operations. The thread-pool model is exactly what libuv (Node.js) and glibc POSIX AIO do for files; see Asynchronous IO Models in Linux. - Legacy native AIO (
io_submit) — only worthwhile forO_DIRECThigh-IOPS storage on older kernels without io_uring; its buffered-I/O-is-synchronous limitation makes it a poor general-purpose choice (detailed in Asynchronous IO Models in Linux). mmap+ page faults — for read-mostly random access to files that fit in memory, mapping the file and faulting pages in can beat explicit reads, at the cost of unpredictable fault latency and no error handling on I/O failure.- io_uring — when you need high-concurrency, heterogeneous file (and socket) operations with the page-cache-hit fast path, and the environment’s security policy permits it (see the restrictions in io_uring as a Syscall Batching Mechanism). For full async filesystem interaction — open, read, write, fsync, stat, rename — through one interface, io_uring is the only complete answer.
Production Notes
The benchmark in Axboe’s design document compares io_uring against native AIO on a fast device: io_uring reaches “1.7M 4k IOPS with polling” and “about 1.2M IOPS” without, while the same test on native AIO is capped lower by its two-syscall-per-I/O overhead and inability to poll efficiently (kernel.dk/io_uring.pdf, §9.1). The buffered-async section (§9.2) is the more broadly relevant result: by serving page-cache hits inline, io_uring removes the “at least two context switches” that a userspace I/O thread pool pays even when the data was already cached — the single biggest practical win for general-purpose applications, because most file reads in a warm system are cache hits. The fio benchmark tool ships an io_uring engine that exercises all of these features and is the standard way to reproduce these numbers.
See Also
- Asynchronous IO Models in Linux — the comparative landscape (thread pools, POSIX AIO, native AIO, epoll, io_uring) this note’s “alternatives” point into
- The io_uring Submission and Completion Queues — the SQ/CQ ring mechanics, SQE/CQE layout,
buf_index/IOSQE_FIXED_FILE, and the barriers the file path rests on - io_uring as a Syscall Batching Mechanism — the three syscalls, SQPOLL, and the security restrictions (6.12/6.18 LTS)
- Direct IO and O_DIRECT — the page-cache-bypassing mode that makes fixed buffers and parallel writes matter
- The Page Cache and address_space / Readahead and Read Path — the buffered data path io_uring reuses for cache hits and misses
- fsync fdatasync and Durability — what
IORING_OP_FSYNC/FSYNC_DATASYNCactually flush - Vectored IO readv and writev — the scatter/gather model
IORING_OP_READV/WRITEVmirror - Linux Filesystems and VFS MOC — parent map (§9, Filesystem Notification and Async I/O)