Skip to content

NikoMalik/xuring

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

xuring

Callback-driven io_uring runtime for Linux. Zero-alloc hot path, intrusive task queues, no runtime dependencies beyond libc.

What it does

  • Wraps the Linux io_uring kernel interface into a single-threaded event loop
  • Tasks are pre-allocated and recycled through intrusive linked lists - no heap allocation on the hot path
  • Completion callbacks can submit new work, enabling multi-step state machines (socket -> connect -> read -> ...)
  • Mock backend for deterministic testing under Miri without kernel syscalls

Requirements

  • Linux kernel 5.6+ (feature detection adapts to 5.18+, 5.19+, 6.0+, 6.1+)
  • Rust edition 2024

Supported operations

  • noop - no-op for benchmarking and testing
  • timer - relative or absolute timeout
  • read / write - single-buffer I/O with offset
  • readv / writev - vectored (scatter/gather) I/O
  • open / close - file open and close
  • fsync - sync file data and metadata
  • stat / lstat - file metadata via statx
  • socket / connect / accept - TCP/UDP socket lifecycle
  • recv - receive data from socket
  • poll - poll fd for readiness
  • splice - zero-copy data transfer between fds
  • cancel - cancel one or all in-flight tasks
  • msg_ring - send work between rings (inter-thread communication)

Architecture

 User code
   |
   v
 Ring.noop() / .read() / .write() / ...
   |
   |  creates Task, pushes to submission_q
   v
 Ring.run(condition)
   |
   |  drain submission_q -> kernel SQ ring
   |  io_uring_enter() syscall
   |  drain CQ ring -> local completions list
   v
 (task.callback)(&mut ring, &task)
   |
   |  callback inspects task.result
   |  callback can submit new work -> submission_q
   v
 task -> free_q (recycled on next get_task())

Task lifecycle:

free_q -> get_task() -> reinit -> submission_q -> kernel -> completions -> callback -> free_q

Usage

Basic noop

use xuring::io_uring::{Context, OpResult, Ring, RingError, RunCondition, Task};

let mut ring = Ring::init(16).unwrap();

fn on_noop(_ring: &mut Ring, task: &Task) -> Result<(), RingError> {
    assert!(matches!(task.result, Some(OpResult::Noop)));
    Ok(())
}

ring.noop(Context { cb: on_noop, ..Context::default() });
ring.run(RunCondition::UntilDone).unwrap();

Timer

use xuring::io_uring::{Context, OpResult, Ring, RingError, RunCondition, Task, Timespec};

let mut ring = Ring::init(16).unwrap();

fn on_timer(_ring: &mut Ring, task: &Task) -> Result<(), RingError> {
    assert!(matches!(task.result, Some(OpResult::Timer(Ok(())))));
    Ok(())
}

ring.timer(
    Timespec { sec: 0, nsec: 10_000_000 }, // 10ms
    Context { cb: on_timer, ..Context::default() },
);
ring.run(RunCondition::UntilDone).unwrap();

Read and write on a pipe

use xuring::io_uring::{Context, Offset, OpResult, Ring, RingError, RunCondition, Task};

let mut ring = Ring::init(16).unwrap();

let mut fds = [0i32; 2];
unsafe { libc::pipe(fds.as_mut_ptr()) };
let (read_fd, write_fd) = (fds[0], fds[1]);

fn on_write(_ring: &mut Ring, task: &Task) -> Result<(), RingError> {
    match &task.result {
        Some(OpResult::Write(Ok(n))) => assert_eq!(*n, 5),
        other => panic!("unexpected: {other:?}"),
    }
    Ok(())
}

fn on_read(_ring: &mut Ring, task: &Task) -> Result<(), RingError> {
    match &task.result {
        Some(OpResult::Read(Ok(n))) => assert!(*n > 0),
        other => panic!("unexpected: {other:?}"),
    }
    Ok(())
}

ring.write(write_fd, b"hello", Offset::File, Context { cb: on_write, ..Context::default() });

let mut buf = [0u8; 16];
ring.read(read_fd, &mut buf, Offset::File, Context { cb: on_read, ..Context::default() });

ring.run(RunCondition::UntilDone).unwrap();

Chained operations (open -> close)

Callbacks receive &mut Ring, so they can submit follow-up work:

use xuring::io_uring::{Context, OpResult, Ring, RingError, RunCondition, Task};

let mut ring = Ring::init(16).unwrap();

fn on_close(_ring: &mut Ring, task: &Task) -> Result<(), RingError> {
    assert!(matches!(task.result, Some(OpResult::Close(Ok(())))));
    Ok(())
}

fn on_open(ring: &mut Ring, task: &Task) -> Result<(), RingError> {
    if let Some(OpResult::Open(Ok(fd))) = &task.result {
        ring.close(*fd, Context { cb: on_close, ..Context::default() });
    }
    Ok(())
}

ring.open(c"/dev/null", libc::O_RDONLY as u32, 0,
    Context { cb: on_open, ..Context::default() });
ring.run(RunCondition::UntilDone).unwrap();

Cancellation

use xuring::io_uring::{Context, Ring, RingError, RunCondition, Task, Timespec};

let mut ring = Ring::init(16).unwrap();

fn on_timer(_ring: &mut Ring, _task: &Task) -> Result<(), RingError> { Ok(()) }
fn on_cancel(_ring: &mut Ring, _task: &Task) -> Result<(), RingError> { Ok(()) }

// Submit a long timer, then cancel it.
let timer_ptr = ring.timer(
    Timespec { sec: 3600, nsec: 0 },
    Context { cb: on_timer, ..Context::default() },
);

unsafe { &mut *timer_ptr.as_ptr() }.cancel(
    &mut ring,
    Context { cb: on_cancel, ..Context::default() },
);

ring.run(RunCondition::UntilDone).unwrap();

Callbacks and state machines

Each task carries three fields for user state:

  • callback: fn(&mut Ring, &Task) -> Result<(), RingError> - completion handler
  • msg: u16 - discriminant for multi-step state machines
  • userdata: Option<NonNull<()>> - type-erased pointer to user data

A typical multi-step operation (e.g. TCP connect) works like this:

Step 1: submit socket() with msg=0, callback=handle
Step 2: handle() sees msg=0, reads socket fd from result, submits connect() with msg=1
Step 3: handle() sees msg=1, reads connect result, delivers fd to user

The stda::net module implements this pattern for tcp_connect_to_addr() and udp_connect_to_addr().

Threading

Thread spawns a child ring on a dedicated OS thread, sharing the parent's io_uring worker pool:

use xuring::io_uring::{Ring, Thread};

let mut ring = Ring::init(16).unwrap();
let mut thread = Thread::spawn(&mut ring, 16).unwrap();

// Send work to the thread via msg_ring().
// Kill and join when done.

Inter-ring communication uses msg_ring - the parent submits a CQE directly into the child's completion queue without a syscall round-trip.

Mock backend

For testing without a kernel:

use xuring::io_uring::{Ring, RunCondition, Context, OpResult};

let mut ring = Ring::init_mock();

ring.noop(Context::default());
ring.run(RunCondition::Once).unwrap();

Mock callbacks can be registered per operation type to control results deterministically. All Miri tests use this backend.

Testing

cargo test                           # unit + integration tests
cargo +nightly miri test --lib       # Miri (mock backend only, no FFI)
cargo clippy -- -D warnings          # lint check

Dependencies

  • libc - syscalls and POSIX types
  • bitflags - flag type macros
  • cfg-if - platform conditionals

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages