When syscalls become the bottleneck
You are building a high-throughput file server. You have optimized the Rust code. You have tuned the network stack. You have eliminated allocations in the hot path. But under heavy load, the CPU usage spikes, and throughput plateaus. The bottleneck is not your logic. It is the system calls.
Every read and write involves a context switch between user space and kernel space. The traditional Linux async model uses epoll. epoll is excellent, but it still requires a syscall to submit an operation and another to retrieve the result. When you are processing thousands of small files, those syscalls add up. The CPU spends more time switching contexts than doing work.
You need a way to batch operations and let the kernel work asynchronously without waking up the CPU for every single request. That is what io_uring provides. It changes the contract between the application and the kernel, reducing overhead and enabling massive concurrency.
The drop-box model
Traditional async I/O works like a waiter in a busy restaurant. You give your order to the waiter. The waiter runs to the kitchen, waits for the chef to finish, and brings the food back. The waiter is blocked until the order is complete. If you have many orders, you need many waiters, or the customers wait.
io_uring replaces the waiter with a drop-box. You write your request on a slip of paper and drop it in the submission box. The chef sees the slip and starts cooking immediately. When the food is ready, the chef puts the result in a separate completion box. You check the completion box whenever you are ready.
There is no waiting. No blocking. The kernel processes requests in the background while your application continues running. You can drop ten slips in the box at once. The kernel processes them all. You only interact with the box when you have work to submit or results to collect. This model eliminates context switches for the request submission and allows efficient batching.
Enabling io_uring in Tokio
Tokio supports io_uring, but it is behind a feature flag and requires an unstable configuration. This is a deliberate choice. io_uring is relatively new, and the API is still evolving. Tokio wants to ensure stability before making it the default. The io-uring feature enables the backend, but you must also pass a configuration flag to tell Tokio you are aware of the instability.
Add the feature to your dependencies.
// Cargo.toml
[dependencies]
// Enable the io-uring backend.
// This compiles the io_uring support into the runtime.
tokio = { version = "1", features = ["full", "io-uring"] }
Set the configuration flag in your build environment.
RUSTFLAGS="--cfg tokio_unstable" cargo run
Tokio checks for this flag at compile time. If you enable the feature without the flag, the build fails. The compiler rejects the code with an error indicating that io-uring requires tokio_unstable. This protection prevents accidental use of unstable APIs in production builds.
Convention aside: put the flag in your project configuration to avoid typing it every time. Create a .cargo/config.toml file in your project root.
# .cargo/config.toml
[build]
rustflags = ["--cfg", "tokio_unstable"]
This applies the flag to all builds, tests, and benchmarks. It keeps your workflow clean and ensures consistency across your team.
Enable the feature, set the flag, and you are ready. Do not skip the flag. Tokio checks for it at compile time.
Minimal example
Once configured, the API looks familiar. You still use tokio::fs. The magic happens under the hood. Tokio detects the io-uring feature and routes file operations through the ring buffer instead of the standard blocking syscalls.
use tokio::fs;
#[tokio::main]
async fn main() {
// Write data to a file.
// With io-uring enabled, this submits a write request to the kernel ring.
// The task yields while the kernel processes the request.
let content = b"Hello, io_uring!";
fs::write("output.txt", content).await.unwrap();
// Read it back.
// This submits a read request.
// The kernel fills the buffer and signals completion.
let data = fs::read("output.txt").await.unwrap();
println!("Read: {}", String::from_utf8_lossy(&data));
}
Run this code. It behaves exactly like standard Tokio code. The difference is performance. Under heavy load, the io_uring backend reduces CPU usage and increases throughput. You do not need to change your application logic. Tokio handles the translation.
What happens under the hood
io_uring uses two shared memory rings: the Submission Queue (SQ) and the Completion Queue (CQ). The kernel maps these rings into your process's memory. Writing to the SQ is just writing to a buffer. No syscall is needed to submit a request. You only call io_uring_enter to wake the kernel or wait for completions.
When you call fs::write, Tokio creates a request structure and places it in the SQ. The request contains the file descriptor, the buffer pointer, the length, and a user data tag. Tokio then polls the runtime. The runtime checks the CQ for completions. If the request is not done, the runtime yields the task.
The kernel reads the SQ in the background. It processes the write operation. When the write finishes, the kernel writes a completion entry to the CQ. The entry includes the result and the user data tag. The runtime sees the completion and wakes the corresponding task. The task resumes and returns the result.
This flow avoids copying data between user and kernel space for the request metadata. It also allows batching. You can submit multiple requests to the SQ before calling io_uring_enter. The kernel processes the entire batch. This reduces the number of syscalls and improves cache locality.
The kernel does the work. Your app stays responsive. That is the promise of io_uring.
Realistic workload
The real power of io_uring shines in concurrent workloads. Consider a service that processes many files simultaneously. With epoll, each file operation requires a syscall. With io_uring, Tokio batches the submissions automatically.
use tokio::fs;
use tokio::task;
#[tokio::main]
async fn main() {
let files = vec!["data/1.bin", "data/2.bin", "data/3.bin", "data/4.bin"];
// Spawn tasks for each file.
// The runtime batches io_uring submissions when multiple tasks are ready.
let mut handles = vec![];
for file in files {
let handle = task::spawn(async move {
// Each read submits to the ring.
// The kernel processes them concurrently.
let content = fs::read(file).await.unwrap();
content.len()
});
handles.push(handle);
}
// Collect results.
let mut total_size = 0;
for handle in handles {
total_size += handle.await.unwrap();
}
println!("Total size: {} bytes", total_size);
}
In this example, four tasks are spawned. Each task reads a file. When the runtime polls, it sees four pending requests. It submits all four to the SQ in a single batch. The kernel processes them. The runtime wakes the tasks as completions arrive.
You write standard async code. Tokio does the heavy lifting. The performance gain comes for free once the feature is enabled.
Pitfalls and kernel requirements
io_uring is a kernel feature. It requires Linux 5.1 or newer. If you run this code on an older kernel, it fails. Tokio detects the kernel version at runtime. If io_uring is not available, Tokio falls back to epoll. However, some operations may fail with ENOSYS or EOPNOTSUPP if the kernel does not support specific io_uring commands.
Check your kernel version before blaming your code.
uname -r
If the version is below 5.1, io_uring is not available. You must use the default backend or upgrade your kernel.
Another pitfall is the tokio_unstable flag. If you forget the flag, the build fails. The error message is clear.
error: the feature io-uring requires the cfg tokio_unstable
You must pass --cfg tokio_unstable in RUSTFLAGS. This applies to tests and benchmarks as well. If you run cargo test without the flag, the tests fail. Use the .cargo/config.toml approach to avoid this issue.
Check your kernel version before blaming your code. io_uring is a kernel feature first and a Rust feature second.
Decision matrix
Use Tokio with the io-uring feature when you run on Linux 5.1+ and need maximum I/O throughput without changing your async code.
Use standard Tokio with the default epoll backend when you target macOS, Windows, or older Linux kernels where io_uring is unavailable.
Use the tokio-uring crate when you need direct access to the ring buffer for advanced patterns like manual batching, custom polling logic, or integration with C libraries that expose io_uring APIs.
Use blocking threads with spawn_blocking when you must call synchronous C libraries that cannot be adapted to async I/O, regardless of the backend.
Pick the tool that matches your deployment target. Performance means nothing if the binary will not run.