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 |
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 arraylocalPartβ new stringdomainPartβ 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
- β‘
stackallocfor 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 π‘οΈ
- Never return Span from a method if it references stack memory
- Always validate slice parameters before creating views
- Return ArrayPool buffers in finally blocks
- Convert Memory to Span only when ready to process synchronously
- Limit stackalloc size to avoid stack overflow
Performance Checklist β
- β
Replace
string.Substring()withAsSpan().Slice() - β
Use
Span<T>for local array operations - β
Pool large temporary buffers with
ArrayPool<T> - β
Prefer
ReadOnlySpan<char>for string comparisons - β
Use
stackallocfor 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
- Microsoft Docs: Memory and Span Usage Guidelines
- Span<T> Performance on .NET Blog
- ArrayPool Best Practices
π 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!