.NET & C# Development · Lesson 67 of 92

Zero-Allocation Code — Span<T>, ArrayPool & Object Pooling

How the .NET GC Works

The GC divides the managed heap into generations:

  • Gen 0 — short-lived objects (most allocations). Collected frequently, very fast.
  • Gen 1 — objects that survived Gen 0. Buffer between Gen 0 and Gen 2.
  • Gen 2 — long-lived objects (statics, caches). Collected infrequently, expensive.
  • LOH (Large Object Heap) — objects >= 85,000 bytes. Collected only with Gen 2. Never compacted by default.

The GC is generational because most objects die young. If you create lots of large, short-lived objects they land on the LOH, cause Gen 2 collections, and fragment the heap. That's expensive.

What Causes Allocations

String concatenation in loops:

C#
// Bad — creates a new string on every iteration
string result = "";
foreach (var item in items)
    result += item.Name + ", ";

LINQ chains: every Select, Where, and ToList allocates an enumerator and potentially a new collection.

Boxing: converting value types to object or interface types allocates on the heap.

C#
int x = 42;
object boxed = x;          // heap allocation
IComparable c = x;         // also boxing
var list = new List<object> { x }; // boxing on each add

Params arrays:

C#
void Log(string format, params object[] args) { ... }
Log("Value: {0}", 42); // allocates object[] and boxes 42

Span<T> and Memory<T>

Span<T> is a stack-allocated view over a contiguous region of memory — an array, a string, stack memory, or native memory. No heap allocation.

C#
// Slicing without allocating a new string
ReadOnlySpan<char> input = "Hello, World!".AsSpan();
ReadOnlySpan<char> hello = input.Slice(0, 5); // no allocation

// Parsing without substring
bool TryParseIntFast(ReadOnlySpan<char> input, out int result) =>
    int.TryParse(input, out result);

// Zero-copy CSV column read
void ProcessCsvLine(ReadOnlySpan<char> line)
{
    int comma = line.IndexOf(',');
    var name  = line.Slice(0, comma);
    var value = line.Slice(comma + 1);
    // work with name and value without allocating
}

Memory<T> is the async-compatible version of Span<T> — use it when you need to pass a slice across await boundaries.

C#
public async Task WriteChunkAsync(Memory<byte> buffer, CancellationToken ct)
{
    await stream.WriteAsync(buffer, ct); // no copy, no allocation
}

ArrayPool<T> to Avoid LOH Allocations

Don't allocate large temporary byte arrays — rent and return them.

C#
// Bad — 1MB array on the LOH, never compacted
byte[] buffer = new byte[1024 * 1024];

// Good — rent from the pool
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
try
{
    int read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct);
    Process(buffer.AsSpan(0, read));
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}

The pool reuses arrays, eliminating Gen 2 collections from large temporary allocations. clearArray: false skips zeroing for performance — only use true if the buffer held sensitive data.

string.Create for Hot-Path String Building

String interpolation allocates an intermediate DefaultInterpolatedStringHandler. For one-off strings it's fine. For hot paths:

C#
// Interpolation — readable but allocates
string id = $"{prefix}-{sequence:D6}";

// string.Create — zero-intermediate-allocation
string id = string.Create(prefix.Length + 7, (prefix, sequence), static (span, state) =>
{
    state.prefix.AsSpan().CopyTo(span);
    span[state.prefix.Length] = '-';
    state.sequence.TryFormat(span.Slice(state.prefix.Length + 1), out _, "D6");
});

For general-purpose string building in a loop, StringBuilder is still the right tool — it batches allocations internally.

IDisposable and Using Patterns

Objects wrapping unmanaged resources (file handles, DB connections, HTTP connections, streams) implement IDisposable. Not disposing them leaks the resource.

C#
// Always use using — compiler calls Dispose() even on exception
using var stream = new FileStream(path, FileMode.Open);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync();

// Explicit for scoped lifetimes
using (var scope = serviceScopeFactory.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.SaveChangesAsync();
} // scope.Dispose() called here, releasing db connection

Implement IDisposable yourself when your class holds disposable fields:

C#
public class ReportGenerator : IDisposable
{
    private readonly HttpClient _client;
    private bool _disposed;

    public ReportGenerator(HttpClient client) => _client = client;

    public void Dispose()
    {
        if (_disposed) return;
        _client.Dispose();
        _disposed = true;
        GC.SuppressFinalize(this);
    }
}

Detecting Memory Leaks

dotnet-counters — live process metrics:

Bash
dotnet-counters monitor --process-id <PID> System.Runtime

Watch gen-2-gc-count, loh-size, and working-set. Steady growth in gen-2-gc-count with rising working-set is the leak signature.

dotnet-dump — take a heap snapshot and inspect retained objects:

Bash
dotnet-dump collect --process-id <PID>
dotnet-dump analyze <dump-file>
# inside the REPL:
> dumpheap -stat           # top types by instance count
> dumpheap -type EventHandler  # look for event handler leaks
> gcroot <address>         # who's holding a reference?

Common Leak Patterns

Static event subscriptions:

C#
// AppDomain.CurrentDomain.UnhandledException never goes out of scope
// Every closure captured here lives forever
AppDomain.CurrentDomain.UnhandledException += (_, e) => Log(e);

// Fix: unsubscribe in Dispose
public void Dispose() => AppDomain.CurrentDomain.UnhandledException -= _handler;

Forgotten IDisposable from DI:

C#
// This creates a DbContext that never gets disposed
var db = serviceProvider.GetService<AppDbContext>();
// Fix: GetRequiredService inside a scope, then dispose the scope

Captured closures holding large objects:

C#
byte[] largeBuffer = new byte[10 * 1024 * 1024];
Timer timer = new Timer(_ => Process(largeBuffer), null, 0, 1000);
// largeBuffer is kept alive as long as the timer lives

Set largeBuffer = null after you're done if the timer outlives the buffer's usefulness, or restructure so the closure doesn't capture it.