Core internals

A class-by-class tour of the ioxide core - the reactor, the ring, the connection, and the plumbing that lets a normal async handler run with no thread pool underneath it. This is the class-level companion to the Architecture overview: every type, what it is, and how it works.

The model in one paragraph

One Reactor = one OS thread + one io_uring + one SO_REUSEPORT listener + one connection table. You run one reactor per core. The reactor thread is the sole issuer of submission-queue entries (SQEs) and the sole owner of every structure it touches, so nothing on the hot path takes a lock. Each await in your handler - a socket read, a Postgres query, a file read - submits an SQE and parks its continuation on a reusable IValueTaskSource; when the matching completion (CQE) arrives, the reactor calls SetResult and your code resumes inline on the same thread. Off-reactor threads never submit; they hand work to the reactor through lock-free queues woken by an eventfd.

Native · io_uring/Native.cs

All raw interop in one file: the three io_uring syscalls (io_uring_setup, io_uring_enter, io_uring_register) issued through syscall(), the libc socket/mmap/eventfd calls, the kernel struct layouts (IoUringSqe at a fixed 64-byte explicit layout, IoUringCqe, the SQ/CQ ring offsets, sockaddr_in, io_uring_buf_reg, __kernel_timespec), and the opcode/flag constants. There is no liburing - just these P/Invokes. Key flags it sets: SINGLE_ISSUER (one submitting thread, so the kernel skips SQ locking), DEFER_TASKRUN (completion work is deferred until enter(GETEVENTS), so the kernel batches and never interrupts the reactor mid-flight), and NO_SQARRAY (6.6+: the SQ index is implicit, dropping a store per submission). MSG_WAITALL on sends makes the kernel coalesce short sends into one CQE. The provided-buffer-ring flags (IORING_REGISTER_PBUF_RING, IOU_PBUF_RING_INC, CQE_F_BUF_MORE) drive the recv path.

Ring · io_uring/Ring.cs

A thin unsafe wrapper over the mmapped submission and completion queues. Create(entries) calls io_uring_setup (preferring NO_SQARRAY, falling back on EINVAL for older kernels), then mmaps the SQ ring, the CQ ring, and the SQE array, and caches pointers to the head/tail/mask fields the kernel shares.

Ring.cs · claim the next SQE slot, or return null when the ring is full

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public IoUringSqe* GetSqe()
{
    uint head = Volatile.Read(ref *_sqHead);
    if (_sqeTail - head >= _sqEntries)
    {
        return null;                 // full - the caller must flush and retry
    }

    uint slot = _sqeTail & _sqMask;
    if (_hasSqArray)
    {
        _sqArray[slot] = slot;       // skipped under NO_SQARRAY (6.6+)
    }
    _sqeTail++;
    return &_sqes[slot];
}

Reactor · Reactor/Reactor.cs (+ .Incremental, .RingHost, .Handler)

The reactor is one class split across four partial files. Reactor.cs holds the loop, dispatch, and SQE producers; Reactor.Incremental.cs the per-connection buffer-ring path; Reactor.RingHost.cs the IRingHost implementation for ring-native clients; Reactor.Handler.cs a deliberately non-unsafe part holding the one method that must await (you can't await inside an unsafe context).

user_data: the routing key

Every SQE carries a 64-bit user_data that comes back on its CQE, packed as [kind:8 | generation:16 | fd:32] (or, for client ops, the low 32 bits are a slot index). The generation is the crux of lifetime safety: a pooled Connection bumps its generation every time it is recycled, so a straggler CQE from a closed-and-reused fd carries a stale generation and is detected and dropped instead of corrupting the fd's new tenant. Kinds: accept, recv, send, wake, client, cancel, timer.

Reactor.cs · packing the routing key into user_data, and unpacking it on the CQE

private const int KindShift = 56;
private const int GenShift  = 32;

private static ulong Tag(byte kind, ushort gen, int fd)
    => ((ulong)kind << KindShift) | ((ulong)gen << GenShift) | (uint)fd;

// ...and on dispatch the CQE's user_data unpacks straight back out:
byte   kind = (byte)(cqe.user_data >> KindShift);
ushort gen  = (ushort)(cqe.user_data >> GenShift);
int    fd   = (int)(uint)cqe.user_data;

Startup - Run()

Runs on the reactor's own thread, in order: record the managed thread id (every fast-path "am I on the reactor?" check compares against it); create the Ring (DEFER_TASKRUN requires setup and enter on the same thread); open one SO_REUSEPORT listener per port (Tcp.Port + Tcp.ExtraPorts); register the shared buffer ring (or the incremental machinery); create the wake eventfd; run OnStart so the app opens its ring-native clients here (they ride this reactor's ring); arm the multishot accept, the wake poll, and the periodic timer; then fall into the loop.

The loop and dispatch

LoopShared / LoopIncremental drain the hand-off queues, then SubmitAndWait(1), then dispatch every ready CQE by kind:

Reactor.cs · the loop - drain the hand-offs, one syscall, then a batched dispatch

private void LoopShared()
{
    while (true)
    {
        DrainReturnQ();      // buffer returns from off-reactor handlers
        DrainFlushQ();       // flushes
        DrainRecycleQ();     // connection teardowns
        DrainRemoteOps();    // client-op submissions

        int rc = Ring.SubmitAndWait(1);   // submit everything staged, wait for >= 1 CQE
        if (rc < 0 && rc != -EINTR && rc != -EAGAIN && rc != -EBUSY)
        {
            Console.Error.WriteLine($"[r{Id}] io_uring_enter failed: {rc}");
            break;
        }

        uint ready = Ring.CqReady();      // read the CQ tail once
        for (uint i = 0; i < ready; i++)
        {
            Dispatch(in Ring.CqeAt(i));   // dispatching frequently runs handler code inline
        }
        Ring.CqAdvance(ready);            // publish the consumed head once
    }
}

The periodic timer and tickers

A single-shot IORING_OP_TIMEOUT (a native __kernel_timespec the reactor owns) fires every ~250 ms, re-armed each time. On fire, OnTimerTick runs every callback registered via AddTicker(Action) - on the reactor thread - then re-arms. This is how the pg/redis pools sweep per-command timeouts and replenish connections without a thread-per-command or any cross-thread timer: one timeout SQE per reactor, negligible cost, and the tickers see reactor-owned state directly.

Reactor.cs · a single-shot IORING_OP_TIMEOUT, re-armed after every tick

private void ArmTimer()
{
    IoUringSqe* sqe = GetSqeOrFlush();
    sqe->opcode    = IORING_OP_TIMEOUT;
    sqe->addr      = (ulong)_timerTs;     // a __kernel_timespec the reactor owns
    sqe->len       = 1;
    sqe->user_data = Tag(KindTimer, 0, 0);
}

private void OnTimerTick()
{
    for (int i = 0; i < _tickers.Count; i++)
    {
        _tickers[i]();                    // pg/redis pools sweep timeouts + replenish here
    }
    ArmTimer();                           // single-shot: re-arm for the next interval
}

SQE production and back-pressure

GetSqeOrFlush is the one place SQEs are produced, reactor-thread-only (SINGLE_ISSUER is a promise to the kernel). If the SQ is full mid-batch it flushes with a no-wait enter and retries in a bounded loop - submission never blocks on completions, and a transient full ring back-pressures instead of crashing.

Reactor.cs · GetSqeOrFlush - flush and retry when the SQ fills mid-batch

private IoUringSqe* GetSqeOrFlush()
{
    IoUringSqe* sqe = Ring.GetSqe();
    if (sqe != null)
    {
        return sqe;
    }

    for (int attempt = 0; attempt < 16 && sqe == null; attempt++)
    {
        Ring.SubmitAndWait(0);     // submit staged SQEs, don't wait - just make room
        sqe = Ring.GetSqe();
    }

    if (sqe == null)
    {
        throw new InvalidOperationException("io_uring SQ still full after repeated flush");
    }
    return sqe;
}

Off-reactor hand-off

Four queues carry work from other threads to the reactor, each woken by writing the eventfd (a multishot POLL_ADD turns that into a CQE): a bounded lock-free Mpsc<ushort> for buffer returns, an Mpsc<ulong> for flushes (packing (generation << 32) | fd), and ConcurrentQueues for connection recycles and client-op submissions (ref types). Every public entry point first checks CurrentManagedThreadId == reactorThreadId and takes a direct, queue-free fast path when already on the reactor.

Reactor.cs · every hand-off entry point fast-paths when it is already on the reactor thread

public void EnqueueReturnQ(ushort bid)
{
    if (Environment.CurrentManagedThreadId == _reactorThreadId)
    {
        ReturnBufferDirect(bid);     // already home - no queue, no wake
        return;
    }

    SpinWait sw = default;
    while (!_returnQ.TryEnqueue(bid))
    {
        sw.SpinOnce();
    }
    WakeFdWrite();                   // nudge the reactor's eventfd
}

Recycling

Recycle always runs on the reactor: MarkClosed wakes any awaiters, an ASYNC_CANCEL is submitted for the connection's multishot recv tagged with the pre-bump generation (so it matches the armed SQE), leftover buffers return (shared) or the per-connection ring unregisters (incremental), the fd closes, and Clear() bumps the generation - invalidating every stale token and queued reference at once - before the connection is pushed back to the pool (or freed if the pool is at PoolMax).

Reactor.cs / Connection.cs · cancel by the pre-bump generation, then bump it to void every stale token

// Reactor.Recycle - cancel the armed recv BEFORE the generation moves, so it matches the SQE:
SubmitCancel(Tag(KindRecv, (ushort)conn.Generation, fd));
// ...
conn.Clear();

// Connection.Clear:
internal void Clear()
{
    // Bump first: stale IVTS tokens now resolve to Closed(), stale CQEs fail the gen check.
    Interlocked.Increment(ref _generation);

    Volatile.Write(ref _armed, 0);
    Volatile.Write(ref _closed, 0);
    // ...reset the rest of the per-connection state
}

Connection · Connection/Connection.cs (+ .Read, .Write, .Incremental)

One connection is a sealed unsafe partial class that is at once an IValueTaskSource<RecvSnapshot> (the read side) and an IValueTaskSource + IBufferWriter<byte> (the write side). It owns a native write slab, an SpscRecvRing of received slices, and - in incremental mode - its own buffer ring.

SpscRecvRing · Utils/SpscRecvRing.cs

A single-producer/single-consumer ring of recv Items (pointer, buffer id, length, a HasBuffer flag, and the connection generation at enqueue time). The reactor produces (one recv CQE = one enqueue); the handler consumes. Power-of-two capacity, lock-free via a volatile tail/head. SnapshotTail lets a reader capture "everything received so far" and drain exactly up to it with TryDequeueUntil / CountUntil, which is what RecvSnapshot carries. Overflow (the handler falling too far behind) is reported to the reactor, which tears the connection down rather than dropping data silently.

Mpsc · Utils/Mpsc.cs

A bounded lock-free multi-producer/single-consumer queue - Vyukov's bounded MPMC specialized to one consumer. Power-of-two capacity, zero allocation after construction; each cell carries a sequence number that coordinates ownership between producers (CAS on the enqueue position) and the lone consumer (plain reads/writes). T is unmanaged so cells are blittable. The enqueue/dequeue positions are cache-line-padded (PaddedLong) to avoid false sharing. Used for the buffer-return and flush hand-off queues.

The ring-native client plumbing

How Postgres, Redis, and file I/O run on the same ring as the server.

RingOpSource.cs · synchronous continuations, and clearing the in-flight flag before SetResult

public sealed class RingOpSource : IRingCompletion, IValueTaskSource<int>
{
    private ManualResetValueTaskSourceCore<int> _core = new()
    {
        RunContinuationsAsynchronously = false   // resume the awaiter inline, on the reactor
    };

    private volatile bool _inFlight;

    public void Complete(int result)
    {
        _inFlight = false;        // cleared first: the inline continuation may Prepare() the next op
        _core.SetResult(result);
    }
}

Zero-copy adapters

BCL bridges

ConnectionStream.cs · the Stream write bridge - stage into the slab, then chain onto the connection's flush source (no async state machine, so an SslStream write still resumes inline)

public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
    Span<byte> destination = _conn.GetSpan(1);   // full slab when empty
    if (buffer.Length > destination.Length)
    {
        return WriteMultiAsync(buffer);          // record larger than the slab - rare
    }

    buffer.Span.CopyTo(destination);
    _conn.Advance(buffer.Length);

    ValueTask flush = _conn.FlushAsync();
    if (flush.IsCompletedSuccessfully)
    {
        return ValueTask.CompletedTask;
    }

    _writeCore.Reset();                              // park: chain this Stream's own IVTS
    _pendingFlush = flush.GetAwaiter();              // onto the connection's flush source
    _pendingFlush.UnsafeOnCompleted(_onFlushDone);
    return new ValueTask(this, _writeCore.Version);
}

One subtlety: a ConnectionStream is both an IValueTaskSource<int> (read) and an IValueTaskSource (write) at once - the returned value task's static type (ValueTask<int> vs ValueTask) routes each GetResult/OnCompleted to the right backing core, so read and write each park with zero allocation and no async state machine. The one cost the Stream contract forces is a copy into the caller's buffer - the raw read API and the pipe adapter expose ring memory directly; Stream cannot.

Configuration · ServerConfig.cs, IoxideRuntime.cs

ServerConfig is the record of tunables. At the root, the engine and its recv machinery: ReactorCount, RingEntries (SQ/CQ depth), DualStack, the shared-ring knobs (RecvBufferSize, BufferRingEntries) and the incremental-mode knobs (Incremental - which also selects the reactor loop variant - MaxConnections, ConnBufRingEntries, IncRecvBufferSize): io_uring provided-buffer rings are per-reactor machinery, not socket config. Under Tcp (a TcpOptions): Port + ExtraPorts, ListenBacklog, WriteSlabSize, PoolMax, RecvQueueEntries, WriteOverflow, ZeroCopySend. Under Udp (a UdpOptions): Ports, RecvSlots, Gro. And Quic holds the optional QuicOptions. RecvSnapshot is the readonly struct a read returns (tail + closed flag). IoxideRuntime is the version / wiring doc surface.