You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

Span<T> & ReadOnlySpan<T>

Access memory slices efficiently without allocation or copying

Span & ReadOnlySpan

Master high-performance memory management with free flashcards and spaced repetition practice. This lesson covers Span fundamentals, ReadOnlySpan usage, stack-only constraints, and zero-allocation slicingβ€”essential concepts for building efficient C# applications that minimize heap pressure and garbage collection overhead.

Welcome to Memory Views πŸ’»

In modern C# applications, performance often comes down to how efficiently you manage memory. Traditional arrays and strings work well for most scenarios, but they come with hidden costs: heap allocations, garbage collection pauses, and unnecessary copying. Enter Span and ReadOnlySpanβ€”revolutionary types introduced in C# 7.2 that provide a safe, high-performance way to work with contiguous memory without allocations.

🎯 What makes Span special? It's a ref struct that lives entirely on the stack, representing a view into memory that can point to arrays, stack-allocated memory, or even unmanaged memory. No heap allocations, no GC pressure, just blazing-fast memory access.

Core Concepts πŸ”

What is Span?

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 lightweight window into existing dataβ€”it doesn't own the memory, it just provides access to it.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  MEMORY OWNERSHIP VS VIEWS              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚  Traditional Array (OWNS memory):      β”‚
β”‚  β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”               β”‚
β”‚  β”‚ 1 β”‚ 2 β”‚ 3 β”‚ 4 β”‚ 5 β”‚ (heap)        β”‚
β”‚  β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜               β”‚
β”‚                                         β”‚
β”‚  Span (VIEW into memory):          β”‚
β”‚  β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”               β”‚
β”‚  β”‚ 1 β”‚ 2 β”‚ 3 β”‚ 4 β”‚ 5 β”‚ (original)    β”‚
β”‚  β””β”€β”€β”€β”΄β”€β”¬β”€β”΄β”€β”¬β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜               β”‚
β”‚        β”‚   β”‚                           β”‚
β”‚        └───┴─ Span points here        β”‚
β”‚         (no copy, no allocation)      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key characteristics:

  • Stack-only: Cannot be boxed, cannot be fields in classes, cannot be used in async methods
  • Zero-allocation slicing: Create sub-views without copying data
  • Type-safe: Provides bounds checking and type safety
  • Unified API: Works with arrays, stack memory, and native memory

ReadOnlySpan - Immutable Views

ReadOnlySpan is the read-only counterpart to Span. It provides the same performance benefits but guarantees that the underlying data cannot be modified through the span.

string text = "Hello, World!";
ReadOnlySpan<char> span = text.AsSpan();
// span[0] = 'h'; // ❌ Compile error!

πŸ’‘ Tip: Use ReadOnlySpan by default when you don't need to modify data. It communicates intent and enables the compiler to optimize better.

Stack-Only Constraint (ref struct)

The ref struct constraint is what makes Span so fastβ€”but it comes with limitations:

βœ… Allowed❌ Not Allowed
Local variablesClass/struct fields
Method parametersBoxing to object
Return valuesAsync/await methods
Stack arrays (stackalloc)Lambda captures
Synchronous LINQGeneric type arguments (pre-C# 11)
// βœ… Valid uses
public void Process(Span<int> data) { }

public Span<int> GetSlice(int[] array) 
{
    return array.AsSpan(0, 10);
}

// ❌ Invalid uses
public class Container
{
    private Span<int> _data; // ❌ Can't be a field!
}

public async Task ProcessAsync(Span<int> data) // ❌ Can't use in async!
{
    await Task.Delay(100);
}

Creating Spans

You can create Span from multiple sources:

1. From arrays:

int[] array = { 1, 2, 3, 4, 5 };
Span<int> span = array; // Implicit conversion
Span<int> fullSpan = array.AsSpan();
Span<int> slice = array.AsSpan(1, 3); // Elements [1,2,3]

2. From stack memory (stackalloc):

Span<int> stackSpan = stackalloc int[10];
stackSpan[0] = 42;

3. From strings:

string text = "Hello";
ReadOnlySpan<char> charSpan = text.AsSpan();
ReadOnlySpan<char> substring = text.AsSpan(0, 3); // "Hel"

4. From Memory:

Memory<int> memory = new int[] { 1, 2, 3 };
Span<int> span = memory.Span;

Slicing and Subviews

One of Span's most powerful features is zero-allocation slicing. You can create views into subsets of data without copying:

int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Span<int> span = numbers;

// Multiple ways to slice:
Span<int> first5 = span.Slice(0, 5);     // [0,1,2,3,4]
Span<int> last5 = span.Slice(5);         // [5,6,7,8,9]
Span<int> middle = span.Slice(3, 4);     // [3,4,5,6]

// Using range operators (C# 8.0+):
Span<int> rangeSlice = span[2..7];       // [2,3,4,5,6]
Span<int> fromStart = span[..3];         // [0,1,2]
Span<int> toEnd = span[5..];             // [5,6,7,8,9]
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  SLICING WITHOUT ALLOCATION             β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚  Original Array:                        β”‚
β”‚  β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”   β”‚
β”‚  β”‚ 0 β”‚ 1 β”‚ 2 β”‚ 3 β”‚ 4 β”‚ 5 β”‚ 6 β”‚ 7 β”‚   β”‚
β”‚  β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜   β”‚
β”‚                                         β”‚
β”‚  Slice(2, 4):                          β”‚
β”‚  β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”   β”‚
β”‚  β”‚ 0 β”‚ 1 β”‚ 2 β”‚ 3 β”‚ 4 β”‚ 5 β”‚ 6 β”‚ 7 β”‚   β”‚
β”‚  β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”¬β”€β”΄β”€β”¬β”€β”΄β”€β”¬β”€β”΄β”€β”¬β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜   β”‚
β”‚            β”‚   β”‚   β”‚   β”‚               β”‚
β”‚            β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜               β”‚
β”‚            View into [2,3,4,5]         β”‚
β”‚            (no copy!)                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Common Operations

Span provides a rich set of operations:

Accessing elements:

Span<int> span = stackalloc int[] { 1, 2, 3 };
int first = span[0];
span[1] = 42;
int length = span.Length;

Copying:

Span<int> source = stackalloc int[] { 1, 2, 3 };
Span<int> dest = stackalloc int[3];
source.CopyTo(dest);

// Or use TryCopyTo for safety:
if (!source.TryCopyTo(dest))
{
    Console.WriteLine("Destination too small");
}

Filling:

Span<int> span = stackalloc int[10];
span.Fill(42); // All elements = 42
span.Clear();  // All elements = 0

Searching:

ReadOnlySpan<char> text = "Hello World";
int index = text.IndexOf('W');           // 6
bool contains = text.Contains('o');      // true
int lastIndex = text.LastIndexOf('o');   // 7

Real-World Examples πŸš€

Example 1: String Parsing Without Allocations

Traditional string manipulation creates many temporary objects. With ReadOnlySpan, you can parse strings with zero allocations:

public static class CsvParser
{
    public static void ParseLine(ReadOnlySpan<char> line)
    {
        while (line.Length > 0)
        {
            int commaIndex = line.IndexOf(',');
            
            ReadOnlySpan<char> field = commaIndex >= 0
                ? line.Slice(0, commaIndex)
                : line;
            
            // Process field (no string allocation!)
            ProcessField(field);
            
            // Move to next field
            line = commaIndex >= 0 
                ? line.Slice(commaIndex + 1) 
                : ReadOnlySpan<char>.Empty;
        }
    }
    
    private static void ProcessField(ReadOnlySpan<char> field)
    {
        // Trim whitespace without allocation
        field = field.Trim();
        
        // Parse as integer if needed
        if (int.TryParse(field, out int value))
        {
            Console.WriteLine($"Number: {value}");
        }
        else
        {
            Console.WriteLine($"Text: {field.ToString()}");
        }
    }
}

// Usage:
string csvLine = "John,30,Engineer,New York";
CsvParser.ParseLine(csvLine.AsSpan()); // Zero allocations!

πŸ’‘ Performance win: Traditional Split(',') would allocate a string array plus substrings. This approach allocates nothing.

Example 2: Buffer Processing with Stackalloc

When you need temporary buffers for small data, stackalloc with Span is incredibly efficient:

public static string BytesToHex(byte[] bytes)
{
    const int maxStackAlloc = 256;
    
    // Use stack for small arrays, heap for large
    Span<char> buffer = bytes.Length <= maxStackAlloc / 2
        ? stackalloc char[bytes.Length * 2]
        : new char[bytes.Length * 2];
    
    for (int i = 0; i < bytes.Length; i++)
    {
        byte b = bytes[i];
        buffer[i * 2] = GetHexChar(b >> 4);
        buffer[i * 2 + 1] = GetHexChar(b & 0x0F);
    }
    
    return new string(buffer);
}

private static char GetHexChar(int value)
{
    return (char)(value < 10 ? '0' + value : 'A' + value - 10);
}

// Usage:
byte[] data = { 0xFF, 0xA3, 0x12 };
string hex = BytesToHex(data); // "FFA312" - buffer on stack!

⚑ Performance note: For arrays up to 128 bytes, stackalloc is typically faster than heap allocation and doesn't trigger GC.

Example 3: Efficient Array Manipulation

Span makes working with array segments much cleaner:

public static void ReverseWords(char[] sentence)
{
    Span<char> span = sentence;
    
    // Reverse entire sentence first
    span.Reverse();
    
    // Then reverse each word
    int start = 0;
    for (int i = 0; i <= span.Length; i++)
    {
        if (i == span.Length || span[i] == ' ')
        {
            // Reverse the word from start to i-1
            span.Slice(start, i - start).Reverse();
            start = i + 1;
        }
    }
}

// Usage:
char[] text = "Hello World from Span".ToCharArray();
ReverseWords(text);
Console.WriteLine(new string(text)); // "Span from World Hello"

πŸ”§ Try this: Implement a similar algorithm using traditional string methodsβ€”you'll see how many allocations it requires!

Example 4: Working with Binary Data

Span is perfect for low-level binary operations:

public static class BinaryUtils
{
    public static int ReadInt32BigEndian(ReadOnlySpan<byte> buffer)
    {
        if (buffer.Length < 4)
            throw new ArgumentException("Buffer too small");
        
        return (buffer[0] << 24) | 
               (buffer[1] << 16) | 
               (buffer[2] << 8) | 
               buffer[3];
    }
    
    public static void WriteInt32BigEndian(Span<byte> buffer, int value)
    {
        if (buffer.Length < 4)
            throw new ArgumentException("Buffer too small");
        
        buffer[0] = (byte)(value >> 24);
        buffer[1] = (byte)(value >> 16);
        buffer[2] = (byte)(value >> 8);
        buffer[3] = (byte)value;
    }
    
    // Process packets without allocations
    public static void ProcessPacket(ReadOnlySpan<byte> packet)
    {
        if (packet.Length < 8)
            return;
        
        ReadOnlySpan<byte> header = packet.Slice(0, 4);
        ReadOnlySpan<byte> payload = packet.Slice(4);
        
        int packetId = ReadInt32BigEndian(header);
        Console.WriteLine($"Packet ID: {packetId}, Payload: {payload.Length} bytes");
    }
}

// Usage:
byte[] networkData = new byte[100];
BinaryUtils.WriteInt32BigEndian(networkData.AsSpan(), 12345);
BinaryUtils.ProcessPacket(networkData);

Common Mistakes ⚠️

Mistake 1: Trying to Store Span in a Field

// ❌ WRONG - Won't compile!
public class DataProcessor
{
    private Span<int> _buffer; // Error: ref struct can't be a field
    
    public DataProcessor(int[] data)
    {
        _buffer = data;
    }
}

// βœ… RIGHT - Store the underlying array or Memory<T>
public class DataProcessor
{
    private readonly int[] _buffer;
    
    public DataProcessor(int[] data)
    {
        _buffer = data;
    }
    
    public void Process()
    {
        Span<int> span = _buffer; // Create span when needed
        // Use span...
    }
}

// βœ… ALTERNATIVE - Use Memory<T> for storage
public class DataProcessor
{
    private readonly Memory<int> _buffer;
    
    public DataProcessor(int[] data)
    {
        _buffer = data;
    }
    
    public void Process()
    {
        Span<int> span = _buffer.Span;
        // Use span...
    }
}

Mistake 2: Using Span in Async Methods

// ❌ WRONG - Span can't be used in async methods
public async Task ProcessAsync(Span<int> data)
{
    await Task.Delay(100); // Error!
    // Process data...
}

// βœ… RIGHT - Use Memory<T> instead
public async Task ProcessAsync(Memory<int> data)
{
    await Task.Delay(100);
    Span<int> span = data.Span; // Get span when needed
    // Process span...
}

// βœ… ALTERNATIVE - Process before await
public async Task ProcessAsync(int[] data)
{
    Span<int> span = data;
    int result = ProcessSpan(span); // Synchronous span usage
    
    await Task.Delay(100);
    
    // Use result...
}

private int ProcessSpan(Span<int> span)
{
    // All span operations here
    return span.Length;
}

Mistake 3: Dangling References with Stackalloc

// ❌ WRONG - Returning stack memory!
public Span<int> CreateBuffer()
{
    Span<int> buffer = stackalloc int[10];
    return buffer; // Dangerous! Stack will be unwound!
}

// βœ… RIGHT - Use array for returns
public Span<int> CreateBuffer()
{
    int[] buffer = new int[10];
    return buffer; // Safe - array is on heap
}

// βœ… RIGHT - Process stack data immediately
public int ProcessData()
{
    Span<int> buffer = stackalloc int[10];
    // Fill buffer...
    return ComputeSum(buffer); // Use it before returning
}

Mistake 4: Not Checking Bounds Before Slicing

// ❌ WRONG - No bounds checking
public void ProcessSubstring(string text, int start, int length)
{
    ReadOnlySpan<char> span = text.AsSpan(start, length); // Throws if out of bounds!
    // Process...
}

// βœ… RIGHT - Validate parameters
public void ProcessSubstring(string text, int start, int length)
{
    if (start < 0 || start + length > text.Length)
    {
        throw new ArgumentOutOfRangeException();
    }
    
    ReadOnlySpan<char> span = text.AsSpan(start, length);
    // Process...
}

// βœ… ALTERNATIVE - Use range operators with bounds check
public void ProcessSubstring(string text, int start, int length)
{
    if (start < 0 || start + length > text.Length)
        return;
    
    ReadOnlySpan<char> span = text.AsSpan()[start..(start + length)];
    // Process...
}

Mistake 5: Unnecessary ToString() Calls

// ❌ WRONG - Allocates string unnecessarily
public bool StartsWithHello(string text)
{
    ReadOnlySpan<char> span = text.AsSpan(0, 5);
    string substr = span.ToString(); // Allocation!
    return substr == "Hello";
}

// βœ… RIGHT - Use SequenceEqual
public bool StartsWithHello(string text)
{
    if (text.Length < 5) return false;
    
    ReadOnlySpan<char> span = text.AsSpan(0, 5);
    return span.SequenceEqual("Hello".AsSpan()); // No allocation!
}

// βœ… ALTERNATIVE - Use StartsWith extension
public bool StartsWithHello(string text)
{
    return text.AsSpan().StartsWith("Hello".AsSpan());
}

Performance Characteristics πŸ“Š

OperationTraditionalSpanBenefit
SubstringO(n) + allocationO(1) + zero allocation⚑ Much faster
Array sliceArray.Copy + allocationView creation⚑ Zero copy
Buffer operationsHeap allocation + GCStack allocation⚑ No GC pressure
Element accessBounds checkBounds checkβœ… Same safety
String comparisonAllocates temp stringsDirect comparison⚑ Zero allocation

🧠 Memory device: Remember "SPAN" = Stack Performance And No-allocation!

Span vs Memory πŸ†š

While Span is powerful, sometimes you need Memory:

FeatureSpanMemory
LocationStack only (ref struct)Heap (regular struct)
Async support❌ Noβœ… Yes
Field storage❌ Noβœ… Yes
Performance⚑ Fastest⚑ Fast
Use caseSync hot pathsAsync, storage
Get SpanDirect use.Span property
// When to use each:
public void SynchronousProcessing(int[] data)
{
    Span<int> span = data; // βœ… Use Span for sync
    ProcessSync(span);
}

public async Task AsynchronousProcessing(int[] data)
{
    Memory<int> memory = data; // βœ… Use Memory for async
    await ProcessAsync(memory);
}

private async Task ProcessAsync(Memory<int> memory)
{
    await Task.Delay(100);
    Span<int> span = memory.Span; // Get Span when needed
    // Use span...
}

Key Takeaways 🎯

  1. Span is a view, not ownershipβ€”it points to existing memory without copying or allocating

  2. Stack-only constraint (ref struct) enables incredible performance but limits usage scenarios

  3. Zero-allocation slicing makes substring and array segment operations essentially free

  4. ReadOnlySpan should be your default choice when you don't need to modify data

  5. Use stackalloc with Span for small temporary buffers (typically < 1KB)

  6. Memory bridges the gap when you need async support or field storage

  7. Avoid ToString() on spans when possibleβ€”use SequenceEqual, StartsWith, or other span methods

  8. Check bounds before slicing to avoid runtime exceptions

  9. String parsing becomes allocation-free with ReadOnlySpan

  10. Performance matters: In hot paths, Span can reduce allocations by 90%+ compared to traditional approaches

πŸ“‹ Quick Reference Card

πŸ’» Span Cheat Sheet

Create from arraySpan<int> s = array;
Create from stackSpan<int> s = stackalloc int[10];
Slicespan.Slice(start, length) or span[start..end]
Copysource.CopyTo(dest)
Fillspan.Fill(value)
Clearspan.Clear()
Searchspan.IndexOf(value)
Comparespan.SequenceEqual(other)
String to spantext.AsSpan()
For asyncUse Memory<T> instead

⚠️ Restrictions:

  • ❌ No fields in classes
  • ❌ No async methods
  • ❌ No boxing
  • ❌ No lambda captures
  • βœ… Local variables only
  • βœ… Method parameters/returns

πŸ“š Further Study

  1. Microsoft Docs - Memory and Span Usage Guidelines: https://docs.microsoft.com/en-us/dotnet/standard/memory-and-spans/
  2. Stephen Toub's Span Deep Dive: https://learn.microsoft.com/en-us/archive/msdn-magazine/2018/january/csharp-all-about-span-exploring-a-new-net-mainstay
  3. High-Performance C# with Span: https://www.youtube.com/watch?v=byvoPD15CXs

πŸŽ“ Practice tip: Try refactoring string-heavy code in your projects to use ReadOnlySpan. Profile before and afterβ€”you'll be amazed at the performance gains and reduced GC pressure!

πŸ”¬ Advanced challenge: Implement a zero-allocation JSON parser for simple key-value pairs using ReadOnlySpan. Compare memory allocations with existing libraries using a profiler.