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.
GetSqe()- claims the next SQE slot if the ring isn't full (tail - head < entries), writing the implicit SQ-array slot whenNO_SQARRAYisn't available. Returnsnullwhen full. Aggressively inlined.SubmitAndWait(waitFor)- publishes the SQ tail with a release store, thenio_uring_entersubmitting the staged SQEs and (whenwaitFor > 0) blocking for at least that many completions.CqReady()/CqeAt(i)/CqAdvance(n)- the batched CQ drain (the liburingfor_each_cqepattern): read the kernel-written tail once (acquire), process the whole batch, publish the consumed head once (release) - one barrier per batch, not per CQE.
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
}
}
- recv - pull the buffer id from
flags >> 16; generation-check the connection.res ≤ 0is EOF/error → teardown. A stale generation → return the buffer, drop the CQE. OtherwiseConnection.Completeenqueues the slice; if its recv queue overflows, cancel the multishot and tear down rather than zombify. Re-arm the multishot when the kernel ended it (F_MOREclear). - send - generation-checked; advance
WriteHead; a short send (rare under MSG_WAITALL) resubmits the remainder; a full ack runsCompleteFlush, resuming the handler inline. Error → cancel recv + tear down. - accept - pop a pooled connection (or build one), set
TCP_NODELAY, register it, init its refcount to 2, arm its multishot recv stamped with its generation, and start the handler viaRunHandlerAsync. Incremental mode also registers the per-connection buffer ring here. - client - free the op slot before invoking the completion, so the inline continuation can submit its next op into the same slot without aliasing.
- wake - drain the eventfd counter (the queues drain at the top of the next iteration); re-arm the multishot poll if needed.
- timer - run the registered tickers, then re-arm. See below.
- cancel - the ack of an ASYNC_CANCEL the reactor issued; nothing to route.
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.
- Two-owner refcount -
_refsstarts at 2 on accept (the reactor's recv side and the handler). Teardown runs only when it hits 0 viaDecRef, so a connection is never recycled under a live handler. - Generation as the IVTS token - the value-task token handed to an awaiter is the
connection's generation snapshot. After a pool recycle bumps the generation,
GetResult/GetStatus/OnCompletedsee the token mismatch and resolve the stale awaiter toClosed()instead of a torn result. - Lost-wakeup-free read -
ReadAsyncuses an_armedflag plus a sticky_pending, with a re-check between arming and returning the task, so a completion that races the arm is never lost. Continuations are synchronous (RunContinuationsAsynchronously = false) - that's what makes resume inline. - Read snapshot -
ReadAsyncreturns aRecvSnapshot(a tail marker + closed flag). The handler then drains slices up to that tail withTryGetItem/GetSnapshotMemories, and returns their buffers withReturnBuffers- which routes to the shared ring or the per-connection ring by mode. - Write side -
Write/GetSpan/Advancestage into the fixed write slab;FlushAsyncarms the flush IVTS and asks the reactor to submit the send.CompleteFlush(from the send CQE) resets the slab and resumes the handler. A_flushInProgressguard rejects writes mid-flush. - SendOpFlags - per-connection send flags, defaulting to
MSG_WAITALL;ioxide.tlsclears it after the kTLS handoff (kTLS rejects MSG_WAITALL).
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.
- IRingHost (
IRingHost.cs) - the contract a reactor implements so clients can submit on it:SubmitConnect/Send/Recv/Read/Write(fd, nint buffer, ..., completion). Buffers cross asnintbecause raw pointers can't survive anawait. Routing is per-operation (a slot inuser_data), so concurrent ops on one fd never collide. Safe from any thread - off-reactor callers are marshalled. IRingCompletion is the one-method callback the reactor invokes on the CQE. - RingOpSource (
Client/RingOpSource.cs) - the awaitable half of a single ring op, reusable across ops and zero-allocation:Prepare()arms it (throwing if one is already in flight), you hand it to a submit as the completion, and you await the returnedValueTask<int>. Continuations are synchronous, so the awaiter resumes inline on the reactor. It clears its in-flight flag beforeSetResultso the inline continuation can immediatelyPrepare()the next op. - RingSocket (
Client/RingSocket.cs) - a client TCP socket whose connect, sends, and receives all run on the host's ring. It holds twoRingOpSources (_tx,_rx) so one send and one recv can be in flight at once. Connect takes an IPv4 literal (DNS would block the reactor - resolve up front). The building block for every ring-native protocol client. - RingPool<T> (
Client/RingPool.cs) - a tiny rent/return pool of ring-native resources for one reactor (e.g. file readers). Waiter continuations run synchronously on the returning thread, so an inline return resumes the next renter inline - the no-thread-pool property holds even under contention.
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
- UnmanagedMemoryManager (
Utils/UnmanagedMemoryManager.cs) - aMemoryManager<byte>over a native pointer, so a ring buffer can be exposed asMemory<byte>/ReadOnlySequence<byte>with no copy. Carries the buffer id and generation for safe return;Resetre-points a pooled manager so the pipe reader reuses one instance per held slice instead of allocating per recv. - RingSegment + RingMemoryExtensions (
Utils/) - aReadOnlySequenceSegment<byte>andToReadOnlySequence()that stitch several recv buffers into one logicalReadOnlySequence, so a parser sees a fragmented request whole - still pointing straight at the ring buffers (parse before you return them).
BCL bridges
- TcpConnectionPipeReader (
Connection/Tcp/TcpConnectionPipeReader.cs) - adapts the raw read API toSystem.IO.Pipelines.PipeReader, allocation-free at steady state: a parked read chains onto the connection's value-task source (no async state machine), recv slices live in pooled segments on a persistent chain, andAdvanceTotrims the consumed front and returns those buffers. It honorsexamined- when everything held has been examined, it waits for new bytes instead of returning the same data. - TcpConnectionPipeWriter / TcpConnectionStream / TcpConnectionDualPipe
(
Connection/Tcp/) - aPipeWriterover the write slab, aStream(soSslStreamand other BCL code can ride a connection - see TLS internals), and a duplex pipe pairing.
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.