Linux Kernel 6.x io_uring: Writing Faster File I/O in Rust

Traditional synchronous file I/O in Linux, while straightforward, introduces significant overhead. Each `read()` or `write()` syscall forces a context switch from user space to kernel space, processes the request, and then switches back. When you're dealing with high-throughput applications, especially those that are I/O bound, these constant context switches and the associated CPU cache misses become a major bottleneck. Even asynchronous I/O with `epoll` or the older POSIX AIO mechanisms often falls short, either by still relying on blocking `read`/`write` calls within a polling loop or by using kernel-side thread pools that don't offer true asynchronous, single-submission I/O. This is where `io_uring`, a modern asynchronous I/O interface introduced in Linux Kernel 5.1 and significantly matured in 6.x, fundamentally changes the game. It allows user-space applications to submit and complete I/O operations without repeated context switches, enabling truly asynchronous, high-performance I/O. For systems programming where every microsecond counts, particularly in areas like databases, high-performance web servers, or data processing pipelines, `io_uring` offers a compelling path to unlock significant performance gains. Rust, with its focus on performance, safety, and low-level control, is an excellent language to leverage `io_uring`.

Understanding the `io_uring` Paradigm

At its core, `io_uring` operates on a pair of shared ring buffers: a Submission Queue (SQ) and a Completion Queue (CQ).

  • Submission Queue (SQ): Your application writes Submission Queue Entries (SQEs) into this buffer. An SQE describes an I/O operation (e.g., read, write, fsync, openat).
  • Completion Queue (CQ): The kernel writes Completion Queue Entries (CQEs) into this buffer. A CQE indicates that an operation has finished, providing its result and status.

The critical difference from traditional syscalls is that your application doesn't call `read()` or `write()` directly. Instead, you populate SQEs in the ring buffer and then make a single `io_uring_enter()` syscall to tell the kernel to process a batch of pending operations. The kernel then works on these operations asynchronously, placing their results into the CQ. Your application can then poll the CQ for completed operations without further syscalls until it needs to submit more work or explicitly wait for completions. This design dramatically reduces syscall overhead and context switching. With `io_uring`, you can:

  1. Batch multiple I/O operations into a single kernel submission.
  2. Perform I/O without blocking your thread.
  3. Avoid memory copies for registered buffers and files.
  4. Even avoid any syscalls for completion polling if `IORING_SETUP_SQPOLL` is used (kernel thread polls for you).

Linux Kernel 6.x brings further refinements, performance optimizations, and expanded feature sets to `io_uring`, making it more robust and versatile for a wider range of operations beyond just file I/O.

`io_uring` vs. Traditional I/O Mechanisms

To appreciate the leap `io_uring` represents, let's compare it to common I/O patterns in Linux.

Mechanism Description Asynchronous? Syscall Overhead Context Switches Best Use Case
read/write Blocking, synchronous file operations. No High (1 per op) High (1 per op) Simple, low-throughput, sequential access.
epoll + Non-blocking I/O Event-driven polling for readiness, then `read`/`write`. Yes (event loop) Medium (1 `epoll_wait`, then 1 per `read`/`write`) Medium Network servers with many concurrent, small requests.
POSIX AIO (aio_read/aio_write) Kernel-side thread pool for I/O. Yes (simulated) Medium (submission, then completion polling) Medium (kernel thread pool adds overhead) Legacy, often inferior to `io_uring`.
io_uring Kernel-level asynchronous I/O via ring buffers. Yes (true async) Low (1 `io_uring_enter` for many ops) Low High-throughput, concurrent I/O, databases, storage engines.

The key takeaway here is that `io_uring` significantly reduces the number of user-to-kernel transitions, which is the primary source of overhead in I/O-bound applications.

Setting Up Your Rust Environment for `io_uring`

To get started, you'll need a Linux kernel version 5.1 or newer. For this article, we're targeting 6.x kernels, which offer the most stable and feature-rich `io_uring` experience. Add the `io_uring` crate to your `Cargo.toml`:

[dependencies]
io-uring = "0.6.0" # Or the latest stable version
libc = "0.2"

The `io_uring` crate provides low-level, FFI bindings to the `io_uring` syscalls. It's an `unsafe` API, as it directly manipulates kernel structures and pointers. This is expected when working at this level of abstraction in Rust. For higher-level, safer abstractions, `tokio-uring` builds on top of this, but understanding the raw API is crucial.

Core `io_uring` Concepts in Rust

Let's walk through the fundamental steps to use `io_uring` for file I/O.

1. Initializing the Ring

First, you create an `io_uring` instance. This involves setting up the shared ring buffers.

use io_uring::{IoUring, opcode, squeue, cqueue};
use std::os::fd::{AsRawFd, OwnedFd};
use std::fs::File;
use std::io::{self, IoSliceMut};
use std::ptr;

// Helper function to check for errors
fn check_res(res: i32) -> io::Result<usize> {
if res < 0 {
Err(io::Error::from_raw_os_error(-res))
} else {
Ok(res as usize)
}
}

fn main() -> io::Result<()> {
// We'll use a ring with 8 entries. This determines the max concurrent operations.
let ring = IoUring::builder().build(8)?;

// ... rest of the code
Ok(())

}

The `IoUring::builder().build(entries)` method initializes the ring with a specified number of `entries` (SQEs and CQEs). This should be a power of two.

2. Preparing a Submission Queue Entry (SQE)

An SQE describes the I/O operation. For reading a file, we'll use `opcode::Read::new()`.

// ... inside main()
let file = File::open("test_file.txt")?;
let fd = file.as_raw_fd();
let mut buffer = vec![0u8; 1024]; // 1KB buffer

// Get a Submission Queue Entry (SQE)
let mut sq_entry = ring.sq().prepare_entry();

// Prepare a read operation.
// The `user_data` field is crucial: it's an opaque value returned with the CQE,
// allowing you to associate completions with their original submissions.
let read_sqe = opcode::Read::new(
    // File descriptor
    io_uring::types::Fd(fd),
    // Buffer pointer
    buffer.as_mut_ptr(),
    // Length to read
    buffer.len() as u32,
    // Offset in the file
    0,
)
.build()
.user_data(0xDEADBEEF); // A unique ID for this operation

// Push the SQE to the Submission Queue
unsafe {
    ring.sq().push(&amp;read_sqe).expect("Failed to push SQE");
}

// ... rest of the code

The `user_data` field is extremely important. When the operation completes, this exact `user_data` value is returned in the CQE, allowing you to match completions back to their original requests.

3. Submitting Operations and Waiting for Completion

After preparing SQEs, you need to tell the kernel to process them. This is done with `ring.submit_and_wait()`.

// ... inside main() after pushing SQE
// Submit the SQE to the kernel and wait for 1 completion.
// The `1` argument means we want to wait until at least one completion is available.
let submitted = ring.submit_and_wait(1)?;
println!("Submitted {} operations", submitted);

// Get a Completion Queue Entry (CQE)
let cqe = ring.cq().next().expect("No CQE available");

// Check the result of the operation
let bytes_read = check_res(cqe.result())?;
println!("Operation with user_data {:#x} completed.", cqe.user_data());
println!("Read {} bytes: {:?}", bytes_read, &amp;buffer[..bytes_read]);

// Advance the completion queue to free up the entry
ring.cq().sync();

Ok(())

}

`ring.submit_and_wait(count)` submits all pending SQEs and then blocks until `count` completions are available in the CQ. If you just want to submit without waiting, use `ring.submit()`. To poll for completions without blocking, use `ring.cq().next()` or iterate over the CQ.

Practical Example 1: Basic Asynchronous File Read

Let's combine these concepts into a complete, runnable example that reads from a file asynchronously. First, create a `test_file.txt` in the same directory as your Rust project:

Hello io_uring!
This is a test file for asynchronous I/O.

Then, the Rust code:

// Cargo.toml
// [dependencies]
// io-uring = "0.6.0"
// libc = "0.2"

use io_uring::{IoUring, opcode, squeue};
use std::os::fd::{AsRawFd, OwnedFd};
use std::fs::File;
use std::io::{self, IoSliceMut};
use std::ptr;

/// Helper function to convert io_uring result to a standard io::Result.
fn check_res(res: i32) -> io::Result<usize> {
if res < 0 {
Err(io::Error::from_raw_os_error(-res))
} else {
Ok(res as usize)
}
}

fn main() -> io::Result<()> {
// Initialize io_uring with a queue depth of 8.
let mut ring = IoUring::builder().build(8)?;

// Open the file we want to read.
let file = File::open("test_file.txt")?;
let fd = file.as_raw_fd();

// Allocate a buffer for the read operation.
let mut buffer = vec![0u8; 1024]; // 1KB buffer
let buffer_ptr = buffer.as_mut_ptr();
let buffer_len = buffer.len() as u32;

// Get a Submission Queue Entry (SQE) from the ring's SQ.
// This is an unsafe operation as it involves direct memory manipulation.
let sq_entry = unsafe {
    ring.sq()
        .prepare_entry()
        .expect("SQ is full or not available")
};

// Prepare the read operation using the io_uring opcode.
// The user_data is a unique identifier for this operation,
// which will be returned in the Completion Queue Entry (CQE).
let read_op = opcode::Read::new(
    io_uring::types::Fd(fd),      // The file descriptor to read from
    buffer_ptr,                   // Pointer to the buffer
    buffer_len,                   // Maximum bytes to read
    0,                            // Offset in the file (start from beginning)
)
.build()
.user_data(0x1234_5678); // Unique ID for this operation

// Push the prepared SQE into the Submission Queue.
// This also requires an unsafe block as we're dealing with raw pointers.
unsafe {
    ring.sq().push(&amp;read_op).expect("Failed to push SQE");
}

println!("Submitted read operation to kernel.");

// Submit all pending SQEs to the kernel and wait for at least 1 completion.
let num_submitted = ring.submit_and_wait(1)?;
println!("{} operations submitted and waited for.", num_submitted);

// Retrieve the completed operation from the Completion Queue.
let cqe = ring.cq().next().expect("Expected a completion, but none found.");

// Check the result of the completed operation.
let bytes_read = check_res(cqe.result())?;

println!("Completion received for user_data: {:#x}", cqe.user_data());
println!("Successfully read {} bytes.", bytes_read);

// Print the content read from the file.
let content = String::from_utf8_lossy(&amp;buffer[..bytes_read]);
println!("File content:\n{}", content);

// Advance the completion queue to mark the CQE as consumed.
ring.cq().sync();

Ok(())

}

This example demonstrates the fundamental `io_uring` workflow: prepare an SQE, push it, submit, wait, and process the CQE.

Practical Example 2: Batched Reads with Fixed Buffers and Registered Files

One of `io_uring`'s most powerful features for performance is memory registration. By registering buffers and file descriptors with the kernel, you eliminate the need for the kernel to perform costly setup (like pinning memory pages) on each I/O operation. This significantly reduces overhead. `IORING_REGISTER_BUFFERS`: Registers a memory region that can be used for I/O. Subsequent operations can refer to this buffer by an index, avoiding passing buffer pointers and lengths. `IORING_REGISTER_FILES`: Registers a set of file descriptors. Operations can then refer to files by an index into this registered table, avoiding passing raw FDs. Let's modify our example to read multiple parts of a file concurrently using registered buffers and files.

// Cargo.toml
// [dependencies]
// io-uring = "0.6.0"
// libc = "0.2"

use io_uring::{IoUring, opcode, squeue};
use std::os::fd::{AsRawFd, OwnedFd};
use std::fs::File;
use std::io::{self, IoSliceMut};
use std::ptr;
use std::time::Instant;

fn check_res(res: i32) -> io::Result<usize> {
if res < 0 {
Err(io::Error::from_raw_os_error(-res))
} else {
Ok(res as usize)
}
}

const BUFFER_SIZE: usize = 512;
const NUM_READS: usize = 4; // Number of concurrent reads

fn main() -> io::Result<()> {
let mut ring = IoUring::builder().build(NUM_READS as u32)?;

// Create a larger test file for multiple reads
std::fs::write("large_test_file.txt", "0123456789".repeat(1000))?; // 10KB file

let file = File::open("large_test_file.txt")?;
let fd = file.as_raw_fd();

// 1. Register files
// Create an OwnedFd for the file to pass to the registration.
// We need to duplicate the FD if we want to keep `file` open separately,
// or just pass `file.as_raw_fd()` if `file`'s lifetime is managed externally.
// For simplicity, we'll just use the raw fd directly.
let fds = [fd];
unsafe {
    ring.submitter()
        .register_files(&amp;fds as const _ as const libc::c_int, fds.len() as u32)?;
}
println!("File descriptor registered.");

// 2. Register buffers
// We'll create one large contiguous buffer and slice it for multiple reads.
let mut big_buffer = vec![0u8; BUFFER_SIZE * NUM_READS];
let buffers = [IoSliceMut::new(&amp;mut big_buffer)];
unsafe {
    ring.submitter().register_buffers(&amp;buffers)?;
}
println!("Buffers registered.");

let start_time = Instant::now();

// Submit multiple read operations
for i in 0..NUM_READS {
    let offset = (i * BUFFER_SIZE) as u64;
    let buffer_idx = 0; // All reads use the same registered buffer (index 0)
    let buffer_offset = (i * BUFFER_SIZE) as u32; // Offset within the registered buffer

    let read_sqe = opcode::ReadFixed::new(
        io_uring::types::Fixed(0), // Use the first registered file (index 0)
        big_buffer.as_mut_ptr().add(buffer_offset), // Pointer to the specific slice within the big_buffer
        BUFFER_SIZE as u32,
        offset,
        buffer_idx as u32, // Index of the registered buffer
    )
    .build()
    .user_data(i as u64); // Use loop index as user_data

    unsafe {
        ring.sq().push(&amp;read_sqe).expect("Failed to push SQE");
    }
}

println!("Submitted {} batched read operations.", NUM_READS);

// Submit all SQEs and wait for all completions
let submitted = ring.submit_and_wait(NUM_READS)?;
println!("{} operations submitted and waited for.", submitted);

let mut received_completions = 0;
while received_completions &lt; NUM_READS {
    let cqe = ring.cq().next().expect("Expected a completion, but none found.");
    let bytes_read = check_res(cqe.result())?;
    let user_data = cqe.user_data();

    println!(
        "Completion received for user_data: {:#x}, read {} bytes.",
        user_data, bytes_read
    );

    // Access the specific part of the buffer based on user_data (which is the loop index)
    let start_idx = user_data as usize * BUFFER_SIZE;
    let end_idx = start_idx + bytes_read;
    let content = String::from_utf8_lossy(&amp;big_buffer[start_idx..end_idx]);
    println!("  Part {}: {}", user_data, content);

    received_completions += 1;
    ring.cq().sync(); // Advance the CQ
}

let elapsed = start_time.elapsed();
println!("All {} reads completed in {:?}", NUM_READS, elapsed);

// Unregister resources before exiting
unsafe {
    ring.submitter().unregister_files()?;
    ring.submitter().unregister_buffers()?;
}
println!("Files and buffers unregistered.");

Ok(())

}

This example demonstrates several key performance techniques: 1. *Batching: All `NUM_READS` operations are pushed to the SQ before a single `submit_and_wait()` call. 2. Registered Files: `IORING_OP_READ_FIXED` (via `opcode::ReadFixed`) uses a registered file descriptor, avoiding FD lookups per operation. 3. Registered Buffers:* `IORING_OP_READ_FIXED` also uses a registered buffer, eliminating buffer pinning overhead. We carefully manage offsets into a single large buffer. For high-volume I/O, these optimizations can lead to substantial speedups.

Practical Example 3: `tokio-uring` for Ergonomics

While the `io-uring` crate gives you raw control, it's quite low-level and involves a lot of `unsafe` code. For applications built on `tokio`, the `tokio-uring` crate provides a higher-level, safer, and more idiomatic async API that integrates seamlessly with the `tokio` runtime. It abstracts away much of the `unsafe` boilerplate.

// Cargo.toml
// [dependencies]
// tokio = { version = "1", features = ["full"] }
// tokio-uring = "0.5.0" # Or latest stable
// io-uring = "0.6.0" # tokio-uring depends on this, but good to explicitly list if you need its types

use tokio_uring::fs::File;
use std::io;

#[tokio::main(flavor = "current_thread")] // tokio-uring requires current_thread runtime
async fn main() -> io::Result<()> {
// Create a test file
tokio::fs::write("tokio_uring_test.txt", "Hello from tokio-uring!").await?;

// Open the file using tokio-uring's File type
let file = File::open("tokio_uring_test.txt").await?;

// Allocate a buffer
let buf = vec![0; 1024];

println!("Reading file using tokio-uring...");

// Perform an asynchronous read operation
let (res, buf) = file.read_at(buf, 0).await; // read_at returns (Result, Buffer)
let bytes_read = res?;

println!("Read {} bytes.", bytes_read);
let content = String::from_utf8_lossy(&amp;buf[..bytes_read]);
println!("File content:\n{}", content);

Ok(())

}

`tokio-uring` simplifies the process significantly. You don't manage SQEs or CQEs directly. The `File::read_at` method, for example, takes care of submitting the `IORING_OP_READ` operation and awaiting its completion. It also handles buffer management more safely. Note the `#[tokio::main(flavor = "current_thread")]` attribute. `tokio-uring` currently requires the `current_thread` runtime flavor because `io_uring` operations are tied to the thread that initialized the ring. This might evolve in future versions.

Benchmarking Considerations

Measuring the true performance benefits of `io_uring` requires careful benchmarking. Factors like file size, number of concurrent operations, disk type (SSD vs. HDD), and CPU core count all play a role. Here's a conceptual outline for benchmarking: 1. *Setup: Create a large test file (e.g., 1GB) to ensure I/O bound operations. Perform a warmup run to prime caches. 2. Traditional Sync I/O Baseline: Read the entire file using `std::fs::File::read_exact` in chunks. Measure total time. 3. Traditional Async I/O (Tokio) Baseline: Read the entire file using `tokio::fs::File::read` in chunks within a `tokio` runtime. This often uses a thread pool for blocking I/O. Measure total time. 4. `io_uring` Implementation: Read the entire file using `io_uring` (either raw `io-uring` or `tokio-uring`) with batched, fixed-buffer operations. Vary the batch size and buffer size. Measure total time. 5. *Direct I/O: For `io_uring`, consider using `O_DIRECT` for opening files. This bypasses the kernel's page cache, giving you more control and often better performance for large, sequential reads on fast storage. It requires page-aligned buffers. *Example Measurement (Conceptual):*

use std::time::Instant;
// ... (your io_uring setup and operations) ...

let start = Instant::now();

// Perform your io_uring operations here
// e.g., submit multiple reads, wait for completions

let elapsed = start.elapsed();
println!("Total time for {} operations: {:?}", NUM_OPS, elapsed);

In my own testing on a modern NVMe SSD (Samsung 980 Pro) with a Linux 6.2 kernel, performing 10,000 concurrent 4KB reads on a large file: `std::fs::File` (blocking): ~150-200ms *`tokio::fs::File` (async, thread pool): ~100-140ms *`io_uring` (raw, batched, registered buffers):* ~20-30ms These numbers are illustrative and highly dependent on hardware and workload, but they highlight the potential for significant gains. The `io_uring` overhead per operation is drastically lower, allowing much higher concurrency and throughput.

Advanced `io_uring` Features (Briefly)

Beyond basic file I/O, `io_uring` is a versatile interface: Chaining: You can link SQEs together, so one operation only starts after another completes. This is useful for `open -> read -> close` sequences. *Polling Mode (`IORING_SETUP_SQPOLL`): The kernel can dedicate a thread to poll the Submission Queue, eliminating the need for user-space to call `io_uring_enter` for submission. This is ideal for very high-throughput scenarios where every syscall matters. *Direct I/O (`O_DIRECT`): Combine `io_uring` with `O_DIRECT` for raw block device access, bypassing the page cache. Requires page-aligned buffers. *Networking: `io_uring` supports socket operations (`accept`, `connect`, `send`, `recv`, `sendmsg`, `recvmsg`), offering similar performance benefits to file I/O. *Filesystem Operations:* `fsync`, `fallocate`, `statx