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

Last generated

Span<T> & ReadOnlySpan<T>

Master high-performance memory management with free flashcards and spaced repetition practice. This lesson covers Span<T> fundamentals, ReadOnlySpan<T> 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<T> and ReadOnlySpan<T>β€”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<T>?

Span<T> 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<T> (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<T> - Immutable Views

ReadOnlySpan<T> is the read-only counterpart to Span<T>. 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<T> 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<T> 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<T> 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<T>:

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

Slicing and Subviews

One of Span<T>'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<T> 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<char>, 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<T> 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<T> 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<T> 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 πŸ“Š

OperationTraditionalSpan<T>Benefit
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<T> πŸ†š

While Span<T> is powerful, sometimes you need Memory<T>:

FeatureSpan<T>Memory<T>
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<T> 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<T> should be your default choice when you don't need to modify data

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

  6. Memory<T> 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<char>

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

πŸ“‹ Quick Reference Card

πŸ’» Span<T> 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<T> 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<T>: https://www.youtube.com/watch?v=byvoPD15CXs

πŸŽ“ Practice tip: Try refactoring string-heavy code in your projects to use ReadOnlySpan<char>. 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<char>. Compare memory allocations with existing libraries using a profiler.