Async I/O on Linux with io_uring: A Practical Overview
This article covers Async I/O on Linux with io_uring: A Practical Overview. io_uring moves beyond readiness: submit I/O and receive completions. Learn the core concepts, when it helps, and minimal examples in C, Zig, and Rust.
Linux io_uring is an asynchronous I/O interface designed to reduce syscall overhead and improve throughput and latency under load.
Where epoll tells you “a fd is ready”, io_uring is closer to:
- submit operations (read/write/open/stat, etc.)
- receive completions when done
1) Key components
- SQ (Submission Queue): you enqueue requests.
- CQ (Completion Queue): kernel posts results.
- SQE: submission queue entry.
- CQE: completion queue entry.
Many operations can be submitted in batches, reducing syscalls.
2) When io_uring helps
- high-throughput servers (lots of concurrent I/O)
- workloads where syscall overhead is measurable
- mixed operations: accept + read + write + fs ops
When it may not:
- simple programs doing a little I/O
- workloads dominated by CPU, not I/O
3) Minimal conceptual example
Real io_uring code is longer (setup, rings, feature flags). In production, you usually use a library.
Below are minimal “use a library” examples.
4) C: using liburing (conceptual snippet)
#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char **argv) {
if (argc != 2) return 2;
int fd = open(argv[1], O_RDONLY);
if (fd < 0) return 1;
struct io_uring ring;
if (io_uring_queue_init(8, &ring, 0) != 0) return 1;
char buf[4096];
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int res = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (res > 0) write(1, buf, (size_t)res);
io_uring_queue_exit(&ring);
close(fd);
return 0;
}
5) Zig: calling liburing
Zig can link against C libraries and call them via @cImport.
const std = @import("std");
const c = @cImport({
@cInclude("liburing.h");
@cInclude("fcntl.h");
@cInclude("unistd.h");
});
pub fn main() !void {
var args_it = std.process.args();
_ = args_it.next();
const path = args_it.next() orelse return error.InvalidArgs;
const fd = c.open(path, c.O_RDONLY);
if (fd < 0) return error.OpenFailed;
defer _ = c.close(fd);
var ring: c.struct_io_uring = undefined;
if (c.io_uring_queue_init(8, &ring, 0) != 0) return error.InitFailed;
defer c.io_uring_queue_exit(&ring);
var buf: [4096]u8 = undefined;
const sqe = c.io_uring_get_sqe(&ring);
c.io_uring_prep_read(sqe, fd, &buf, buf.len, 0);
_ = c.io_uring_submit(&ring);
var cqe: ?*c.struct_io_uring_cqe = null;
_ = c.io_uring_wait_cqe(&ring, &cqe);
const res = cqe.?.res;
c.io_uring_cqe_seen(&ring, cqe.?);
if (res > 0) {
_ = c.write(1, &buf, @as(usize, @intCast(res)));
}
}
6) Rust: use io-uring crate (high level)
// Cargo.toml: io-uring = "0.6"
use io_uring::{opcode, types, IoUring};
use std::fs::File;
use std::os::unix::io::AsRawFd;
fn main() -> std::io::Result<()> {
let path = std::env::args().nth(1).expect("path");
let f = File::open(path)?;
let mut ring = IoUring::new(8)?;
let mut buf = vec![0u8; 4096];
let read_e = opcode::Read::new(types::Fd(f.as_raw_fd()), buf.as_mut_ptr(), buf.len() as _)
.offset(0)
.build()
.user_data(1);
unsafe {
ring.submission().push(&read_e).expect("queue full");
}
ring.submit_and_wait(1)?;
let cqe = ring.completion().next().unwrap();
let res = cqe.result();
if res > 0 {
let n = res as usize;
std::io::Write::write_all(&mut std::io::stdout(), &buf[..n])?;
}
Ok(())
}
7) Safety and operational notes
- io_uring has a long tail of feature flags and kernel-version differences.
- For production, you’ll want:
- fixed buffers / registered files
- careful backpressure
- robust error handling
References
io_uring(7): https://man7.org/linux/man-pages/man7/io_uring.7.html- liburing: https://github.com/axboe/liburing
- Rust
io-uringcrate: https://crates.io/crates/io-uring - LWN io_uring articles (excellent background): https://lwn.net/Articles/776703/