nexus-async-rt 0.4.1

Single-threaded async executor with pre-allocated task storage
Documentation

nexus-async-rt

Single-threaded async runtime for latency-sensitive systems. Built on mio.

Not a tokio replacement — a purpose-built alternative for single-threaded event loops where predictable latency matters more than multi-threaded throughput.

When to use this vs tokio

Use nexus-async-rt when:

  • Single-threaded event loop (one core, no work-stealing)
  • Predictable tail latency (no scheduler jitter)
  • Zero-alloc task spawning on the hot path (slab allocation)
  • Futures are !Send — and that's fine
  • You're building on the nexus ecosystem (nexus-net, nexus-rt)

Use tokio when:

  • Multi-threaded execution or work-stealing
  • tokio ecosystem (tower, hyper, tonic, etc.)
  • Futures need to be Send across threads
  • Broad community support matters

Both can coexist in the same process. Use nexus-async-rt for the latency-critical event loop and tokio for everything else.

Quick start

use nexus_async_rt::*;
use nexus_rt::WorldBuilder;

let mut world = WorldBuilder::new().build();
let mut rt = Runtime::new(&mut world);

rt.block_on(async {
    let handle = spawn_boxed(async { 42 });
    let result = handle.await;
    assert_eq!(result, 42);
});

What you get

Task spawning

Two strategies, same API:

// Box-allocated — default, no setup needed
let handle = spawn_boxed(async { compute() });

// Slab-allocated — pre-allocated, zero-alloc hot path
let handle = spawn_slab(async { compute() });

Both return JoinHandle<T> — await for the result, drop to detach, or call abort() to cancel (consumes the handle).

Slab allocation (zero-alloc spawn)

For hot-path tasks where allocation jitter is unacceptable:

// SAFETY: single-threaded runtime owns the slab.
let slab = unsafe { Slab::<256>::with_chunk_capacity(64) };
let mut rt = Runtime::builder(&mut world)
    .slab_unbounded(slab)
    .build();

rt.block_on(async {
    // Pre-allocated — no Box, no allocator, zero syscalls
    let handle = spawn_slab(async { fast_path() });
    handle.await
});

Or claim a slot first, spawn later:

if let Some(claim) = try_claim_slab() {
    let handle = claim.spawn(async { work() });
    // ...
}

Timers

use std::time::Duration;

// Sleep
sleep(Duration::from_millis(100)).await;

// Timeout
let result = timeout(Duration::from_secs(5), some_future).await;

// Interval
let mut tick = interval(Duration::from_millis(10));
loop {
    tick.tick().await;
    poll_market_data();
}

I/O (mio-based)

use nexus_async_rt::{TcpStream, TcpListener, io};

// Client
let stream = TcpStream::connect(addr, io())?;

// Server
let listener = TcpListener::bind(addr, io())?;
let (stream, peer) = listener.accept().await?;

Channels

Three flavors for different use cases:

use nexus_async_rt::channel;

// Local MPSC — !Send, zero atomics, single-threaded
let (tx, rx) = channel::local::channel(64);

// Cross-thread MPSC — Sender: Clone + Send
let (tx, rx) = channel::mpsc::channel(64);

// Cross-thread SPSC — fastest cross-thread path
let (tx, rx) = channel::spsc::channel(64);

World access

Access nexus-rt World resources from async tasks:

with_world(|world| {
    let config = world.resource::<Config>();
    // ...
});

Graceful shutdown

rt.block_on(async {
    // ... spawn tasks ...
    shutdown_signal().await; // waits for Ctrl+C
});

Cancellation

let token = CancellationToken::new();
let child = token.child_token();

spawn_boxed(async move {
    while !child.is_cancelled() {
        do_work().await;
    }
    // cleanup
});

token.cancel(); // cancels all children

JoinHandle

spawn_boxed and spawn_slab return JoinHandle<T>:

  • Await — get the result: let val = handle.await;
  • Detach — drop the handle, task continues, output dropped on completion
  • Aborthandle.abort() consumes the handle, future dropped on next poll
  • Checkhandle.is_finished() for non-blocking status

JoinHandle is !Send and !Sync — stays on the executor thread.

Performance

Path p50
Task dispatch (poll cycle) 55-64 cycles
Local channel try_send+try_recv 13 ns
MPSC channel try_send+try_recv 22 ns
SPSC channel try_send+try_recv 15 ns
Cross-thread channel (busy spin) 15 ns
Cross-thread channel (park/epoll) 1.7 us
Tokio-compat waker bridge 76 ns

Features

Feature Default Description
tokio-compat No Adapters for bridging tokio and nexus-async-rt in the same process

Dependencies

  • mio — I/O event loop (epoll/kqueue)
  • nexus-rt — World/WorldBuilder for typed resource storage
  • nexus-slab — Optional pre-allocated task storage
  • nexus-timer — Hierarchical timer wheel
  • nexus-queue / nexus-logbuf — Lock-free internal queues

Design Notes

Runtime::block_on is the only entry point for driving the executor. The drain() method was removed -- all task completion is handled within block_on's poll loop.

Cross-thread wakes use a deferred-free strategy: tasks woken from another thread are queued via an intrusive Vyukov MPSC queue and processed on the next executor poll. Task memory is freed on the executor thread, not the waking thread, to avoid cross-thread deallocation.

Platform support

Unix only (#![cfg(unix)]). Linux is the primary target, macOS supported.