SPSC Recv Ring
The SpscRecvRing is the per-connection inbound data queue. It’s a lock-free, single-producer single-consumer ring buffer optimized for the reactor-to-handler data path.
Design
public sealed class SpscRecvRing
{
private RingItem[] _items; // power-of-2 capacity array
private int _mask; // capacity - 1
private long _tail; // producer write position
private long _head; // consumer read position
}- Capacity: 1024 items (hardcoded in connection construction)
- Producer: Reactor thread (enqueues
RingItemon recv CQE) - Consumer: Handler task (drains items in snapshot batches)
Why SPSC?
Each connection is owned by exactly one reactor. The reactor is the sole producer, and the handler awaiting ReadAsync() is the sole consumer. This single-producer/single-consumer invariant means:
- No atomics needed on
_tailor_headfor their respective owners - Volatile reads/writes are sufficient for cross-thread visibility
- No CAS loops, no contention, no backoff
- Maximum throughput with minimal overhead
API
Producer (Reactor Thread)
public bool TryEnqueue(in RingItem item)Enqueues one item. Returns false if the ring is full.
- Volatile-reads
_headto check capacity (_tail - _head >= capacity→ full) - Stores item at
_items[_tail & _mask] - Volatile-writes
_tail = _tail + 1(release semantics: ensures item is visible before tail advances)
Consumer (Handler Thread)
public long SnapshotTail()Volatile-reads _tail to capture the current batch boundary. The handler can drain items up to this position without observing partially-written state.
public bool TryDequeueUntil(long tailSnapshot, out RingItem item)Dequeues one item, bounded by the snapshot. Returns false when _head >= tailSnapshot.
- Compares
_headagainsttailSnapshot - Reads
_items[_head & _mask] - Advances
_head(plain write – only consumer writes_head)
public RingItem DequeueSingle()Unconditional dequeue. Assumes the ring is not empty. Direct read and advance.
Inspection
public bool IsEmpty() // volatile reads head and tail
public long GetTailHeadDiff() // approximate count (tail - head)
public void Clear() // volatile reset both to 0Memory Ordering
The SPSC ring relies on two key ordering guarantees:
Producer Side (Release)
_items[_tail & _mask] = item; // store item
Volatile.Write(ref _tail, _tail + 1); // publish tail (release fence)The volatile write to _tail ensures that the item store is visible to the consumer before the tail advance is visible. The consumer will never see an advanced tail with a stale item.
Consumer Side (Acquire)
long tail = Volatile.Read(ref _tail); // acquire fence
var item = _items[_head & _mask]; // load itemThe volatile read of _tail ensures the consumer sees all stores made by the producer up to that tail position.
Single-Writer Optimization
Since only the consumer writes _head and only the producer writes _tail, these fields don’t need atomic operations. Plain reads from the owning thread and volatile reads from the other thread are sufficient.
Snapshot-Based Batching
The snapshot pattern prevents the consumer from chasing a moving tail:
// Handler side:
RingSnapshot result = await connection.ReadAsync();
long snapshot = result.TailSnapshot; // captured once
// Drain exactly what was available at ReadAsync time
while (connection.TryGetRing(snapshot, out RingItem ring))
{
// process ring...
connection.ReturnRing(ring.BufferId);
}
// Guaranteed to terminate: snapshot is fixed, head advances toward itThis is important because the reactor may enqueue more items while the handler is processing. Without a snapshot boundary, the handler could spin indefinitely.
Ring Full Behavior
When the SPSC ring is full (1024 items waiting to be consumed by the handler):
- The reactor’s
EnqueueRingItem()detects the ring is full - The connection is marked as closed (
_closed = 1) - If the handler is armed, it’s woken with a close signal
- If no handler is armed,
_pendingis set
This is a safety measure – a handler that falls behind and doesn’t drain its ring will eventually have its connection closed rather than consuming unbounded kernel buffers.
Performance Characteristics
| Operation | Cost | Allocation |
|---|---|---|
TryEnqueue | ~5 ns | None |
TryDequeueUntil | ~3 ns | None |
SnapshotTail | ~1 ns | None |
IsEmpty | ~2 ns | None |
All operations are [MethodImpl(MethodImplOptions.AggressiveInlining)] for JIT inlining.