A minimalist single-threaded async runtime built on completion-based I/O:
io_uring on Linux, and on Windows a mix of
I/O Rings (the
io_uring-style submission/completion ring on Windows 11 / Server 2022) and classic
overlapped I/O where a ring operation isn't available. It exposes async filesystem,
network, timer, buffered-I/O, and channel primitives over a small, dependency-light core.
- Thread-per-core / single-threaded. Each
Runtimeowns all of its task and I/O state in anRc<State>and is!Send. There is no work-stealing scheduler and no cross-thread synchronization on the hot path. Blocking work is offloaded to a sharedThreadPool. - Completion-based, not readiness-based. Unlike epoll-style runtimes, the kernel
owns the buffer for the duration of an operation. Buffers travel with the request
(
Request→ backend →Response), so the API never lets the kernel write into caller-borrowed memory. - Interface-owned buffers. The buffered I/O layer (
AsyncReader/AsyncWriter) owns itsIoBufferand stores in-flight completions in its own state. Cancelling a future (e.g. viatimeout) parks the in-flight op rather than losing data — the next call resumes it. The design follows Zig 0.15'sstd.Io.Reader/Writerwithstd::ionaming conventions. - Cross-platform. Both backends expose the same
Poller/Request/Responsesurface, so the public API is identical on Linux and Windows.
use nanopore::Runtime;
use nanopore::time::sleep;
use std::time::Duration;
let rt = Runtime::new().unwrap();
rt.block_on(async {
println!("waiting...");
sleep(Duration::from_secs(1)).await;
println!("done");
});use nanopore::Runtime;
use nanopore::fs::AsyncFile;
use nanopore::io::{AsyncReader, AsyncWriter, copy};
let rt = Runtime::new().unwrap();
rt.block_on(async {
let src = AsyncFile::open("src.txt").await.unwrap();
let dst = AsyncFile::create("dst.txt").await.unwrap();
let mut reader = AsyncReader::new(&src);
let mut writer = AsyncWriter::new(&dst);
// `copy` moves the reader's buffer directly into the writer — no userspace copy.
copy(&mut reader, &mut writer).await.unwrap();
});use nanopore::Runtime;
use nanopore::net::AsyncTcpListener;
use nanopore::io::{AsyncReader, AsyncWriter};
let rt = Runtime::new().unwrap();
rt.block_on(async {
let listener = AsyncTcpListener::bind(("127.0.0.1", 8080)).await.unwrap();
let (client, _addr) = listener.accept().await.unwrap();
let mut reader = AsyncReader::new(&client);
let mut writer = AsyncWriter::new(&client);
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.unwrap();
writer.write_all(&buf).await.unwrap();
writer.flush().await.unwrap();
});Tasks are spawned with Runtime::spawn (returning a JoinHandle) or, from inside the
runtime, with handle().spawn(...).
These three primitives let you work against the interface-owned buffers directly,
without bouncing bytes through a temporary Vec:
AsyncReader::buffer/fill_buf— borrow the bytes the kernel filled, in place.AsyncReader::consume— mark how many of those bytes you actually used.AsyncWriter::unfilled_mut— borrow the writer's spare capacity and fill it directly, skipping the userspace copy thatwrite_allperforms.
This loop uppercases a file while it streams through. The payload never lands in a
caller-owned Vec, but transforming from the read buffer into the write buffer still
costs one userspace copy per byte:
use nanopore::Runtime;
use nanopore::fs::AsyncFile;
use nanopore::io::{AsyncReader, AsyncWriter};
let rt = Runtime::new().unwrap();
rt.block_on(async {
let src = AsyncFile::open("src.txt").await.unwrap();
let dst = AsyncFile::create("dst.txt").await.unwrap();
let mut reader = AsyncReader::new(&src);
let mut writer = AsyncWriter::new(&dst);
loop {
// Borrow the bytes the kernel already read into the reader's buffer.
let chunk = reader.fill_buf().await.unwrap();
if chunk.is_empty() {
break; // end of stream
}
// Reserve spare capacity in the writer and transform straight into it.
let spare = writer.unfilled_mut(chunk.len());
let n = spare.len();
for (slot, &b) in spare.iter_mut().zip(chunk) {
slot.write(b.to_ascii_uppercase());
}
if n == 0 {
// Writer buffer is full — drain it and retry the same chunk.
writer.flush().await.unwrap();
} else {
// Mark exactly what we moved as consumed.
reader.consume(n);
}
}
writer.flush().await.unwrap();
});unfilled_mut hands back &mut [MaybeUninit<u8>] and commits the slice to the buffer
immediately, so the kernel's next drain sends precisely what you wrote into it.
When you only read or only write, the data touches exactly one buffer — the very memory the kernel reads from or writes into — so there is no userspace copy at all.
Scanning consumes the read buffer in place (counting newlines, here) without ever copying bytes out:
use nanopore::Runtime;
use nanopore::fs::AsyncFile;
use nanopore::io::AsyncReader;
let rt = Runtime::new().unwrap();
let lines = rt.block_on(async {
let file = AsyncFile::open("src.txt").await.unwrap();
let mut reader = AsyncReader::new(&file);
let mut lines = 0usize;
loop {
let chunk = reader.fill_buf().await.unwrap();
if chunk.is_empty() {
break;
}
let n = chunk.len();
lines += chunk.iter().filter(|&&b| b == b'\n').count();
reader.consume(n);
}
lines
}).unwrap();Producing writes computed bytes straight into the writer's buffer — the same memory the kernel later drains:
use nanopore::Runtime;
use nanopore::fs::AsyncFile;
use nanopore::io::AsyncWriter;
let rt = Runtime::new().unwrap();
rt.block_on(async {
let file = AsyncFile::create("dst.bin").await.unwrap();
let mut writer = AsyncWriter::new(&file);
let mut next = 0usize;
while next < 256 {
let spare = writer.unfilled_mut(256 - next);
if spare.is_empty() {
writer.flush().await.unwrap(); // buffer full — drain and continue
continue;
}
for slot in spare.iter_mut() {
slot.write(next as u8);
next += 1;
}
}
writer.flush().await.unwrap();
});For the read-then-write case, copy is itself zero-copy: rather than copying buffer to
buffer like the uppercase loop above, it moves the reader's buffer into the write
request, so the bytes pass from one kernel operation to the next untouched.
| Feature | Default | Description |
|---|---|---|
fs |
✅ | Async filesystem APIs (AsyncFile). |
net |
✅ | Async TCP/UDP/Unix socket APIs. |
time |
✅ | sleep, timeout, and timer futures. |
io |
✅ | Buffered I/O layer: IoBuffer, AsyncReader/AsyncWriter, Source/Sink, io::copy, and the data-carrying Request/Response variants. |
nightly |
Unstable std features (abort_unwind, tcp_linger, doc_cfg, Windows metadata APIs) and spawn_blocking_detached. |
|
debug-force-ioring / debug-no-ioring |
Windows-only knobs to force or disable the I/O-ring backend for testing. |
Build a subset with, e.g.:
cargo build --no-default-features --features fs,timeruntime—Runtimeconstruction and event-loop control (block_on,run_once,run_loop).task— task handles, spawning, the thread-localhandle(), and cooperative yielding.fs—AsyncFile(implementsSource/Sinkpositionally, plus syncstd::io::Seek).net—AsyncTcpListener/AsyncTcpStream, UDP, and Unix-domain sockets.time—sleep,timeout, and related futures.io— completion-basedRequest/Responseprotocol and the buffered reader/writer.sync— async channels:oneshot,mpsc,mpmc.threadpool—ThreadPoolforspawn_blocking(a global pool is the default).
- Rust 1.85 (pinned via
rust-toolchain.toml; some APIs are gated behind thenightlyfeature). - Linux with a kernel new enough for the
io_uringoperations used, or Windows.