Skip to content

Spans, memory & allocation

How Span<T>, Memory<T>, ref structs and stackalloc let you work with contiguous data without allocating, and where each one cannot be used.

Span<T> is a type that represents a contiguous region of arbitrary memory - an array, a slice of an array, a string, stack-allocated memory, or native memory - without copying it. It is the modern answer to "pass a pointer and a length" and underpins most of the performance work in .NET since C# 7.2.

The catch is that a Span<T> is a ref struct C# 7.2, which means it lives only on the stack. Understanding why that restriction exists, and when Memory<T> is the escape hatch, is most of the learning curve.

Why Span exists ​

Before spans, a method that wanted to work on part of an array had three choices: take an offset and length as separate parameters, copy the slice into a new array, or use unsafe pointers.

csharp
// Before: offset and length, easy to get wrong
static int Sum(int[] values, int start, int count)
{
    var total = 0;
    for (var i = start; i < start + count; i++)
        total += values[i];
    return total;
}

// With a span: the slice is the parameter
static int Sum(ReadOnlySpan<int> values)
{
    var total = 0;
    foreach (var value in values) total += value;
    return total;
}

The span version cannot get the bounds wrong because the slice carries its own length, and it allocates nothing.

One type, many sources ​

The same ReadOnlySpan<char> can come from a string, an array, stack memory, or native memory:

csharp
var text = "hello world";

ReadOnlySpan<char> fromString = text.AsSpan(6);        // "world"
ReadOnlySpan<char> fromLiteral = "world";               // implicit conversion

char[] array = { 'w', 'o', 'r', 'l', 'd' };
ReadOnlySpan<char> fromArray = array;

Span<byte> fromStack = stackalloc byte[8];

This is why library APIs increasingly accept spans: one signature serves every caller without forcing an allocation.

Slicing is a view, not a copy ​

Ranges on a span return another span over the same memory. Writing through the slice writes through to the original:

csharp
var numbers = new[] { 1, 2, 3, 4, 5 };
Span<int> slice = numbers.AsSpan()[1..4]; // elements 1, 2, 3

slice[0] = 99;
// numbers is now 1, 99, 3, 4, 5

Ranges and indices arrived with ranges C# 8.0 and work on any type with a Slice method, which includes Span<T> and ReadOnlySpan<T>.

The ref struct restriction ​

A Span<T> holds a managed pointer to memory that may be on the stack. If it could be stored on the heap, the stack frame it points into could be gone while the span still referred to it. The compiler prevents that by making Span<T> a ref struct, which cannot:

  • Be a field of a class (it can be a field of another ref struct)
  • Be boxed to object or to an interface
  • Be captured by a lambda or local function closure
  • Survive an await or yield boundary

That last restriction is the one that bites in practice:

csharp
async Task<int> CountAsync(int[] data)
{
    Span<int> span = data;
    await Task.Yield();
    return span.Length; // error CS4007: cannot be preserved across 'await'
}

The fix is to re-derive the span after the await, or to use Memory<T>.

Memory<T> as the escape hatch ​

Memory<T> is a heap-safe counterpart to Span<T>. It can be stored in a field, captured, and survive an await; you call .Span to get a span for the duration of a synchronous operation.

csharp
public sealed class Buffer
{
    private readonly Memory<byte> _memory;   // can be a field

    public Buffer(Memory<byte> memory) => _memory = memory;

    public async Task ProcessAsync()
    {
        await Task.Yield();
        Process(_memory.Span);               // span created after the await
    }

    private static void Process(ReadOnlySpan<byte> data) { }
}

Use Memory<T> when the data must outlive a method or cross an await, and Span<T> for the actual work. Every Memory<T> can produce a Span<T>, but not the other way round.

stackalloc and inline arrays ​

stackalloc allocates a block on the stack, which is ideal for small, short-lived buffers:

csharp
Span<byte> buffer = stackalloc byte[256];

Stack space is limited (typically around 1MB per thread), so this is only appropriate for small buffers. A stackalloc in a loop allocates once at method entry, not per iteration. Since C# 8 it can be used as an argument or inside a conditional - see stackalloc in nested expressions C# 8.0.

For a fixed-size buffer that needs to be a field of a struct rather than a local, inline arrays C# 12.0 give the same embedded storage without unsafe.

Pooling instead of allocating ​

When a buffer is too large for the stack but still short-lived, rent it from ArrayPool<T> and return it in a finally. The pool amortises the allocation across calls.

csharp
const int Size = 4096;
var rented = ArrayPool<byte>.Shared.Rent(Size);
try
{
    var buffer = rented.AsMemory(0, Size);   // always re-slice to what you asked for
    var read = await stream.ReadAsync(buffer);
    Process(buffer.Span[..read]);
}
finally
{
    ArrayPool<byte>.Shared.Return(rented);
}

Note that Rent may return a larger array than requested, so always re-slice to the size you asked for. Forgetting the Return, or returning an array you still hold a span over, is the usual source of bugs here.

Practical tips ​

  • Accept ReadOnlySpan<T> in APIs, not T[]. The caller can still pass an array, and every other source becomes possible without a copy.
  • Use ReadOnlySpan<char> for parsing. String slicing allocates; span slicing does not. Combined with pattern match on spans C# 11.0 and UTF-8 literals C# 11.0, a parser can run allocation-free.
  • Remember that spans do not own memory. A Span<T> over a rented buffer is invalid the moment the buffer is returned, and the compiler will not catch that.
  • Prefer Memory<T> at API boundaries that cross an await, and convert to a span for the synchronous work.
  • Do not use spans to hide a copy. If the data is being materialised anyway, a plain T[] or IReadOnlyList<T> is simpler and the span gives nothing back.

More information ​

Powered by Intracia