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

Memory Views

Work with contiguous memory using Span, Memory, and related types

Memory Views in C#

Manage high-performance memory operations with Span<T> and Memory<T> for zero-allocation slicing and efficient data manipulation. This lesson covers memory views, stack vs. heap allocation, and practical techniquesβ€”essential concepts for performance-critical C# development. Practice with free flashcards to master these powerful tools for optimizing memory access patterns.

Welcome πŸ‘‹

Welcome to Memory Views in C#! If you've ever needed to work with slices of arrays without copying data, or wanted to write algorithms that work seamlessly with both arrays and strings, then Span<T>, Memory<T>, and ReadOnlySpan<T> are your best friends. These types represent views over contiguous memory regions, allowing you to reference portions of data structures without allocating additional memory.

πŸ’‘ Why Memory Views Matter:

  • Zero allocations when slicing arrays or strings
  • Unified API for arrays, strings, native memory, and stack memory
  • Performance gains in parsing, serialization, and data processing
  • Type safety while working with raw memory

In this lesson, we'll explore how memory views work, when to use Span<T> vs Memory<T>, and practical patterns for high-performance C# code.


Core Concepts 🧠

What Are Memory Views?

A memory view is a lightweight reference to a contiguous region of memory. Instead of copying data, views provide a "window" into existing memory. Think of it like looking at a section of a book through a magnifying glassβ€”you're examining part of the book without tearing out pages.

Original Array:  [10][20][30][40][50][60][70][80]
                      ↑           ↑
Span View:           [30][40][50]
                  (no copy, just reference)

The Three Key Types

Type Storage Can Modify Use Case
Span<T> Stack only (ref struct) βœ… Yes High-performance, local operations
ReadOnlySpan<T> Stack only (ref struct) ❌ No Read-only views, string parsing
Memory<T> Heap (can box) βœ… Yes (via Span) Async methods, field storage

Span<T>: The Performance Champion πŸ†

Span<T> is a ref struct, meaning it can only live on the stack. This restriction allows the JIT compiler to optimize it aggressively:

int[] array = { 1, 2, 3, 4, 5, 6, 7, 8 };
Span<int> slice = array.AsSpan(2, 4); // Elements [3, 4, 5, 6]

slice[0] = 99; // Modifies array[2] directly
Console.WriteLine(array[2]); // Output: 99

Key characteristics:

  • ⚑ Zero overhead - compiles to direct memory access
  • 🚫 Cannot be boxed or stored in fields (stack-only)
  • 🎯 Cannot cross await boundaries in async methods
  • πŸ”§ Perfect for local algorithms and data processing

ReadOnlySpan<T>: Safe Immutability πŸ”’

ReadOnlySpan<T> provides read-only access, which is crucial for string processing and working with const data:

string text = "Hello, World!";
ReadOnlySpan<char> span = text.AsSpan(0, 5); // "Hello"

// span[0] = 'h'; // ❌ Compile error - read-only!

if (span.SequenceEqual("Hello".AsSpan()))
{
    Console.WriteLine("Match!"); // βœ… Zero allocations
}

πŸ’‘ Pro Tip: String methods like Substring allocate new strings, but AsSpan with slicing creates views with zero allocations.

Memory<T>: Async-Friendly Alternative πŸ”„

Memory<T> is a regular struct (not a ref struct), so it can:

  • Be stored in fields and properties
  • Cross await boundaries in async methods
  • Be boxed and stored in collections
public class DataProcessor
{
    private Memory<byte> _buffer; // βœ… Can store as field
    
    public async Task ProcessAsync(Memory<byte> data)
    {
        // βœ… Can use Memory in async methods
        await Task.Delay(100);
        
        // Convert to Span for actual processing
        Span<byte> span = data.Span;
        for (int i = 0; i < span.Length; i++)
        {
            span[i] = (byte)(span[i] * 2);
        }
    }
}
Memory vs Span Decision Tree

    Need to use in async method?
           β”œβ”€ YES β†’ Use Memory
           └─ NO β†’ Can store in field?
                    β”œβ”€ YES β†’ Use Memory
                    └─ NO β†’ Use Span (fastest)

Creating Memory Views πŸ”¨

Source Create Span Create Memory
Array array.AsSpan() array.AsMemory()
String str.AsSpan() str.AsMemory()
Stack memory stackalloc int[10] ❌ Not supported
Native pointer new Span(ptr, length) Via MemoryManager

Slicing Operations βœ‚οΈ

Slicing creates a view of a subsection without copying:

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

// Traditional approach (allocates new array)
int[] oldWay = numbers.Skip(3).Take(4).ToArray();

// Modern approach (zero allocation)
Span<int> modernWay = numbers.AsSpan(3, 4); // [3, 4, 5, 6]

// Chain slicing operations
Span<int> slice1 = numbers.AsSpan(2, 6);    // [2, 3, 4, 5, 6, 7]
Span<int> slice2 = slice1.Slice(1, 3);      // [3, 4, 5]
Original:  [0][1][2][3][4][5][6][7][8][9]
                    ↑start=3, length=4
Slice:             [3][4][5][6]
                        ↑further slice(1,3)
Slice2:                [4][5][6]

Stack Allocation with Span πŸ“š

For small, temporary buffers, you can allocate directly on the stack:

public void ProcessSmallData()
{
    // Allocate 256 bytes on stack (no GC pressure)
    Span<byte> buffer = stackalloc byte[256];
    
    // Fill with data
    for (int i = 0; i < buffer.Length; i++)
    {
        buffer[i] = (byte)(i % 256);
    }
    
    // Process without heap allocation
    int sum = 0;
    foreach (byte b in buffer)
    {
        sum += b;
    }
    // Stack memory automatically freed when method returns
}

⚠️ Warning: Stack space is limited (~1MB typically). Use stackalloc only for small buffers (< 1KB recommended). For larger buffers, use ArrayPool<T>.Shared.Rent().

Working with Strings Efficiently πŸ“

String operations traditionally create many temporary allocations. ReadOnlySpan<char> eliminates most of them:

public bool IsValidEmail(string email)
{
    ReadOnlySpan<char> span = email.AsSpan();
    
    int atIndex = span.IndexOf('@');
    if (atIndex == -1) return false;
    
    // Split without allocation
    ReadOnlySpan<char> localPart = span.Slice(0, atIndex);
    ReadOnlySpan<char> domainPart = span.Slice(atIndex + 1);
    
    // Check parts
    return localPart.Length > 0 && 
           domainPart.Length > 0 && 
           domainPart.Contains('.', StringComparison.Ordinal);
}

Traditional approach allocations:

  • Split('@') β†’ new string array
  • localPart β†’ new string
  • domainPart β†’ new string

Span approach allocations: Zero! πŸŽ‰

Common Operations πŸ”§

Operation Span/ReadOnlySpan Method Description
Copy data source.CopyTo(destination) Fast memory copy
Fill with value span.Fill(value) Set all elements
Clear/zero span.Clear() Set to default(T)
Find element span.IndexOf(item) Returns index or -1
Compare span.SequenceEqual(other) Element-wise equality
Reverse span.Reverse() In-place reversal

Examples πŸ’»

Example 1: High-Performance String Parsing

Scenario: Parse CSV data without allocating strings for each field.

public struct CsvRow
{
    public ReadOnlySpan<char> GetField(ReadOnlySpan<char> line, int fieldIndex)
    {
        int currentField = 0;
        int start = 0;
        
        for (int i = 0; i < line.Length; i++)
        {
            if (line[i] == ',')
            {
                if (currentField == fieldIndex)
                {
                    return line.Slice(start, i - start);
                }
                currentField++;
                start = i + 1;
            }
        }
        
        // Last field (no trailing comma)
        if (currentField == fieldIndex)
        {
            return line.Slice(start);
        }
        
        return ReadOnlySpan<char>.Empty;
    }
}

// Usage:
string csvLine = "John,Doe,30,Engineer";
var parser = new CsvRow();

ReadOnlySpan<char> firstName = parser.GetField(csvLine.AsSpan(), 0);
ReadOnlySpan<char> age = parser.GetField(csvLine.AsSpan(), 2);

Console.WriteLine($"Name: {firstName.ToString()}, Age: {age.ToString()}");
// Output: Name: John, Age: 30

Why this is fast:

  • βœ… No string allocations during parsing
  • βœ… Direct indexing into original string
  • βœ… Only allocate when calling .ToString() on final results

Example 2: Binary Data Processing with Span

Scenario: Process network packets by reading headers and payloads.

public class PacketProcessor
{
    public void ProcessPacket(Span<byte> packet)
    {
        if (packet.Length < 8)
        {
            throw new ArgumentException("Packet too short");
        }
        
        // Read header (first 8 bytes)
        Span<byte> header = packet.Slice(0, 8);
        int packetType = header[0];
        int payloadLength = BitConverter.ToInt32(header.Slice(4, 4));
        
        // Read payload
        Span<byte> payload = packet.Slice(8, payloadLength);
        
        // Process based on type
        switch (packetType)
        {
            case 1:
                ProcessDataPacket(payload);
                break;
            case 2:
                ProcessControlPacket(payload);
                break;
        }
    }
    
    private void ProcessDataPacket(Span<byte> data)
    {
        // Directly modify data in place
        for (int i = 0; i < data.Length; i++)
        {
            data[i] ^= 0xFF; // XOR decrypt
        }
    }
    
    private void ProcessControlPacket(Span<byte> data)
    {
        // Handle control packet
    }
}

Benefits:

  • 🎯 Zero allocations for slicing packet sections
  • ⚑ In-place modifications without copying
  • πŸ”’ Type-safe access to binary data

Example 3: ArrayPool with Memory Views

Scenario: Rent temporary buffers without GC allocations.

using System.Buffers;

public class DataCompressor
{
    public byte[] CompressData(byte[] input)
    {
        // Rent buffer from pool (avoid allocation)
        byte[] buffer = ArrayPool<byte>.Shared.Rent(input.Length * 2);
        
        try
        {
            // Work with buffer as a Span
            Span<byte> bufferSpan = buffer.AsSpan(0, input.Length);
            input.AsSpan().CopyTo(bufferSpan);
            
            // Perform compression (example: simple RLE)
            int compressedLength = RunLengthEncode(bufferSpan);
            
            // Return only the used portion
            byte[] result = new byte[compressedLength];
            buffer.AsSpan(0, compressedLength).CopyTo(result);
            return result;
        }
        finally
        {
            // Always return buffer to pool
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }
    
    private int RunLengthEncode(Span<byte> data)
    {
        // Simplified RLE implementation
        int writePos = 0;
        for (int i = 0; i < data.Length; )
        {
            byte value = data[i];
            int count = 1;
            
            while (i + count < data.Length && 
                   data[i + count] == value && 
                   count < 255)
            {
                count++;
            }
            
            data[writePos++] = (byte)count;
            data[writePos++] = value;
            i += count;
        }
        return writePos;
    }
}

Pattern benefits:

  • ♻️ Reuses buffers from pool (reduces GC pressure)
  • πŸš€ Fast slicing and manipulation with Span
  • πŸ›‘οΈ Safe with try-finally to ensure buffer return

Example 4: Async Processing with Memory<T>

Scenario: Asynchronous file processing that needs memory views.

public class AsyncFileProcessor
{
    private readonly byte[] _buffer = new byte[4096];
    
    public async Task<int> ProcessFileAsync(string path)
    {
        using FileStream fs = new FileStream(path, FileMode.Open);
        int totalProcessed = 0;
        
        while (true)
        {
            // Use Memory<byte> for async operations
            Memory<byte> memory = _buffer.AsMemory();
            int bytesRead = await fs.ReadAsync(memory);
            
            if (bytesRead == 0) break;
            
            // Convert to Span for synchronous processing
            Span<byte> span = memory.Span.Slice(0, bytesRead);
            ProcessChunk(span);
            
            totalProcessed += bytesRead;
        }
        
        return totalProcessed;
    }
    
    private void ProcessChunk(Span<byte> data)
    {
        // Fast synchronous processing
        for (int i = 0; i < data.Length; i++)
        {
            if (data[i] == 0x00)
            {
                data[i] = 0xFF; // Replace null bytes
            }
        }
    }
}

Why use Memory here:

  • βœ… ReadAsync(Memory<byte>) requires Memory, not Span
  • βœ… Can await without restrictions
  • βœ… Convert to Span when needed for fast operations

Common Mistakes ⚠️

Mistake 1: Storing Span in a Field

// ❌ WRONG - Span cannot be a field
public class BadExample
{
    private Span<int> _data; // Compile error: ref struct in non-ref struct
}

// βœ… RIGHT - Use Memory<T> for fields
public class GoodExample
{
    private Memory<int> _data; // Works fine
    
    public void Process()
    {
        Span<int> span = _data.Span; // Get Span when needed
    }
}

Why: Span<T> is a ref struct that must live on the stack. Use Memory<T> for storage.

Mistake 2: Using Span Across Await

// ❌ WRONG
public async Task ProcessAsync(int[] data)
{
    Span<int> span = data.AsSpan();
    await Task.Delay(100); // Compile error: span used across await
    span[0] = 42;
}

// βœ… RIGHT - Use Memory or split into sync sections
public async Task ProcessAsync(int[] data)
{
    Memory<int> memory = data.AsMemory();
    await Task.Delay(100);
    memory.Span[0] = 42; // Get span after await
}

// βœ… ALTERNATIVE - Process before await
public async Task ProcessAsync(int[] data)
{
    Span<int> span = data.AsSpan();
    span[0] = 42; // Process first
    await Task.Delay(100); // Then await
}

Mistake 3: Excessive Stack Allocation

// ❌ DANGEROUS - Too large for stack
public void ProcessLargeData()
{
    Span<byte> buffer = stackalloc byte[100000]; // May cause stack overflow!
}

// βœ… RIGHT - Use ArrayPool for larger buffers
public void ProcessLargeData()
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(100000);
    try
    {
        Span<byte> span = buffer.AsSpan(0, 100000);
        // Process...
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

Rule of thumb: Use stackalloc only for buffers < 1KB.

Mistake 4: Assuming Span Lifetime

// ❌ DANGEROUS - Span may reference freed stack memory
public Span<int> GetStackData()
{
    Span<int> local = stackalloc int[10];
    return local; // Compile error: cannot return ref struct
}

// βœ… RIGHT - Return array or use caller-provided buffer
public int[] GetData()
{
    int[] array = new int[10];
    // Fill array
    return array;
}

public void GetData(Span<int> destination)
{
    if (destination.Length < 10)
        throw new ArgumentException("Buffer too small");
    // Fill destination directly
}

Mistake 5: Ignoring Bounds Checking

// ❌ WRONG - May throw IndexOutOfRangeException
public void UnsafeSlice(int[] data, int start, int length)
{
    Span<int> span = data.AsSpan(start, length); // No validation
}

// βœ… RIGHT - Validate before slicing
public void SafeSlice(int[] data, int start, int length)
{
    if (start < 0 || length < 0 || start + length > data.Length)
    {
        throw new ArgumentOutOfRangeException("Invalid slice parameters");
    }
    Span<int> span = data.AsSpan(start, length);
}

// βœ… ALTERNATIVE - Use TrySlice pattern
public bool TryGetSlice(int[] data, int start, int length, out Span<int> result)
{
    if (start >= 0 && length >= 0 && start + length <= data.Length)
    {
        result = data.AsSpan(start, length);
        return true;
    }
    result = Span<int>.Empty;
    return false;
}

Key Takeaways 🎯

πŸ“‹ Quick Reference Card

Type When to Use Restrictions
Span<T> Local, high-performance operations Stack only, no async, no fields
ReadOnlySpan<T> Read-only views, string parsing Same as Span, cannot modify
Memory<T> Async methods, fields, storage Slight overhead vs Span

Core Principles:

  • 🎯 Use Span<T> for local, synchronous operations
  • πŸ”„ Use Memory<T> when you need async or storage
  • πŸ“¦ Use ArrayPool<T> for temporary large buffers
  • βœ‚οΈ Slicing is freeβ€”no allocations
  • ⚑ stackalloc for small buffers only (< 1KB)

Performance Wins:

  • Zero-allocation string slicing with AsSpan()
  • In-place array manipulation
  • Unified API for arrays, strings, and native memory
  • Type-safe alternative to pointers

Common Patterns:

// Pattern 1: String parsing
ReadOnlySpan<char> text = input.AsSpan();
ReadOnlySpan<char> part = text.Slice(start, length);

// Pattern 2: Buffer pooling
var buffer = ArrayPool<byte>.Shared.Rent(size);
try { /* use buffer */ }
finally { ArrayPool<byte>.Shared.Return(buffer); }

// Pattern 3: Stack allocation
Span<int> temp = stackalloc int[64];

// Pattern 4: Async with Memory
Memory<byte> mem = buffer.AsMemory();
int read = await stream.ReadAsync(mem);
Span<byte> span = mem.Span.Slice(0, read);

Memory Safety Rules πŸ›‘οΈ

  1. Never return Span from a method if it references stack memory
  2. Always validate slice parameters before creating views
  3. Return ArrayPool buffers in finally blocks
  4. Convert Memory to Span only when ready to process synchronously
  5. Limit stackalloc size to avoid stack overflow

Performance Checklist βœ…

  • βœ… Replace string.Substring() with AsSpan().Slice()
  • βœ… Use Span<T> for local array operations
  • βœ… Pool large temporary buffers with ArrayPool<T>
  • βœ… Prefer ReadOnlySpan<char> for string comparisons
  • βœ… Use stackalloc for small temporary buffers
  • βœ… Avoid boxing Span (it's a ref struct)
  • βœ… Profile to verify allocation reduction

🧠 Memory Aid: Span for Synchronous, Stack, Speed. Memory for Multiple contexts, Methods (async), Membership (fields).


πŸ“š Further Study


πŸŽ‰ Congratulations! You now understand how to use Span<T>, Memory<T>, and related types to write high-performance, allocation-free C# code. Practice these patterns in your performance-critical code paths to see significant improvements in throughput and reduced GC pressure!