You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Memory Management and Garbage Collection in .NET

Span<T> and ReadOnlySpan<T>

Stack-only ref structs for zero-allocation slicing

Span and ReadOnlySpan

Master modern .NET memory management with Span and ReadOnlySpan, complete with free flashcards and spaced repetition practice. This lesson covers stack-only allocation principles, memory slicing techniques, and safe high-performance buffer manipulationβ€”essential concepts for building efficient .NET applications that minimize heap allocations and garbage collection pressure.

Welcome πŸ’»

Welcome to one of the most transformative features introduced in modern .NET! Span and ReadOnlySpan represent a paradigm shift in how we work with contiguous memory regions. These types enable zero-allocation slicing, provide unified access to arrays, stack memory, and native memory, and deliver performance that rivals unsafe codeβ€”all while maintaining type safety.

πŸ€” Did you know? The introduction of Span<T> in .NET Core 2.1 led to massive performance improvements across the entire framework. The ASP.NET Core team reduced allocations by over 50% in some scenarios simply by adopting these types!

Core Concepts

What Are Span and ReadOnlySpan? 🎯

Span is a ref struct that provides a type-safe, memory-safe representation of a contiguous region of arbitrary memory. Think of it as a "window" into memoryβ€”whether that memory lives on the stack, heap, or even in unmanaged memory.

ReadOnlySpan is the immutable counterpart, preventing modifications to the underlying data. It's similar to the relationship between List<T> and IReadOnlyList<T>, but with far greater performance implications.

// Traditional approach - creates new arrays
int[] original = { 1, 2, 3, 4, 5, 6, 7, 8 };
int[] firstHalf = original.Take(4).ToArray();  // ❌ Heap allocation!
int[] secondHalf = original.Skip(4).ToArray(); // ❌ Another allocation!

// Modern approach - zero allocations
Span<int> span = original;
Span<int> firstHalfSpan = span.Slice(0, 4);    // βœ… Just a view!
Span<int> secondHalfSpan = span.Slice(4, 4);   // βœ… Another view!

The ref struct Constraint ⚑

Both Span<T> and ReadOnlySpan<T> are declared as ref struct, which means they can only live on the stack. This is a deliberate design decision that enables their incredible performance characteristics:

Can Do βœ…Cannot Do ❌
Use as local variablesBe fields in classes
Pass as method parametersBe boxed to object
Return from methodsBe used in async methods
Use in stackalloc scenariosBe captured by lambdas
Store in other ref structsImplement interfaces

🧠 Memory Device: Think "STACK ONLY" - Span stays on the STACK, Only No exceptions, Lambdas Yield problems!

Memory Safety and the Compiler's Role πŸ”’

The C# compiler performs extensive safety analysis to ensure Span<T> instances never outlive the memory they reference:

// ❌ Compiler error: Cannot use local variable in method that returns to caller
Span<int> DangerousMethod()
{
    Span<int> local = stackalloc int[10];
    return local;  // ERROR: Would reference invalid stack memory!
}

// βœ… Safe: Span references heap memory with longer lifetime
Span<int> SafeMethod()
{
    int[] array = new int[10];
    return array.AsSpan();  // OK: Array outlives the method
}

πŸ’‘ Tip: The compiler's safety analysis is called ref safety rules. These rules track the lifetime scope of memory and ensure references never dangle.

Unified Memory Access Pattern 🌍

One of Span<T>'s superpowers is providing a single API for working with memory from different sources:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         UNIFIED SPAN API                 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                             β”‚
β”‚   πŸ“¦ Array       πŸ”§ stackalloc              β”‚
β”‚       ↓              ↓                      β”‚
β”‚       └──→ Span β†β”€β”€β”˜                    β”‚
β”‚                ↑                            β”‚
β”‚                β”‚                            β”‚
β”‚         πŸ”— Native Memory                    β”‚
β”‚                                             β”‚
β”‚  Single API for slice, indexing,           β”‚
β”‚  iteration, comparison, searching          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
void ProcessData(Span<byte> data)
{
    // Same code works regardless of source!
    for (int i = 0; i < data.Length; i++)
    {
        data[i] = (byte)(data[i] ^ 0xFF);  // XOR operation
    }
}

// All these work with the same method:
byte[] heapArray = new byte[100];
ProcessData(heapArray);  // From heap

Span<byte> stackSpan = stackalloc byte[100];
ProcessData(stackSpan);  // From stack

unsafe
{
    byte* ptr = (byte*)Marshal.AllocHGlobal(100);
    ProcessData(new Span<byte>(ptr, 100));  // From native memory
    Marshal.FreeHGlobal((IntPtr)ptr);
}

Slicing Without Allocating πŸ”ͺ

The killer feature of Span<T> is zero-allocation slicing. Traditional substring and array operations create new objects; Span<T> just adjusts pointers:

string text = "Hello, World! Welcome to Span<T>.";

// ❌ Old way - allocates new strings
string part1 = text.Substring(0, 5);      // Allocates "Hello"
string part2 = text.Substring(7, 5);      // Allocates "World"

// βœ… New way - zero allocations
ReadOnlySpan<char> span = text.AsSpan();
ReadOnlySpan<char> hello = span.Slice(0, 5);     // Just a view
ReadOnlySpan<char> world = span.Slice(7, 5);     // Another view

// Even better: range syntax (C# 8.0+)
ReadOnlySpan<char> hello2 = span[0..5];   // Equivalent to Slice(0, 5)
ReadOnlySpan<char> world2 = span[7..12];  // Equivalent to Slice(7, 5)
OperationTraditionalWith SpanSavings
SubstringNew string allocationPointer + length~40 bytes + data
Array segmentNew array allocationPointer + length~24 bytes + data
Memory copyArray.CopyDirect memory accessFunction call overhead

Performance Characteristics ⚑

Understanding the internal structure explains Span<T>'s performance:

// Simplified conceptual representation
public readonly ref struct Span<T>
{
    private readonly ref T _reference;  // Pointer to first element
    private readonly int _length;       // Number of elements
    
    // Just 16 bytes on 64-bit systems!
    // 8 bytes for reference + 8 bytes for length
}
MEMORY LAYOUT COMPARISON

Array (heap object):
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Sync Block | Type Pointer | Length  β”‚  24 bytes overhead
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Element 0 | Element 1 | Element 2 ...β”‚  + actual data
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         ↑
         β”‚ Heap allocation required

Span (stack structure):
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Reference    β”‚ Length  β”‚  16 bytes total (stack)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         └──→ Points to existing memory
              (no new allocation)

Conversion and Interoperability πŸ”„

Span<T> provides rich conversion capabilities:

// From array to Span
int[] array = { 1, 2, 3, 4, 5 };
Span<int> span1 = array;                    // Implicit conversion
Span<int> span2 = array.AsSpan();           // Explicit method
Span<int> span3 = new Span<int>(array);     // Constructor

// From array segment
Span<int> partial = array.AsSpan(1, 3);     // Elements [1], [2], [3]

// From stackalloc
Span<int> stack = stackalloc int[10];

// To array (requires allocation)
int[] backToArray = span1.ToArray();        // Creates new array

// ReadOnlySpan conversions
ReadOnlySpan<int> readOnly = array;         // Implicit
ReadOnlySpan<int> fromSpan = span1;         // Span<T> β†’ ReadOnlySpan<T>
// Span<int> notAllowed = readOnly;        // ❌ Cannot convert back!

πŸ’‘ Tip: Use ReadOnlySpan for method parameters when you don't need to modify the data. This allows both Span<T> and ReadOnlySpan<T> to be passed, following the principle of least privilege.

Practical Examples

Example 1: High-Performance String Parsing πŸ“

Parsing CSV data traditionally creates many temporary string objects. With ReadOnlySpan<char>, we eliminate these allocations:

public static class CsvParser
{
    // ❌ Traditional approach - many allocations
    public static string[] ParseLineOld(string line)
    {
        return line.Split(',');  // Allocates array AND strings
    }
    
    // βœ… Modern approach - zero allocations (except result list)
    public static List<string> ParseLine(ReadOnlySpan<char> line)
    {
        var results = new List<string>();
        
        while (line.Length > 0)
        {
            int commaIndex = line.IndexOf(',');
            
            if (commaIndex == -1)
            {
                // Last field
                results.Add(line.ToString());  // Only allocate final strings
                break;
            }
            
            // Extract field without allocation
            ReadOnlySpan<char> field = line.Slice(0, commaIndex);
            results.Add(field.ToString());
            
            // Move to next field (no allocation)
            line = line.Slice(commaIndex + 1);
        }
        
        return results;
    }
    
    // πŸ”₯ Even better: Parse without string allocation
    public static int SumCsvIntegers(ReadOnlySpan<char> line)
    {
        int sum = 0;
        
        while (line.Length > 0)
        {
            int commaIndex = line.IndexOf(',');
            ReadOnlySpan<char> field = commaIndex == -1 
                ? line 
                : line.Slice(0, commaIndex);
            
            // Parse directly from span (no string allocation)
            if (int.TryParse(field, out int value))
            {
                sum += value;
            }
            
            if (commaIndex == -1) break;
            line = line.Slice(commaIndex + 1);
        }
        
        return sum;
    }
}

// Usage
string csvData = "100,200,300,400,500";
int total = CsvParser.SumCsvIntegers(csvData.AsSpan());
Console.WriteLine($"Total: {total}");  // Output: Total: 1500

Performance impact: In benchmarks, the span-based approach is 3-5x faster and allocates 10-20x less memory than traditional string operations.

Example 2: Safe Buffer Manipulation πŸ›‘οΈ

Working with byte buffers for network protocols or file I/O becomes both safer and faster:

public class PacketProcessor
{
    // Protocol: [2-byte length][4-byte type][payload]
    
    public static bool TryReadPacket(
        ReadOnlySpan<byte> buffer,
        out int packetType,
        out ReadOnlySpan<byte> payload)
    {
        packetType = 0;
        payload = ReadOnlySpan<byte>.Empty;
        
        // Validate minimum size
        if (buffer.Length < 6)
            return false;
        
        // Read length (first 2 bytes) - zero allocation
        short payloadLength = BitConverter.ToInt16(buffer.Slice(0, 2));
        
        // Validate total size
        if (buffer.Length < 6 + payloadLength)
            return false;
        
        // Read type (next 4 bytes) - zero allocation
        packetType = BitConverter.ToInt32(buffer.Slice(2, 4));
        
        // Extract payload - just a view, no copy
        payload = buffer.Slice(6, payloadLength);
        
        return true;
    }
    
    public static void WritePacket(
        Span<byte> buffer,
        int packetType,
        ReadOnlySpan<byte> payload)
    {
        // Write length
        BitConverter.TryWriteBytes(buffer.Slice(0, 2), (short)payload.Length);
        
        // Write type
        BitConverter.TryWriteBytes(buffer.Slice(2, 4), packetType);
        
        // Write payload - efficient copy
        payload.CopyTo(buffer.Slice(6));
    }
}

// Usage
byte[] networkBuffer = new byte[1024];
int bytesReceived = 100;  // From network read

if (PacketProcessor.TryReadPacket(
    networkBuffer.AsSpan(0, bytesReceived),
    out int type,
    out ReadOnlySpan<byte> data))
{
    Console.WriteLine($"Packet type: {type}, Data length: {data.Length}");
    // Process data without any copying
}

Example 3: Stack Allocation for Temporary Buffers πŸ“š

Combining stackalloc with Span<T> enables extremely fast temporary buffer creation:

public static class StringHelpers
{
    // Reverse a string efficiently
    public static string Reverse(string input)
    {
        if (string.IsNullOrEmpty(input))
            return input;
        
        // Allocate on stack for small strings (very fast!)
        // Fall back to heap for large strings
        Span<char> buffer = input.Length <= 128
            ? stackalloc char[input.Length]
            : new char[input.Length];
        
        // Copy and reverse
        for (int i = 0; i < input.Length; i++)
        {
            buffer[i] = input[input.Length - 1 - i];
        }
        
        return new string(buffer);
    }
    
    // Convert to uppercase without allocation (reading only)
    public static bool EqualsIgnoreCase(
        ReadOnlySpan<char> left,
        ReadOnlySpan<char> right)
    {
        if (left.Length != right.Length)
            return false;
        
        return left.Equals(right, StringComparison.OrdinalIgnoreCase);
    }
    
    // Build a formatted string with minimal allocations
    public static string FormatCoordinates(double x, double y, double z)
    {
        // Stack-allocate buffer for formatting
        Span<char> buffer = stackalloc char[100];
        
        int pos = 0;
        "Point(".AsSpan().CopyTo(buffer.Slice(pos));
        pos += 6;
        
        // Format each coordinate
        x.TryFormat(buffer.Slice(pos), out int written);
        pos += written;
        buffer[pos++] = ',';
        buffer[pos++] = ' ';
        
        y.TryFormat(buffer.Slice(pos), out written);
        pos += written;
        buffer[pos++] = ',';
        buffer[pos++] = ' ';
        
        z.TryFormat(buffer.Slice(pos), out written);
        pos += written;
        buffer[pos++] = ')';
        
        return new string(buffer.Slice(0, pos));
    }
}

// Usage
string reversed = StringHelpers.Reverse("Hello");  // "olleH"
bool same = StringHelpers.EqualsIgnoreCase("Test".AsSpan(), "TEST".AsSpan());
string coords = StringHelpers.FormatCoordinates(1.5, 2.7, 3.9);

🧠 Memory Device: "128 is great" - Use stackalloc for buffers 128 bytes or less to avoid stack overflow risks.

Example 4: Working with Memory for Async πŸ”„

Since Span<T> cannot be used in async methods, .NET provides Memory as a heap-based alternative:

public class AsyncBufferProcessor
{
    // ❌ Cannot use Span in async method
    // public async Task ProcessAsync(Span<byte> data) { ... }
    
    // βœ… Use Memory<T> instead
    public async Task<int> ProcessAsync(Memory<byte> data)
    {
        // Simulate async I/O
        await Task.Delay(100);
        
        // Convert to Span when doing actual work
        Span<byte> span = data.Span;
        
        int sum = 0;
        foreach (byte b in span)
        {
            sum += b;
        }
        
        return sum;
    }
    
    // Pattern: Accept Memory<T>, work with Span<T>
    public async Task WriteToStreamAsync(
        Stream stream,
        Memory<byte> buffer,
        int count)
    {
        // Memory<T> can be stored across await
        await stream.WriteAsync(buffer.Slice(0, count));
        
        // After await, convert to Span for processing
        Span<byte> written = buffer.Span.Slice(0, count);
        LogWrittenData(written);  // Synchronous processing
    }
    
    private void LogWrittenData(Span<byte> data)
    {
        // Work with Span in non-async context
        Console.WriteLine($"Wrote {data.Length} bytes");
    }
}

// Usage
var processor = new AsyncBufferProcessor();
byte[] buffer = new byte[1024];
int result = await processor.ProcessAsync(buffer);

πŸ“‹ Span vs Memory Quick Reference

FeatureSpan<T>Memory<T>
StorageStack only (ref struct)Heap (regular struct)
Async support❌ Noβœ… Yes
Performance⚑ Fastest⚑ Fast (converts to Span)
Use caseSynchronous processingAsync/await scenarios
ConversionN/Amemory.Span β†’ Span<T>

Common Mistakes ⚠️

Mistake 1: Trying to Store Span in a Class Field

// ❌ WRONG - Compiler error!
public class DataProcessor
{
    private Span<byte> _buffer;  // ERROR: Cannot be a field
    
    public DataProcessor(Span<byte> buffer)
    {
        _buffer = buffer;  // Won't compile
    }
}

// βœ… CORRECT - Use Memory<T> for storage
public class DataProcessor
{
    private Memory<byte> _buffer;  // OK: Memory can be stored
    
    public DataProcessor(Memory<byte> buffer)
    {
        _buffer = buffer;
    }
    
    public void Process()
    {
        Span<byte> span = _buffer.Span;  // Convert when needed
        // Work with span...
    }
}

Mistake 2: Using Span in Async Methods

// ❌ WRONG - Compiler error!
public async Task ProcessAsync(Span<byte> data)
{
    await Task.Delay(100);  // ERROR: Span cannot cross await
    ProcessData(data);
}

// βœ… CORRECT - Use Memory<T> for async
public async Task ProcessAsync(Memory<byte> data)
{
    await Task.Delay(100);  // OK: Memory can cross await
    ProcessData(data.Span);  // Convert to Span after await
}

private void ProcessData(Span<byte> data)
{
    // Synchronous processing with Span
}

Mistake 3: Assuming Span Copies Data

// ⚠️ DANGER - Modifying through span affects original!
int[] original = { 1, 2, 3, 4, 5 };
Span<int> span = original.AsSpan();
span[0] = 999;

Console.WriteLine(original[0]);  // Output: 999 (not 1!)

// βœ… CORRECT - Use ToArray() to create a copy
int[] original = { 1, 2, 3, 4, 5 };
Span<int> span = original.AsSpan();
int[] copy = span.ToArray();  // Create independent copy
copy[0] = 999;

Console.WriteLine(original[0]);  // Output: 1 (unchanged)
Console.WriteLine(copy[0]);      // Output: 999

Mistake 4: Returning Stack-Allocated Spans

// ❌ EXTREMELY DANGEROUS - Compiler prevents this!
Span<int> CreateBuffer()
{
    Span<int> buffer = stackalloc int[10];
    return buffer;  // ERROR: Would reference invalid stack memory
}

// βœ… CORRECT - Return heap-allocated or parameter-based spans
Span<int> GetSlice(int[] array, int start, int length)
{
    return array.AsSpan(start, length);  // OK: array outlives method
}

int[] CreateInitializedArray()
{
    Span<int> buffer = stackalloc int[10];
    buffer.Fill(42);
    return buffer.ToArray();  // OK: Converts to heap array
}

Mistake 5: Capturing Span in Lambdas or Closures

// ❌ WRONG - Compiler error!
void ProcessItems(Span<int> items)
{
    var query = items.Where(x => x > 0);  // ERROR: Cannot capture Span
}

// βœ… CORRECT - Convert to array first, or avoid LINQ
void ProcessItems(Span<int> items)
{
    // Option 1: Manual iteration
    for (int i = 0; i < items.Length; i++)
    {
        if (items[i] > 0)
        {
            ProcessItem(items[i]);
        }
    }
    
    // Option 2: Convert to array (if allocation is acceptable)
    var array = items.ToArray();
    var query = array.Where(x => x > 0);
}

Mistake 6: Excessive stackalloc Causing Stack Overflow

// ❌ DANGER - May overflow stack!
void ProcessLargeData()
{
    Span<byte> huge = stackalloc byte[1_000_000];  // 1 MB on stack! πŸ’₯
    // ...
}

// βœ… CORRECT - Use threshold pattern
void ProcessData(int size)
{
    const int StackAllocThreshold = 512;  // Conservative limit
    
    Span<byte> buffer = size <= StackAllocThreshold
        ? stackalloc byte[size]        // Small: use stack
        : new byte[size];              // Large: use heap
    
    // Process buffer...
}

πŸ’‘ Tip: The typical stack size is 1 MB on Windows (64-bit). Keep stackalloc allocations under 128-512 bytes to be safe, especially in recursive or deeply nested code.

Key Takeaways 🎯

βœ… Span and ReadOnlySpan are ref structs that provide type-safe, zero-allocation views into contiguous memory regions

βœ… Stack-only constraint enables incredible performance but prevents use in async methods, as class fields, or with lambdas

βœ… Slicing operations (Slice, indexers) create new views without copying dataβ€”modifications affect the underlying memory

βœ… Memory is the heap-based alternative for scenarios requiring async support or storage in fields

βœ… stackalloc with Span enables ultra-fast temporary buffers, but keep allocations small (≀128-512 bytes)

βœ… Unified API works with arrays, stack memory, and native memory through a single, consistent interface

βœ… ReadOnlySpan provides immutability guarantees and should be preferred for method parameters that don't modify data

βœ… BitConverter and parsing APIs work directly with spans, enabling zero-allocation data transformations

πŸ“‹ Quick Reference Card

ScenarioUse ThisWhy
Synchronous processingSpan<T>Maximum performance
Read-only parameterReadOnlySpan<T>Prevents modifications
Async/awaitMemory<T>Can cross await boundaries
Store in fieldMemory<T>Not a ref struct
Small temp bufferstackalloc + SpanZero allocation
Large bufferArrayPool or heapAvoid stack overflow
String operationsReadOnlySpan<char>Avoid substring allocations
Byte protocolsSpan<byte>Direct memory access

Common Operations

OperationCode
Create from arrayarray.AsSpan()
Slice rangespan[start..end]
Get lengthspan.Length
Access elementspan[index]
Copy datasource.CopyTo(dest)
Fill with valuespan.Fill(value)
Find elementspan.IndexOf(value)
Convert to arrayspan.ToArray()
Memory to Spanmemory.Span

πŸ“š Further Study

  1. Official Documentation: Span Struct - Microsoft Docs - Comprehensive API reference and usage guidelines

  2. Performance Deep Dive: All About Span - MSDN Magazine - Detailed exploration of Span's implementation and performance characteristics

  3. Memory and Async Patterns: Memory and Span usage guidelines - Best practices for choosing between Span and Memory in real-world applications