Overview
ioxide is an io_uring runtime for .NET. It runs one reactor per core, each owning its own ring and serving connections with no locks and no thread hops - your handler resumes right where the I/O completed, on the same thread. This page is the gentle introduction; the Advanced pages go inside the machine.
The idea in one minute
Classic async servers cross a syscall (and often a thread) for every read and write. io_uring changes the deal: you stage many operations in a shared submission queue, enter the kernel once, and collect a whole batch of completions. ioxide builds a thread-per-core server on top of that - and adds the part that makes it pleasant to write: when an operation completes, the awaiting handler continues inline, on the reactor thread, with nothing scheduled anywhere.
- One reactor per core. Each is a thread with its own ring, listener, buffers, and connection pool. Nothing is shared between them - no locks on the hot path.
- Inline resume.
await conn.ReadAsync()parks your handler; the moment the recv completes, the reactor calls back into your code directly. No thread pool, no synchronization context. - Zero-copy where it counts. Received bytes are parsed straight out of kernel-shared buffers; responses are staged in a per-connection slab and sent in one op.
- Zero native dependencies in the core - io_uring is driven through raw syscalls, no liburing.
The shape of a server
You set a config, spawn one reactor per core on its own thread, and give each a
Handle - the per-connection loop. That loop reads, does whatever work, writes,
and flushes; it can await a database, a cache, or a file, and resume inline.
var config = new ServerConfig { Port = 8080, ReactorCount = Environment.ProcessorCount };
var threads = new Thread[config.ReactorCount];
for (int i = 0; i < config.ReactorCount; i++)
{
var reactor = new Reactor(i, config);
reactor.Handle = async (r, conn) =>
{
try
{
while (true)
{
var snapshot = await conn.ReadAsync(); // io_uring recv, resumes inline
while (conn.TryGetItem(snapshot, out var item)) // drain the received slices
if (item.HasBuffer) conn.ReturnBuffer(in item);
conn.Write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"u8);
await conn.FlushAsync(); // io_uring send
if (snapshot.IsClosed) return;
conn.ResetRead();
}
}
finally { conn.DecRef(); }
};
threads[i] = new Thread(reactor.Run) { IsBackground = false };
threads[i].Start();
}
foreach (var t in threads) t.Join();
ioxide does not speak HTTP for you - you write the response bytes. That is deliberate: the runtime is the I/O engine, and what rides on it is yours. The landing page shows the same loop with real request parsing and a database call.
Plugging in backends
Anything your handler talks to - Postgres, Redis, files, an upstream service - is a client that rides the same ring, opened once per reactor and rented per request. The engine never names a client type, so adding one never touches the reactor:
ioxide.pg- pooled ring-native Postgres.ioxide.redis- full RESP2 Redis client.ioxide.file- static files with baked responses.ioxide.tls- kernel TLS, plus a portable SslStream path.- Your own - the same seam in under 100 lines.
Where to go next
For the real mechanics - the submit/wait loop, provided buffer rings, completion routing by generation, the connection lifecycle - start with Architecture, then The Reactor and The Connection. To run more than one entry point (say plaintext and TLS) from one server, see Multi-port.