Skip to content
Memory Management

Memory Management

zerg minimizes GC pressure by using unmanaged memory for all hot-path data: receive buffers, write slabs, and inflight buffers. This page describes the memory management strategy.

Unmanaged Allocations

All performance-critical buffers are allocated outside the managed heap:

ComponentSizeAlignmentLifetime
Buffer ring slabBufferRingEntries * RecvBufferSize per reactor64 bytesReactor lifetime
Write slab16 KB per connection (configurable)64 bytesConnection lifetime
Inflight bufferUser-defined (typically 16 KB) per handler64 bytesHandler lifetime

Why 64-Byte Alignment?

64 bytes is a common L1 cache line size on x86_64. Aligned allocations prevent false sharing and ensure optimal memory access patterns for DMA operations used by the kernel.

byte* ptr = (byte*)NativeMemory.AlignedAlloc((nuint)size, 64);
// ...
NativeMemory.AlignedFree(ptr);

UnmanagedMemoryManager

The UnmanagedMemoryManager class bridges unmanaged pointers and .NET’s managed memory model:

public sealed unsafe class UnmanagedMemoryManager : MemoryManager<byte>
{
    private byte* _ptr;
    private int _length;
    private ushort BufferId;
    private bool _freeable;

    public Span<byte> GetSpan() => new Span<byte>(_ptr, _length);
    public MemoryHandle Pin(int elementIndex = 0) => new MemoryHandle(_ptr + elementIndex);
    public void Unpin() { }  // no-op: already unmanaged
    public void Free() { if (_freeable) NativeMemory.AlignedFree(_ptr); }
}

This enables zero-copy interop between kernel buffers and .NET APIs that accept Memory<byte>, ReadOnlyMemory<byte>, or ReadOnlySequence<byte>.

Constructors

ConstructorFreeableUse Case
(byte* ptr, int length)YesOwned unmanaged allocations
(byte* ptr, int length, bool freeable)ConfigurableBorrowed pointers (e.g., buffer ring)
(byte* ptr, int length, ushort bufferId)YesRecv buffers with buffer ring ID
(byte* ptr, int length, ushort bufferId, bool freeable)ConfigurableFull control

For buffer ring receive data, freeable is typically false because the buffer belongs to the reactor’s slab and is returned, not freed.

Usage Pattern

// Wrap kernel buffer in managed view
var manager = new UnmanagedMemoryManager(ring.Ptr, ring.Length, ring.BufferId, freeable: false);
ReadOnlyMemory<byte> memory = manager.Memory;  // zero allocation

Buffer Ring Slab

Each reactor pre-allocates a contiguous slab for receive buffers:

┌────────────────────────────────────────────────────────────┐
│                    Buffer Ring Slab                          │
│  ┌──────────┬──────────┬──────────┬─────┬──────────┐       │
│  │ Buffer 0 │ Buffer 1 │ Buffer 2 │ ... │ Buffer N │       │
│  │ 32 KB    │ 32 KB    │ 32 KB    │     │ 32 KB    │       │
│  └──────────┴──────────┴──────────┴─────┴──────────┘       │
│  ^ slab                                                     │
│  ^ slab + 0 * bufSize                                       │
│  ^ slab + 1 * bufSize                                       │
│  ^ slab + 2 * bufSize                                       │
└────────────────────────────────────────────────────────────┘

Address formula: bufferPtr = slab + bufferId * RecvBufferSize

The kernel writes directly into these buffers via the buffer ring. When the handler is done, the buffer ID is returned and the same slot is reused for future receives.

Write Slab

Each connection owns a write slab (default 16 KB):

public Connection(int writeSlabSize = 1024 * 16)
{
    _writeSlabSize = writeSlabSize;
    _manager = new UnmanagedMemoryManager(
        (byte*)NativeMemory.AlignedAlloc((nuint)writeSlabSize, 64),
        writeSlabSize
    );
}

The slab lifecycle:

  Write(data)  →  Advance WriteTail  →  FlushAsync()  →  Reactor sends  →  Reset(Head=0, Tail=0)

The slab is never freed and reallocated during the connection’s lifetime – it’s allocated once and reused.

Disposal

public void Dispose()
{
    _manager.Free();    // NativeMemory.AlignedFree
    _manager.Dispose();
}

Connection Pooling

Connections can be pooled to avoid repeated unmanaged allocation. The reset methods prepare a connection for reuse:

MethodSpeedSafetyUse When
Clear()SlowerCancels pending waitersConnection may have outstanding async operations
Clear2()FasterNo waiter cancellationHandler has definitely exited

Both methods:

  • Increment _generation (invalidates stale ValueTask tokens)
  • Set _closed = 1
  • Clear the SPSC receive ring
  • Reset write buffer state

The generation counter is the key safety mechanism: any ValueTask created before the reset will observe a mismatched token and return Closed instead of accessing stale data.

ReadOnlySequence Construction

When received data spans multiple kernel buffers, UnmanagedMemoryManager instances are linked into a ReadOnlySequence<byte>:

public static ReadOnlySequence<byte> ToReadOnlySequence(this UnmanagedMemoryManager[] managers)

This creates a chain of RingSegment objects (custom ReadOnlySequenceSegment<byte> subclass) that link the managed views:

[Manager0.Memory] → [Manager1.Memory] → [Manager2.Memory]
      ↓                    ↓                    ↓
   Segment0 ──next──▶ Segment1 ──next──▶ Segment2

The resulting ReadOnlySequence<byte> can be parsed with SequenceReader<byte> for efficient multi-segment processing.

Memory Copy Helpers

The MemoryExtensions class provides optimized copy operations:

// Copy from native pointer to managed Memory
void CopyFrom(this Memory<byte> dst, byte* src, int len)

// Copy from RingItem to managed Memory
void CopyFromRing(this Memory<byte> dst, ref RingItem ring)

// Copy from array of RingItems to managed Memory
int CopyFromRings(this Memory<byte> dst, RingItem[] ring)

All use Buffer.MemoryCopy for efficient native-to-managed copying with bounds checking.

GC Pressure Analysis

OperationAllocationsNotes
ReadAsync()0ValueTask-based, no state machine allocation
FlushAsync()0ValueTask-based
Write(span)0Direct memcpy to unmanaged slab
GetSpan() + Advance()0Direct pointer arithmetic
TryGetRing()0Returns struct by value
ReturnRing()0Enqueues ushort to MPSC queue
ring.AsSpan()0Creates Span over existing pointer
GetAllSnapshotRingsAsUnmanagedMemory()1 arrayAllocates UnmanagedMemoryManager[]
ToReadOnlySequence()N segmentsAllocates RingSegment per buffer
ConnectionStream.ReadAsync()1 array + segmentsCopies data to managed buffer

For the lowest allocation rate, use TryGetRing() in a loop and process data via ring.AsSpan().