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
asyncboundaries. 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
asyncboundaries. 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.