Span, Memory and Advanced Memory Management

A Span<T> is a view over contiguous memory — an array, a slice of an array, stackalloc memory, native memory or a string (ReadOnlySpan<char>).

Concept

Span<T> and ReadOnlySpan<T> A Span<T> is a view over contiguous memory — an array, a slice of an array, stackalloc memory, native memory or a string (ReadOnlySpan<char>). It is just a reference plus a length, so slicing is free: no copying, no allocation.

Span<T> is a ref struct: it may live only on the stack. Therefore it:

cannot be a field of a class or a normal struct (CS8345), cannot be boxed, captured by lambdas, or used as a generic type argument (except where allows ref struct permits), cannot be preserved across await or yield (CS4007).

These rules guarantee that a span never outlives the memory it points to (for example a stackalloc buffer).

Memory<T> and ReadOnlyMemory<T> Memory<T> is the heap-storable counterpart: it can be a field, be used in async methods, and be stored in collections. Call .Span to get a span for the actual work.

Span<T> / ReadOnlySpan<T> No (not across await) Only in ref struct Memory<T> / ReadOnlyMemory<T>

stackalloc for small, short-lived buffers (keep them small — the stack is ~1 MB).

Span-based APIs: int.Parse(ReadOnlySpan<char>), BinaryPrimitives.ReadUInt32BigEndian, Encoding.GetBytes(string, Span<byte>), MemoryExtensions (IndexOf, SequenceEqual, Split), TryFormat.

ArrayPool<T>.Shared: rent and return large buffers instead of allocating them (always Return in finally; the rented array can be larger than requested and may contain old data).

MemoryPool<T>: rent IMemoryOwner<T> blocks (disposable).

Object pooling for expensive objects (ObjectPool<T> in Microsoft.Extensions.ObjectPool, or a simple custom pool).

SIMD and Hardware-Aware Optimization SIMD (single instruction, multiple data) processes several values per CPU instruction (SSE/AVX on x64, NEON on Arm). System.Numerics.Vector<T> adapts to the hardware width; Vector128/256/512<T> and System.Runtime.Intrinsics give explicit control; TensorPrimitives (System.Numerics.Tensors package) offers ready-made vectorized math. Many.NET APIs (Span.IndexOf, SequenceEqual, Sum over arrays) are already vectorized internally. Other hardware-aware techniques: contiguous memory access (cache friendliness), structs of arrays instead of arrays of objects, avoiding false sharing between threads.

Recall the GC behaviour from Module 30: every avoided allocation is less gen 0 work; avoided large arrays mean less LOH fragmentation.

Why It Matters

Parsing millions of CAN frames or log lines per second is dominated by allocations. Spans let you parse binary frames and text records with zero allocations, which is exactly what high-throughput tools (and .NET itself: Kestrel, System.Text.Json) do.

Syntax

Span<byte> buf = stackalloc byte[64];
ReadOnlySpan<byte> payload = frame.AsSpan(5, 8);
uint id = BinaryPrimitives.ReadUInt32BigEndian(frame);
byte[] rented = ArrayPool<byte>.Shared.Rent(4096); try { ... } finally { ArrayPool<byte>.Shared.Return(rented); }
async Task FillAsync(Memory<byte> m) { await ...; m.Span.Fill(0); }

Example

41.1 Spans, slicing, parsing without allocation, Memory, pools

41.2 Why Span<T> is restricted: the compiler enforces stack-only use 41.3 SIMD with Vector<T>

using System.Buffers;
using System.Buffers.Binary;
using System.Text;

// Span<T> over stack memory: no heap allocation at all
Span<int> values = stackalloc int[5];
for (int i = 0; i < values.Length; i++) values[i] = i * i;
Console.WriteLine($"stackalloc span: {string.Join(",", values.ToArray())}");

// Slicing is free: a span is just (reference, length)
byte[] frame = [0x18, 0xFE, 0xF1, 0x00, 0x08, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
ReadOnlySpan<byte> all = frame;
ReadOnlySpan<byte> idBytes = all[..4];
ReadOnlySpan<byte> payload = all.Slice(5, all[4]);
uint id = BinaryPrimitives.ReadUInt32BigEndian(idBytes);
Console.WriteLine($"id 0x{id:X8}, payload {Convert.ToHexString(payload)}, payload sum {Sum(payload)}");

// Writing through a span modifies the underlying array
Span<byte> writable = frame.AsSpan(5, 2);
writable.Fill(0xFF);
Console.WriteLine($"after Fill through a span: {Convert.ToHexString(frame)}");

// Parsing text without allocating substrings
ReadOnlySpan<char> line = "12.500 CAN1 18FEF100 Rx d 8";
int firstSpace = line.IndexOf(' ');
double time = double.Parse(line[..firstSpace], provider: System.Globalization.CultureInfo.InvariantCulture);
ReadOnlySpan<char
…
class Holder
{
    Span<int> _values;          // a ref struct cannot be a field of a class
}
class Program
{
    static async Task Main()
    {
        Span<byte> buffer = stackalloc byte[8];
        await Task.Delay(1);
        buffer[0] = 1;          // a Span cannot be used across an await
    }
}
using System.Diagnostics;
using System.Numerics;
using System.Runtime.Intrinsics;

// SIMD: one CPU instruction processes several values at once (Vector<T> adapts to the hardware width)
Console.WriteLine($"Vector.IsHardwareAccelerated: {Vector.IsHardwareAccelerated}, Vector<float>.Count: {Vector<float>.Count} floats per operation");
Console.WriteLine($"Vector128 accelerated: {Vector128.IsHardwareAccelerated}, Vector256 accelerated: {Vector256.IsHardwareAccelerated}");

float[] samples = new float[4_000_000];
var rng = new Random(1);
for (int i = 0; i < samples.Length; i++) samples[i] = (float)rng.NextDouble();

float scalar = SumScalar(samples), simd = SumSimd(samples);
Console.WriteLine($"sums agree (to rounding): {Math.Abs(scalar - simd) / scalar < 1e-4}");

var sw = Stopwatch.StartNew();
for (int r = 0; r < 20; r++) SumScalar(samples);
long scalarTicks = sw.ElapsedTicks;
sw.Restart();
for (int r = 0; r < 20; r++) SumSimd(samples);
long simdTicks = sw.ElapsedTicks;
Console.WriteLine($"SIMD sum faster than scalar loop: {simdTicks < scalarTicks}");

static float SumScalar(ReadOnlySpan<float> data)
{
    float sum = 0;
    foreach (float x in data) sum += x;
    return sum;
}

stati
…

Expected Result

10000 span comparisons ... bytes allocated: 0 is the key line: slicing and comparing spans allocated nothing (a Substring version would allocate 10 000 strings). Writing through writable changed the underlying frame array.

The restricted programs do not compile:

On this x64 machine one Vector<float> holds 8 floats (AVX2, 256 bits); on other CPUs the count differs, which is why the code asks Vector<float>.Count instead of hard-coding it.

stackalloc span: 0,1,4,9,16
id 0x18FEF100, payload 0102030405060708, payload sum 36
after Fill through a span: 18FEF10008FFFF030405060708
parsed time 12.5, channel CAN1
10000 span comparisons, matches 10000, bytes allocated: 0
Memory<T> filled in an async method: ABABABAB00000000
rented buffer length >= 1000: True (actual 1024)
used 6 bytes: ECU OK
MemoryPool buffer length >= 64: True
pooled object reused: True, cleared: True
m41_err.cs(3,5): error CS8345: Field or auto-implemented property cannot be of type 'Span<int>' unless it is an instance member of a ref struct.
m41_err2.cs(7,9): error CS4007: Instance of type 'System.Span<byte>' cannot be preserved across 'await' or 'yield' boundary.
Vector.IsHardwareAccelerated: True, Vector<float>.Count: 8 floats per operation
Vector128 accelerated: True, Vector256 accelerated: True
sums agree (to rounding): True
SIMD sum faster than scalar loop: True

Common Mistakes

Returning a span over a stackalloc buffer from a method (the compiler rejects it — the memory would be gone).

Large stackalloc (e.g. 1 MB) → StackOverflowException, which cannot be caught.

Forgetting to return rented arrays, or using rented.Length instead of the requested length.

Using a rented array after returning it.

Converting spans back to arrays/strings (ToArray(), ToString()) in hot loops, losing the benefit.

Optimizing with spans before measuring (Module 57).

Best Practices

Accept ReadOnlySpan<T> parameters in parsing/utility methods — callers can pass arrays, slices, strings or stack memory.

Use Memory<T> for async and storage, .Span for the work.

Keep stackalloc small (a few hundred bytes) and constant-sized, or fall back to ArrayPool.

Measure allocations with GC.GetAllocatedBytesForCurrentThread or BenchmarkDotNet [MemoryDiagnoser].

Interview Relevance

High for performance-oriented roles: "what is Span<T>?", "why is it a ref struct?", "Span vs Memory?", "how does ArrayPool reduce GC pressure?", "how would you parse a large file with minimal allocations?".

Practice Questions

Parse "12.500 CAN1 18FEF100 Rx d 8 01 02 03 04 05 06 07 08" into id and data bytes using only spans.

Write static bool TryReadUInt16BE(ReadOnlySpan<byte> s, out ushort value).

Compare allocations of line.Split(' ') vs a span-based tokenizer for 100 000 lines.

Implement a buffered reader that rents its buffer from ArrayPool<byte>.

Quick Quiz

Q1. (Multiple choice) Which type can be a field of a class? A) Span<byte> B) ReadOnlySpan<byte> C) Memory<byte> D) A ref struct

Answer: C (verified: a Span<int> field gives CS8345).

Q2. (Predict the output) ArrayPool<byte>.Shared.Rent(1000).Length

Answer: At least 1000 — here 1024 (verified). Always track the length you requested.

Q3. (True/False) Slicing a span copies the selected elements.

Answer: False. It creates a new view over the same memory (verified: Fill through a slice changed the array).

Q4. (Find the bug) Span<byte> buffer = stackalloc byte[8]; await Task.Delay(1); buffer[0] = 1;

Answer: A span cannot be preserved across await — CS4007 (verified). Use Memory<byte> or an array.

Coding Challenge

Write static int CountFields(ReadOnlySpan<char> line, char separator) that counts separator-delimited fields without allocating, and prove zero allocation over 100 000 calls.

string line = "12.500;CAN1;18FEF100;Rx;8;0102030405060708";
Console.WriteLine($"fields: {CountFields(line, ';')}");

CountFields(line, ';');                                  // warm-up (JIT)
long before = GC.GetAllocatedBytesForCurrentThread();
int total = 0;
for (int i = 0; i < 100_000; i++) total += CountFields(line, ';');
Console.WriteLine($"total {total}, bytes allocated: {GC.GetAllocatedBytesForCurrentThread() - before}");

static int CountFields(ReadOnlySpan<char> line, char separator) =>
    line.IsEmpty ? 0 : line.Count(separator) + 1;