Cross-platform zero-copy async socket I/O for .NET 10+. IORingGroup abstracts io_uring, Windows Registered I/O (RIO), and kqueue behind a unified submission queue / completion queue interface, enabling high-throughput networking with minimal allocations and no async/await overhead.
| Platform | Backend | Mechanism |
|---|---|---|
| Windows | WindowsManagedRIOGroup |
Registered I/O, pure C# |
| Linux | LinuxIORingGroup |
io_uring via direct syscalls |
| macOS / FreeBSD | DarwinIORingGroup |
kqueue (readiness-based, bridged to completion model) |
<PackageReference Include="IORingGroup" Version="1.0.3" />Or via the CLI:
dotnet add package IORingGroupUse IIORingGroup directly for maximum control. This example shows a minimal single-threaded echo server:
using System.Network;
// Create the platform-appropriate ring
using var ring = IORingGroup.Create(queueSize: 4096, maxConnections: 1024);
// Create a buffer pool for zero-copy I/O
using var bufferPool = new IORingBufferPool(
ring, slabSize: 256, bufferSize: 4096, initialSlabs: 4, maxSlabs: 64
);
// Start listening
var listener = ring.CreateListener("0.0.0.0", 5000, backlog: 128);
// Queue initial accept
ring.PrepareAccept(listener, 0, 0, userData: OpAccept);
ring.Submit();
// Event loop
Span<Completion> completions = stackalloc Completion[256];
while (running)
{
ring.Submit();
int count = ring.PeekCompletions(completions);
for (int i = 0; i < count; i++)
{
ref var cqe = ref completions[i];
// Decode operation type from userData and dispatch
switch (GetOpType(cqe.UserData))
{
case OpAccept:
nint clientHandle = (nint)cqe.Result;
ring.ConfigureSocket(clientHandle);
int connId = ring.RegisterSocket(clientHandle);
// Acquire a buffer and post recv
bufferPool.TryAcquire(out var buffer);
ring.PrepareRecvBuffer(connId, buffer.BufferId,
buffer.WriteOffset, buffer.WritableBytes, userData: OpRecv);
// Re-arm accept
ring.PrepareAccept(listener, 0, 0, userData: OpAccept);
break;
case OpRecv:
buffer.CommitWrite(cqe.Result);
ring.PrepareSendBuffer(connId, buffer.BufferId,
buffer.ReadOffset, buffer.ReadableBytes, userData: OpSend);
break;
case OpSend:
buffer.CommitRead(cqe.Result);
// Post next recv...
break;
}
}
ring.AdvanceCompletionQueue(count);
}
ring.CloseListener(listener);RingSocketManager handles buffer lifecycle, generation tracking, graceful disconnect, and batched sends:
using System.Network;
using var ring = IORingGroup.Create(maxConnections: 4096);
using var manager = new RingSocketManager(ring, maxSockets: 4096);
// Set up listener
var listener = ring.CreateListener("0.0.0.0", 5000, backlog: 128);
ring.PrepareAccept(listener, 0, 0, userData: 0);
Span<RingSocketEvent> events = stackalloc RingSocketEvent[4096];
while (running)
{
int eventCount = manager.ProcessCompletions(events);
for (int i = 0; i < eventCount; i++)
{
switch (events[i].Type)
{
case RingSocketEventType.Accept:
var socket = manager.CreateSocket(events[i].AcceptedSocketHandle);
// Store app state: appState[socket.Id] = new MyState(socket);
ring.PrepareAccept(listener, 0, 0, userData: 0);
break;
case RingSocketEventType.DataReceived:
var s = events[i].Socket;
// Echo: copy recv data to send buffer
var data = s.RecvBuffer.GetReadSpan()[..events[i].BytesTransferred];
data.CopyTo(s.SendBuffer.GetWriteSpan());
s.SendBuffer.CommitWrite(data.Length);
s.RecvBuffer.CommitRead(data.Length);
s.QueueSend(); // Flush-and-forget
break;
case RingSocketEventType.DataSent:
break; // Nothing to do — flush-and-forget
case RingSocketEventType.Disconnected:
// Clean up: appState[events[i].Socket.Id] = null;
break;
}
}
manager.Submit();
}Bursty sockets can outgrow the base send buffer without paying that cost for every idle connection. RingSocketManager supports optional growth through power-of-two tiers above the base sendBufferSize, bounded by a byte budget shared across all sockets:
using var ring = IORingGroup.Create(
maxConnections: 4096,
maxRegisteredBuffers: RingSocketManager.RequiredRegisteredBuffers(
maxSockets: 4096,
sendBufferSize: 256 * 1024,
maxSendBufferSize: 4 * 1024 * 1024,
sendBufferGrowthBudget: 512 * 1024 * 1024
)
);
using var manager = new RingSocketManager(
ring,
maxSockets: 4096,
maxSendBufferSize: 4 * 1024 * 1024, // largest a socket may grow to (0 disables growth)
sendBufferGrowthBudget: 512 * 1024 * 1024 // bytes of tier-pool capacity shared across all sockets
);maxConnections is not optional here: it defaults to 1024, and a ring built for 1024 connections cannot carry a manager built for 4096.
maxSendBufferSizeis the ceiling a socket can grow to; 0 (the default) means growth is disabled. It must be a power of two, no smaller thansendBufferSize, and no larger than 256 MiB.sendBufferGrowthBudgetcaps how many bytes the tier pools may hold in total; a positive value belowRingSocketManager.MinimumSendBufferGrowthBudget(sendBufferSize)throws, since tier buffers are only ever handed out a slab at a time.RequiredRegisteredBuffers(...)computes the registration table size these settings need — pass it asIORingGroup.Create(maxRegisteredBuffers:)so the ring and the manager can't drift out of sync. The manager cross-checks the two in its constructor and throws when the ring's table is too small, so a mismatch surfaces at startup instead of at an accept or a growth. A Windows RIO ring registers at most 65,535 buffers, since a posted operation identifies its buffer with a 16-bit index — roughly 32k sockets at one recv and one send buffer each — andCreatethrows above that rather than let an id narrow into another socket's buffer.- Call
manager.Maintain()about once a minute from the ring thread. It rotates every pool's usage window, trims at most one idle slab per pool down to the recent peak, and returns aSendBufferMaintenancesnapshot (buffers released, growth refusals, tier capacity/usage, plusBaseBuffersReleasedandBaseCapacityBytesfor the two base pools). ItsTierInUseandTierRetainFloorare buffer counts summed across tiers of different sizes; usemanager.GetSendBufferTierStats(tier)when you need one tier's real numbers.
Tier buffers are allocated a slab at a time, and a slab of tier size S holds max(4, min(16, 8 MiB / S)) buffers — so a slab costs 8 MiB up to the point where the floor of 4 buffers takes over, and more above it. Clearing MinimumSendBufferGrowthBudget only guarantees the first tier is reachable. With the 256 KiB base and 4 MiB ceiling above:
| Tier size | Buffers per slab | Slab cost |
|---|---|---|
| 512 KiB | 16 | 8 MiB |
| 1 MiB | 8 | 8 MiB |
| 2 MiB | 4 | 8 MiB |
| 4 MiB | 4 | 16 MiB |
A growth is refused when the tier has no free buffer and its next slab would not fit in what the budget has left, so the budget must cover at least one slab of a tier for that tier to be usable at all — and in practice several, since the lower tiers allocate first and hold their capacity until Maintain() trims them. SendBufferMaintenance.GrowthRefusals is how you find out the budget is set too low.
Worst-case tier memory is exactly sendBufferGrowthBudget. Both base pools are bounded by maxSockets, since a socket holds exactly one buffer from each: with slabSize = RingSocketManager.BasePoolSlabSize(maxSockets, maxBufferSlabs) — max(16, maxSockets / maxBufferSlabs) — each pool tops out at maxSockets rounded up to a whole slab, RingSocketManager.BasePoolSlabCount(maxSockets, maxBufferSlabs) of them. maxBufferSlabs sets the slab size, not a ceiling on connections, so every socket slot is usable.
IORingGroup.Create(maxConnections: n) with maxRegisteredBuffers: 0 sizes its table from that same rule — RingSocketManager.RequiredRegisteredBuffers(n), the no-growth overload — so Create(maxConnections: n) paired with new RingSocketManager(ring, n) always composes. That is a little more than n × 2 whenever the slab does not divide n (1000 sockets need 2016). Pass the full RequiredRegisteredBuffers(maxSockets, sendBufferSize, maxSendBufferSize, sendBufferGrowthBudget, maxBufferSlabs, initialRecvBufferSize, initialSendBufferSize) when the manager enables growth, uses a non-default maxBufferSlabs, or turns on the initial pools below.
Each base pool starts at initialBufferSlabs slabs (default 1), adds a slab when the live one runs out, and gives a fully idle top slab back on a Maintain() call whose retention floor has decayed below the remaining capacity — never below initialBufferSlabs. At maxSockets: 4096, maxBufferSlabs: 128, a 64 KiB recv buffer and a 256 KiB send buffer, that is a 32-buffer slab per pool: 2 MiB + 8 MiB resident at boot, against the 96 MiB the previous defaults allocated up front and the 1.25 GiB the full set of base buffers costs at 4096 connections.
Idle capacity comes back one slab per Maintain() call, and only from the top. Buffers are handed out from the lowest slab that has one free, so ordinary churn drains the newest slabs first and those are the ones that go; a long-lived connection holding a buffer in the top slab pins every slab beneath it until it disconnects. Expect the decay after sendBufferRetentionWindows quiet windows when the newest slabs are free, not as a guarantee.
initialRecvBufferSize and initialSendBufferSize (default 0, off) give every new socket a small
buffer from a third and fourth pool instead of a base one. The consumer calls
TryPromoteSendBuffer(socket) and TryPromoteRecvBuffer(socket) when the connection has earned a
full-size buffer — after credentials verify, for a game server — and the socket moves to the base
pools. Nothing demotes. The point is resource exhaustion: an unauthenticated flood can fill the
initial pools (at maxSockets: 4096 and 4 KiB buffers, 32 MiB) but never touches the base pools.
Send promotion is the growth swap without the budget: queued bytes copy across, bytes in flight
retire with the old buffer, and TryGrowSendBuffer promotes first if the consumer never asked.
That promote-first path is the consumer's own way into the base pool — gate it on the consumer's
notion of a verified peer (ModernUO refuses it until credentials verify) or the guarantee above is
only as strong as the consumer's send path.
Recv promotion is applied at the next recv completion, because a recv is armed against the current
buffer almost always; the swap happens before that completion's DataReceived event, every
readable byte moves across, and the next recv arms on the new buffer. Read socket.RecvBuffer per
event rather than caching it.
The smallest usable size is IORingBuffer.MinimumSize: the page size, except on the Windows legacy
(pre-1803) path, where it is the 64 KiB allocation granularity. Both initial pools use the base slab
rule and count in RequiredRegisteredBuffers(...) — pass the initial sizes there too.
A completion that fills a buffer arms no receive; a consumer that reads from RecvBuffer outside a
completion calls socket.ResumeReceive() once it has freed space — at 4 KiB a burst of small
packets can fill the buffer in one completion.
IORingGroup is designed for single-threaded event loops. The ring, the manager, and all socket operations must be called from the same thread:
ProcessCompletions(),Submit(),CreateSocket(),DisconnectImmediate()RingSocket.QueueSend(),RingSocket.Disconnect()
There is no cross-thread synchronization — this is by design. Single-threaded access eliminates lock contention and enables zero-allocation hot paths. The internal send and disconnect queues are plain Queue<T>, not ConcurrentQueue<T>.
If you need multi-threaded I/O, run multiple rings on separate threads with separate socket sets.
A double-mapped circular buffer: the same physical memory is mapped twice contiguously in virtual address space. This eliminates wrap-around copies — a read or write that crosses the end of the buffer seamlessly continues at the beginning via the second mapping.
GetReadSpan()/GetWriteSpan()— contiguous spans, even across the boundaryCommitRead(n)/CommitWrite(n)— advance head/tail pointers- Platform-specific allocation:
VirtualAlloc2(Windows),memfd_create(Linux),shm_open(macOS)
Multi-slab pool with on-demand allocation. Buffers are pre-registered with the ring for zero-copy I/O:
var pool = new IORingBufferPool(
ring,
slabSize: 256, // Buffers per slab
bufferSize: 4096, // Bytes per buffer
initialSlabs: 4, // Pre-allocate 1024 buffers
maxSlabs: 64 // Grow up to 16K buffers on demand
);
pool.TryAcquire(out var buffer); // O(1) allocation
pool.Release(buffer); // O(1) return to poolRun the echo server and client for performance testing:
# IORing server (default — uses RIO on Windows, io_uring on Linux, kqueue on macOS)
dotnet run --project TestServer -c Release -- --ioring --benchmark --duration 10
# PollGroup server (cross-platform baseline)
dotnet run --project TestServer -c Release -- --pollgroup --benchmark --duration 10
# Client (connect and blast echo traffic)
dotnet run --project TestClient -c Release -- --host 127.0.0.1 --port 5000