Skip to content

Repository files navigation

IORingGroup

NuGet License: BSD-3-Clause .NET 10+

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 Backends

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)

Installation

<PackageReference Include="IORingGroup" Version="1.0.3" />

Or via the CLI:

dotnet add package IORingGroup

Quick Start: Low-Level API

Use 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);

Quick Start: High-Level API

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();
}

Send buffer growth

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.

  • maxSendBufferSize is the ceiling a socket can grow to; 0 (the default) means growth is disabled. It must be a power of two, no smaller than sendBufferSize, and no larger than 256 MiB.
  • sendBufferGrowthBudget caps how many bytes the tier pools may hold in total; a positive value below RingSocketManager.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 as IORingGroup.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 — and Create throws 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 a SendBufferMaintenance snapshot (buffers released, growth refusals, tier capacity/usage, plus BaseBuffersReleased and BaseCapacityBytes for the two base pools). Its TierInUse and TierRetainFloor are buffer counts summed across tiers of different sizes; use manager.GetSendBufferTierStats(tier) when you need one tier's real numbers.

Budget per tier, not just in total

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.

What bounds memory, and what bounds connections

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.

Base pools grow and shrink with the population

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.

Initial pools: small buffers until the consumer promotes

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.

Threading Model

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.

Buffer System

IORingBuffer

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 boundary
  • CommitRead(n) / CommitWrite(n) — advance head/tail pointers
  • Platform-specific allocation: VirtualAlloc2 (Windows), memfd_create (Linux), shm_open (macOS)

IORingBufferPool

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 pool

Benchmarking

Run 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

License

BSD-3-Clause

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages