How to Use io_uring with Rust

Use the io-uring crate in Rust to initialize a Ring and submit I/O operations for high-performance asynchronous processing.

How to Use io_uring with Rust

You're running a web server that handles thousands of concurrent connections. The standard async setup works fine until you push the load higher. Suddenly, the CPU spends more time juggling context switches and crossing the kernel boundary than actually moving data. You've heard whispers of io_uring, a Linux kernel feature that promises to slash that overhead. It sounds like the secret weapon for high-performance I/O. It is, but it requires a shift in how you think about system calls.

The clipboard analogy

Traditional system calls work like a single-lane toll booth. Your program stops, hands a request to the kernel, waits for the kernel to process it, and gets the result back. Every request requires a context switch. io_uring replaces the toll booth with a shared clipboard. Your program writes requests onto the clipboard in a ring buffer. The kernel reads the clipboard asynchronously and writes results back to a separate completion ring. Your program checks the completion ring to see what finished. No stopping. No waiting for the kernel to acknowledge every single request. Just batched, asynchronous hand-offs.

The clipboard stays full of work. The kernel drains it without stopping your thread.

Minimal setup

The io-uring crate wraps the kernel API in safe Rust types. You initialize a ring, build operations, submit them, and check completions.

use io_uring::{IoUring, opcode};

/// Demonstrates a single read operation using io_uring.
fn main() -> std::io::Result<()> {
    // Create a ring with 32 slots.
    // Power-of-two sizes align with kernel optimizations.
    let mut ring = IoUring::new(32)?;

    // Get a handle to the submission queue.
    // This is where you queue requests for the kernel.
    let mut sq = ring.submission();

    // Prepare a buffer for the read.
    // The kernel will write data directly into this memory.
    let mut buf = [0u8; 1024];

    // Build a read request for stdin (fd 0).
    // This constructs the operation but does not execute it.
    let read_op = opcode::Read::new(0.into(), buf.as_mut_ptr(), 1024);

    // Submit the request and wait for one completion.
    // This blocks until the kernel processes the queue.
    sq.submit_and_wait(1)?;

    // Iterate over the completion queue.
    // Each event corresponds to a finished operation.
    let mut cq = ring.completion();
    for cqe in cq {
        // cqe.result() returns bytes read or a negative errno.
        println!("Result: {}", cqe.result());
    }

    Ok(())
}

Convention aside: always use the io-uring crate. The raw kernel API requires manual memory mapping and pointer arithmetic. The crate handles the ring buffer setup, provides type-safe opcodes, and manages the shared memory lifecycle. Reaching for libc here adds zero value and introduces significant risk.

What happens under the hood

When you call IoUring::new, the crate allocates two ring buffers and shares them with the kernel via memory mapping. One ring holds submissions, the other holds completions. You push a request to the submission ring. The kernel sees the new entry and starts the I/O. When the I/O finishes, the kernel writes a completion event to the completion ring. Your code checks the completion ring to see the result.

The magic is batching. You can fill the submission ring with ten reads, submit once, and the kernel processes them all. You get ten completions back. This reduces syscalls from ten to one. The context switch tax vanishes.

Realistic file reading

A real application reads files, handles errors, and manages buffers. Here's how you read a file chunk and handle the result.

use io_uring::{IoUring, opcode};
use std::fs::File;
use std::os::unix::io::AsRawFd;

/// Reads a file using io_uring and handles errors.
fn read_file_chunk(path: &str) -> std::io::Result<usize> {
    // Open the file and get the raw file descriptor.
    let file = File::open(path)?;
    let mut ring = IoUring::new(8)?;
    let mut sq = ring.submission();

    // Allocate a buffer for the read.
    // This buffer must live until the kernel finishes writing.
    let mut buf = vec![0u8; 4096];

    // Create the read operation.
    // We pass the raw fd and a pointer to the buffer.
    let op = opcode::Read::new(
        file.as_raw_fd().into(),
        buf.as_mut_ptr(),
        buf.len() as u32,
    );

    // Submit and wait for the operation to complete.
    // submit_and_wait ensures the kernel has finished before we proceed.
    sq.submit_and_wait(1)?;

    // Check the completion queue.
    let mut cq = ring.completion();
    for cqe in cq {
        let res = cqe.result();
        if res < 0 {
            // Negative results are errno values.
            // Convert them to a std::io::Error.
            return Err(std::io::Error::from_raw_os_error(-res));
        }
        return Ok(res as usize);
    }

    // If no completion arrived, something went wrong.
    Err(std::io::Error::new(
        std::io::ErrorKind::Other,
        "No completion event received",
    ))
}

Batching requests

The real power of io_uring appears when you batch multiple operations. You queue several requests, submit them all at once, and process completions in bulk.

use io_uring::{IoUring, opcode};
use std::fs::File;
use std::os::unix::io::AsRawFd;

/// Reads multiple files concurrently using io_uring batching.
fn read_multiple_files(paths: &[&str]) -> std::io::Result<()> {
    // Create a ring large enough for all requests.
    let size = paths.len().max(8);
    let mut ring = IoUring::new(size)?;
    let mut sq = ring.submission();

    // Buffers for each file.
    // In a real app, you'd track these with an index or handle.
    let mut buffers: Vec<Vec<u8>> = paths
        .iter()
        .map(|_| vec![0u8; 4096])
        .collect();

    // Queue a read for each file.
    for (i, path) in paths.iter().enumerate() {
        let file = File::open(path)?;
        let buf = &mut buffers[i];

        // Build the read operation.
        let op = opcode::Read::new(
            file.as_raw_fd().into(),
            buf.as_mut_ptr(),
            buf.len() as u32,
        );

        // Push the operation to the submission queue.
        // This queues the request without submitting yet.
        sq.push(&op).unwrap();
    }

    // Submit all queued operations and wait for completions.
    // The kernel processes the batch asynchronously.
    sq.submit_and_wait(paths.len())?;

    // Process completions.
    let mut cq = ring.completion();
    for cqe in cq {
        let res = cqe.result();
        if res < 0 {
            eprintln!("Error reading file: {}", -res);
        } else {
            println!("Read {} bytes", res);
        }
    }

    Ok(())
}

Batching turns ten syscalls into one. The kernel sees a batch of work and schedules it efficiently. Your thread blocks once instead of ten times.

Pitfalls and errors

Buffer lifetime is your responsibility

The kernel holds a reference to your buffer. If you drop the buffer before the completion arrives, the kernel writes to freed memory. Rust's borrow checker can't see the kernel. You have to manage this manually.

If you try to structure code where the buffer scope is too short, the compiler might reject you with E0597 (value does not live long enough). That error saves you. The danger is when the borrow checker lets you pass, but the kernel writes later. This happens if you submit an operation, drop the buffer, and check completions later. The kernel writes to garbage.

The borrow checker protects your stack. You protect the kernel's view of your heap.

Ring size matters

Ring buffers perform best with power-of-two sizes. The kernel uses bitwise operations for indexing. If you pick a size like 30, the kernel falls back to slower modulo arithmetic. The io-uring crate might warn you or adjust the size, but it's better to specify powers of two explicitly.

Linux only

io_uring is a Linux kernel feature. It does not exist on macOS, Windows, or BSD. If you need portability, io_uring is not the answer. The crate compiles on other platforms, but it will panic or return errors at runtime.

Error codes in completions

Completion results can be negative. A negative value is an errno. You must check cqe.result() < 0 and convert it to an error. Ignoring negative results leads to silent data corruption or logic bugs.

When to use io_uring

Use io_uring when you need maximum throughput on Linux and can manage the complexity of ring buffers and buffer lifetimes. Use io_uring when profiling shows syscall overhead is the bottleneck and you have enough concurrent I/O to benefit from batching. Use standard async/await when you need portability across operating systems or want the compiler to help with buffer lifetimes via futures. Use blocking I/O when your workload is simple, the number of connections is low, and you don't want to deal with asynchronous state machines or kernel APIs.

Pick the tool that matches your bottleneck. Complexity costs nothing if you don't need the speed.

Where to go next