All articles

C# Interview Guide: Span<T>, Memory<T>, and ArrayPool<T> — Zero-Allocation Patterns

3 min read
#0
0 reads
0 reading now
0 shares

Q20 — Span<T>, Memory<T>, and ArrayPool<T> — Zero-Allocation Patterns

Level: Senior | Topic: Performance Optimization

The Problem: Allocation Pressure

In high-throughput .NET code, excessive heap allocations cause GC pressure — frequent Gen 0 collections, occasional expensive Gen 2 collections, and pauses. Zero-allocation patterns use stack memory, reuse pooled buffers, and slice existing arrays without copying — all to reduce GC work.

The Types

  • Span<T>: A stack-only, zero-allocation view over contiguous memory (array, stack, or unmanaged). Cannot cross async boundaries. The fastest option for sync hot paths.
  • ReadOnlySpan<T>: Like Span but read-only. Ideal for parsing input without allocation.
  • Memory<T>: Heap-safe wrapper over a contiguous region. Can be stored in fields and passed across async boundaries. Slightly more overhead than Span.
  • ArrayPool<T>: A shared pool of reusable arrays. Rent before use, return when done — prevents LOH pressure from large allocations.
  • stackalloc: Allocates a fixed-size buffer on the stack — zero GC, but limited to small sizes (<1KB) and sync code.

Code Example

// Span<T> — parse CSV without string allocations
public static (string Name, decimal Price) ParseCsvRow(ReadOnlySpan<char> row)
{
    int comma = row.IndexOf(',');
    ReadOnlySpan<char> namePart  = row[..comma];         // zero-copy slice
    ReadOnlySpan<char> pricePart = row[(comma + 1)..];   // zero-copy slice

    if (!decimal.TryParse(pricePart, out var price))
        throw new FormatException("Invalid price");

    return (namePart.ToString(), price);  // only 1 allocation: the Name string
}

// ArrayPool — reuse large buffers, avoid LOH (≥85KB) allocations
public static async Task<int> ReadStreamChunkedAsync(Stream stream, CancellationToken ct)
{
    const int ChunkSize = 64 * 1024;  // 64KB
    byte[] buffer = ArrayPool<byte>.Shared.Rent(ChunkSize);
    int totalRead = 0;
    try
    {
        int read;
        while ((read = await stream.ReadAsync(buffer.AsMemory(0, ChunkSize), ct)) > 0)
        {
            ProcessChunk(buffer.AsSpan(0, read));  // zero-copy processing
            totalRead += read;
        }
        return totalRead;
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer, clearArray: true);  // always return!
    }
}

// Memory<T> — async-compatible buffer passing (Span can't cross await)
public static async Task WriteWithMemoryAsync(
    Stream destination, ReadOnlyMemory<byte> data, CancellationToken ct)
    => await destination.WriteAsync(data, ct);

// stackalloc — tiny fixed buffers, zero heap allocation
public static string ToHexString(ReadOnlySpan<byte> bytes)
{
    Span<char> chars = stackalloc char[bytes.Length * 2];  // stack only
    for (int i = 0; i < bytes.Length; i++)
        bytes[i].TryFormat(chars[(i * 2)..], out _, "x2");
    return new string(chars);  // single allocation at the end
}

Senior Insight

Use dotnet-counters monitor --counters System.Runtime[gen-0-gc-count,gen-1-gc-count,alloc-rate] to measure allocation rate before and after optimising. The most impactful changes are usually replacing string.Substring() with Span slicing, replacing per-request buffer allocations with ArrayPool, and using MemoryMarshal for unsafe but zero-copy struct serialisation. Never forget to Return() pooled arrays — memory leaks from ArrayPool are subtle and don't show up as standard GC pressure.

ShareLinkedInX

Comments

Share your thoughts without signing in.