ioxide.tls
0.2.6
See the version list below for details.
dotnet add package ioxide.tls --version 0.2.6
NuGet\Install-Package ioxide.tls -Version 0.2.6
<PackageReference Include="ioxide.tls" Version="0.2.6" />
<PackageVersion Include="ioxide.tls" Version="0.2.6" />
<PackageReference Include="ioxide.tls" />
paket add ioxide.tls --version 0.2.6
#r "nuget: ioxide.tls, 0.2.6"
#:package ioxide.tls@0.2.6
#addin nuget:?package=ioxide.tls&version=0.2.6
#tool nuget:?package=ioxide.tls&version=0.2.6
A shared-nothing io_uring runtime for .NET.
One ring per reactor thread - run one per core. Each reactor owns its ring, its SO_REUSEPORT listener, its connections and its clients outright: nothing is shared, so nothing is locked. HTTP, Postgres, Redis and file I/O all submit on the owning ring and resume inline on the same thread. No thread pool on the hot path. No native dependencies - raw syscalls, nothing else.
Linux 6.1+ · .NET 10 / .NET 11 · status
0.2.6- experimental
Documentation - architecture, guides, the full picture
Hello, ring
using ioxide;
using ioxide.utils;
var config = new ServerConfig
{
ReactorCount = Environment.ProcessorCount,
Tcp = new TcpOptions { Port = 8080 },
};
// One reactor per core. Every one binds :8080 via SO_REUSEPORT and owns its own ring.
for (int i = 0; i < config.ReactorCount; i++)
{
var reactor = new Reactor(i, config);
reactor.TcpHandle = async (r, conn) =>
{
try
{
while (true)
{
// io_uring recv. Resumes inline on this reactor thread - no thread pool hop.
RecvSnapshot snapshot = await conn.ReadAsync();
// ioxide hands you raw bytes; parsing is your code. Here we just drain,
// returning each buffer to the ring.
while (conn.TryGetItem(snapshot, out SpscRecvRing.Item item))
{
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();
if (snapshot.IsClosed) return;
conn.ResetRead();
}
}
finally
{
conn.DecRef();
}
};
new Thread(reactor.Run).Start();
}
dotnet add package ioxide
curl http://localhost:8080/
First-class async/await
Shared-nothing runtimes usually ask you to give up the platform's async model. ioxide keeps it:
handlers are ordinary async Task code, and await works everywhere.
- Ring completions resume their continuations inline on the reactor thread - an awaited recv, query or file read picks up exactly where it left off, with no thread pool hop.
- A per-reactor
SynchronizationContextcatches everything that would otherwise escape: timers,HttpClient,Task.Runresults - their continuations post back to the owning reactor. - Ring operations await through reusable, allocation-free awaitables.
You always wake up on your reactor. That is what makes shared-nothing practical in .NET: connection, pool and handler state stays single-threaded without a lock in sight.
Clients ride the same ring
Every client is opened from OnStart, which runs on the reactor thread - so the connections it
makes belong to that reactor's ring. Start registers the client as a reactor service; the handler
fetches it with GetService<T>(). There is one pool per reactor and no sharing between them.
Postgres
using ioxide.pg;
reactor.OnStart = r => PgPool.Start(r, new PgOptions
{
Host = "127.0.0.1", Port = 5432, User = "bench", Database = "bench",
PoolSize = 4, // per reactor
});
reactor.TcpHandle = async (r, conn) =>
{
PgPool pg = r.GetService<PgPool>();
// Submitted on this reactor's ring; the continuation resumes on this thread.
PgResult result = await pg.QueryAsync("SELECT 42");
// result.Value -> "42"
};
Redis
using ioxide.redis;
reactor.OnStart = r => RedisPool.Start(r, new RedisOptions
{
Host = "127.0.0.1", Port = 6379, PoolSize = 8,
});
reactor.TcpHandle = async (r, conn) =>
{
RedisPool redis = r.GetService<RedisPool>();
await redis.ExecuteAsync("SET", "user:1", "ada");
string? name = await redis.GetAsync("user:1"); // "ada"
// The generic command surface reaches anything RESP2 speaks.
RespValue hits = await redis.ExecuteAsync("INCR", "hits");
};
HTTP client
ioxide.httpclient is one client per origin over HTTP/1.1, HTTP/2 (h2c) or HTTP/3. Requests and
responses are the same types whichever protocol serves them, so the protocol is a configuration
decision, not an API one. Both hops of a proxy - inbound and outbound - stay on one reactor thread.
using ioxide.http11; // HttpClientRequest / HttpClientResponse live here
using ioxide.httpclient;
reactor.OnStart = r => RingHttpClient.Start(r, new RingHttpClientOptions
{
Host = "127.0.0.1", Port = 8081,
PoolSize = 8,
// Start on HTTP/1.1 and switch to HTTP/3 once the origin advertises it via Alt-Svc.
// Http1Only / Http2Only (h2c, prior knowledge) / Http3Only pin it instead.
Policy = HttpProtocolPolicy.Negotiate,
});
reactor.TcpHandle = async (r, conn) =>
{
RingHttpClient http = r.GetService<RingHttpClient>();
using HttpClientResponse response = await http.GetAsync("/api/thing");
int status = response.Status; // 200
ReadOnlyMemory<byte> body = response.Body; // bytes, not a string - decode at the edge
};
Static files
The asset cache opens every file once and shares the descriptors across reactors; small files are served from a pre-baked HTTP response with no I/O at all, larger ones stream off the ring. Every hit is revalidated against disk (size + mtime + inode), so an edit or an atomic rename is served live rather than stale.
using ioxide.file;
var assets = new StaticAssets("/srv/www", maxCachedFileBytes: 256 * 1024);
reactor.OnStart = r =>
{
r.AddService(assets);
AssetReader.CreatePool(r, readers: 4, bufferBytes: 1 << 20);
};
reactor.TcpHandle = async (r, conn) =>
{
StaticAssets snapshot = r.GetService<StaticAssets>();
// The lease pins the snapshot for the whole request, so a concurrent reload
// can't free the fd mid-send.
using StaticAssets.Lease lease = snapshot.Acquire();
if (lease.TryGet("/index.html", out AssetCache.Asset asset))
{
// asset.Response is the baked HTTP response; asset.Fd reads off the ring.
}
};
assets.Reload() swaps in a fresh snapshot atomically - the old descriptors close after a grace
period, so in-flight requests finish on the bytes they started with.
HTTP/3
ioxide.ngtcp2 bundles ngtcp2 + picotls as one self-contained native library (TLS 1.3 lives inside
the transport). ioxide.nghttp3 puts real HTTP/3 on top. Nghttp3Connection owns the read loop -
QPACK, control streams, fin and teardown are its problem - and calls your function once per request.
using ioxide.ngtcp2;
using ioxide.nghttp3;
var engine = new QuicEngine("cert.pem", "key.pem", cidLength: 8, alpn: ["h3"]);
var config = new ServerConfig
{
ReactorCount = Environment.ProcessorCount,
Udp = new UdpOptions { RecvSlots = 16 },
Quic = new QuicOptions
{
Port = 8443, // every reactor binds it via SO_REUSEPORT
LocalCidLength = 8,
ConnectionFactory = engine.CreateFactory(),
},
};
// The request is post-QPACK BYTES throughout - route by byte compare, decode only at the edge.
reactor.QuicHandle = (r, conn) =>
new Nghttp3Connection(conn).RunBufferedAsync(static req =>
req.Path.Span.SequenceEqual("/plaintext"u8)
? Nghttp3Response.Text("Hello, World!")
: Nghttp3Response.Text("not found\n", status: 404));
curl --http3-only -k https://localhost:8443/plaintext
For large or hostile uploads, RunStreamingAsync dispatches at end-of-headers and pulls the body
through req.BodyReader under flow-control pacing - memory is bound by one window, not the body
size. ioxide.http3 is the same surface implemented in pure C# (frames + QPACK + Huffman, no native
h3 code), engine-agnostic over any QUIC connection.
ASP.NET Core
Already have an app? ioxide.Kestrel swaps the transport underneath it - one ring per core, with
Kestrel's request loop pinned to the reactor thread. Your endpoints do not change.
using ioxide.Kestrel;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseIoxide(o => o.ReactorCount = 16);
var app = builder.Build();
app.MapGet("/", () => "hello from io_uring");
app.Run();
Packages
| Package | What it does |
|---|---|
ioxide |
The runtime: reactors, TCP/UDP transports, connections, the ring-native client seam. |
ioxide.http11 |
HTTP message types and the ring-native HTTP/1.1 client. No native dependencies. |
ioxide.nghttp2 |
HTTP/2 + HPACK (nghttp2), bundled native. Sans-I/O - bytes ride any ioxide connection. |
ioxide.ngtcp2 |
QUIC engine: ngtcp2 + picotls bundled native. Only system dependency is OpenSSL 3. |
ioxide.nghttp3 |
HTTP/3 + QPACK (nghttp3), bundled native. Rides any QUIC connection. |
ioxide.http3 |
Pure-C# HTTP/3: frames, QPACK, Huffman. Zero native code, drop-in for ioxide.nghttp3. |
ioxide.tls |
TLS. OpenSSL handshake over the ring, then kTLS - handlers keep writing plaintext. |
ioxide.httpclient |
One HTTP client per origin over 1.1 / h2c / h3, chosen per origin via Alt-Svc. |
ioxide.pg |
Postgres driver. A pool per reactor; connect, query and stream rows on the owning ring. |
ioxide.redis |
Redis client. RESP2, pipelining, pub/sub - pooled per reactor. |
ioxide.file |
Static assets. Immutable snapshots, baked responses, positional ring reads. |
ioxide.Kestrel |
ASP.NET Core transport: UseIoxide() and Kestrel runs one ring per core. |
Scope
ioxide hands you raw bytes and stays out of HTTP. Request parsing and response bytes are your
code; the runtime owns the ring, the connections and the clients. When you want a framework
on top, ioxide.Kestrel plugs the same engine under ASP.NET Core.
Try it
The Playground is one project per workload — Tcp.Raw, Pg, File, Proxy, two
HTTP/3 flavors, and the synthetic Tcp.Pipe/Tcp.Hop/Tcp.TaskRun variants. Each Program.cs is
a complete ioxide server: config, reactors, threads, connection loop and handler all in the one
file, so you can copy it out and run it.
PLAYGROUND_REACTORS=4 dotnet run -c Release --project Playground/Tcp/Raw
The Examples project builds every snippet above as runnable code, with benchmark results.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
NuGet packages (3)
Showing the top 3 NuGet packages that depend on ioxide.tls:
| Package | Downloads |
|---|---|
|
SimpleW.Engine.Ioxide
ioxide network engine integration for SimpleW. |
|
|
GenHTTP.Engine.Ioxide
Package Description |
|
|
Kestrel.Minima
ASP.NET Core Kestrel transport backed by the minima io_uring runtime: one reactor (ring) per core, SO_REUSEPORT load-balanced, with Kestrel's HTTP request loop pinned to the reactor thread. Drop-in via UseMinima(). |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.3.100 | 61 | 8/5/2026 |
| 0.2.6 | 91 | 8/4/2026 |
| 0.2.5 | 88 | 8/4/2026 |
| 0.2.4 | 109 | 8/3/2026 |
| 0.2.3 | 107 | 7/24/2026 |
| 0.2.2 | 102 | 7/23/2026 |
| 0.2.1 | 108 | 7/23/2026 |
| 0.1.1 | 318 | 7/20/2026 |
| 0.0.17 | 463 | 6/23/2026 |
| 0.0.16 | 117 | 6/21/2026 |
| 0.0.15 | 123 | 6/21/2026 |
| 0.0.14 | 111 | 6/21/2026 |
| 0.0.13 | 118 | 6/21/2026 |
| 0.0.12 | 172 | 6/21/2026 |
| 0.0.11 | 150 | 6/20/2026 |
| 0.0.10 | 118 | 6/20/2026 |
| 0.0.9 | 126 | 6/20/2026 |
| 0.0.8 | 149 | 6/19/2026 |
| 0.0.7 | 209 | 6/17/2026 |
| 0.0.6 | 129 | 6/15/2026 |