Skip to main content

Span<T> and Memory<T>: High-Performance Patterns

Overview

Span\<T\> and Memory\<T\> are the foundation of modern high-performance .NET code. They enable zero-allocation operations by providing views over contiguous memory without copying data.

What is Span<T>?

Span\<T\> is a ref struct that provides a type-safe view over a contiguous region of memory.

// Span can wrap different memory sources
Span<int> fromArray = new int[] { 1, 2, 3, 4, 5 };
Span<int> fromStackalloc = stackalloc int[5] { 1, 2, 3, 4, 5 };

// Zero-cost slicing (no copying!)
Span<int> slice = fromArray.Slice(1, 3); // { 2, 3, 4 }
slice[0] = 10; // Modifies original array!

Console.WriteLine(fromArray[1]); // 10

Why Ref Struct?

// ❌ Can't use Span in async methods
public async Task ProcessAsync()
{
Span<byte> buffer = stackalloc byte[128];
await Task.Delay(1); // ERROR: Can't await with Span in scope
}

// ❌ Can't use Span as field
public class Container
{
private Span<byte> _buffer; // ERROR: Can't be a field
}

// ❌ Can't box Span
object obj = new Span<int>(); // ERROR: Can't box ref struct

Why these restrictions?

  • Span can point to stack memory
  • Stack memory is invalid after method returns
  • Async methods might resume on different thread/stack
  • Fields could outlive stack memory

Span<T> vs ReadOnlySpan<T>

// Span\<T\> - mutable
void ModifyData(Span<int> data)
{
data[0] = 42; // Can modify
}

// ReadOnlySpan\<T\> - immutable
void ReadData(ReadOnlySpan<int> data)
{
int first = data[0]; // Can read
// data[0] = 42; // ERROR: Can't modify
}

// String as ReadOnlySpan
ReadOnlySpan\<char\> text = "Hello World";
ReadOnlySpan\<char\> hello = text.Slice(0, 5); // No string allocation!

Memory<T>

Memory\<T\> is a struct (not ref struct) that can be used in async methods and as fields.

// ✅ Can use Memory in async methods
public async Task ProcessAsync()
{
Memory<byte> buffer = new byte[1024];
await Task.Delay(1); // No problem!

// Convert to Span when needed
Span<byte> span = buffer.Span;
}

// ✅ Can use Memory as field
public class DataProcessor
{
private readonly Memory<byte> _buffer = new byte[4096];

public async Task ProcessAsync()
{
await Task.Delay(1);
Span<byte> span = _buffer.Span; // Get Span when needed
}
}

Memory<T> vs ReadOnlyMemory<T>

public class Message
{
private readonly ReadOnlyMemory<byte> _data;

public Message(byte[] data)
{
_data = data; // Stores reference, no copy
}

public ReadOnlySpan<byte> GetData() => _data.Span;
}

Zero-Allocation String Parsing

Traditional Approach (Allocations!)

// ❌ Creates many string allocations
string ParseUserId(string input)
{
var parts = input.Split('|'); // Allocates array
return parts[0]; // Already a string, but Split created garbage
}

// Example: "user123|active|2024-01-01"
// Split allocates: string[] + 3 string objects

Span Approach (Zero Allocations!)

// ✅ No allocations!
ReadOnlySpan\<char\> ParseUserId(ReadOnlySpan\<char\> input)
{
int index = input.IndexOf('|');
return index > 0 ? input.Slice(0, index) : input;
}

// Usage
string input = "user123|active|2024-01-01";
ReadOnlySpan\<char\> userId = ParseUserId(input);

// If you need a string
string userIdString = userId.ToString(); // Only allocate if needed

Parsing Numbers

// ❌ Traditional: allocates substring
string input = "Value: 12345";
int value = int.Parse(input.Substring(7)); // Allocates!

// ✅ Span: zero allocation
ReadOnlySpan\<char\> input = "Value: 12345";
int value = int.Parse(input.Slice(7)); // No allocation!

Practical Examples

Example 1: CSV Parsing

public readonly struct CsvRow
{
private readonly ReadOnlyMemory\<char\> _line;

public CsvRow(string line) => _line = line.AsMemory();

public IEnumerable<ReadOnlyMemory\<char\>> GetFields()
{
var remaining = _line;
while (true)
{
var span = remaining.Span;
int comma = span.IndexOf(',');

if (comma < 0)
{
yield return remaining;
break;
}

yield return remaining.Slice(0, comma);
remaining = remaining.Slice(comma + 1);
}
}
}

// Usage
var row = new CsvRow("John,Doe,30,Engineer");
foreach (var field in row.GetFields())
{
Console.WriteLine(field.Span.ToString());
}
// No string allocations until ToString()!

Example 2: Binary Protocol Parsing

public readonly ref struct MessageParser
{
private readonly ReadOnlySpan<byte> _data;

public MessageParser(ReadOnlySpan<byte> data) => _data = data;

public int MessageType => _data[0];

public int Length => BitConverter.ToInt32(_data.Slice(1, 4));

public ReadOnlySpan<byte> Payload => _data.Slice(5, Length);
}

// Usage
byte[] data = GetNetworkData();
var parser = new MessageParser(data);

Console.WriteLine($"Type: {parser.MessageType}");
Console.WriteLine($"Length: {parser.Length}");
ProcessPayload(parser.Payload);
// Zero allocations!

Example 3: Stack Allocation for Small Buffers

// ❌ Heap allocation
public byte[] FormatMessage(int id, string text)
{
var buffer = new byte[1024]; // Heap allocation
// Format message
return buffer;
}

// ✅ Stack allocation for small buffers
public void FormatMessage(int id, string text, Span<byte> output)
{
Span<byte> buffer = stackalloc byte[256]; // Stack allocation!
// Format into buffer
buffer.CopyTo(output);
}

// Usage
Span<byte> result = stackalloc byte[256];
FormatMessage(123, "Hello", result);

ArrayPool<T>

For larger temporary buffers, use ArrayPool\<T\> instead of new[].

// ❌ Allocates and GC pressure
public void ProcessLargeData()
{
for (int i = 0; i < 1000; i++)
{
var buffer = new byte[8192]; // 1000 allocations!
// Process data
}
}

// ✅ Reuses buffers
public void ProcessLargeData()
{
for (int i = 0; i < 1000; i++)
{
var buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
Span<byte> span = buffer.AsSpan(0, 8192);
// Process data
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}

ArrayPool Best Practices

// ✅ Always return in finally
var buffer = ArrayPool<byte>.Shared.Rent(size);
try
{
// Use buffer
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}

// ✅ Clear sensitive data before returning
var buffer = ArrayPool<byte>.Shared.Rent(size);
try
{
// Store sensitive data
}
finally
{
Array.Clear(buffer, 0, size);
ArrayPool<byte>.Shared.Return(buffer);
}

// ✅ Rent might return larger array
var buffer = ArrayPool<byte>.Shared.Rent(100);
// buffer.Length might be 128, 256, etc.
// Always use requested size, not buffer.Length

Advanced Patterns

Pattern 1: MemoryPool<T>

For custom buffer management:

using var memoryOwner = MemoryPool<byte>.Shared.Rent(8192);
Memory<byte> memory = memoryOwner.Memory;

await stream.ReadAsync(memory);
// IMemoryOwner disposes automatically

Pattern 2: Custom Span Operations

public static class SpanExtensions
{
public static int Count\<T\>(this ReadOnlySpan\<T\> span, T value)
where T : IEquatable\<T\>
{
int count = 0;
foreach (var item in span)
{
if (item.Equals(value))
count++;
}
return count;
}
}

// Usage
ReadOnlySpan<int> numbers = stackalloc int[] { 1, 2, 3, 2, 1 };
int twos = numbers.Count(2); // 2, no allocations!

Pattern 3: Span-Based StringBuilder

public ref struct SpanBuilder
{
private Span\<char\> _buffer;
private int _position;

public SpanBuilder(Span\<char\> buffer)
{
_buffer = buffer;
_position = 0;
}

public void Append(ReadOnlySpan\<char\> text)
{
text.CopyTo(_buffer.Slice(_position));
_position += text.Length;
}

public void Append(int value)
{
if (value.TryFormat(_buffer.Slice(_position), out int written))
_position += written;
}

public ReadOnlySpan\<char\> AsSpan() => _buffer.Slice(0, _position);
}

// Usage
Span\<char\> buffer = stackalloc char[128];
var builder = new SpanBuilder(buffer);
builder.Append("ID: ");
builder.Append(12345);
string result = builder.AsSpan().ToString(); // "ID: 12345"

When to Use What?

Use Span<T> when:

  • ✅ Working with buffers in synchronous code
  • ✅ Performance is critical
  • ✅ Zero allocations needed
  • ✅ Slicing/parsing without copying

Use Memory<T> when:

  • ✅ Need to use in async methods
  • ✅ Need to store in fields
  • ✅ Need to pass across async boundaries
  • ✅ Working with APIs that require Memory<T>

Use ReadOnlySpan<T>/ReadOnlyMemory<T> when:

  • ✅ Data shouldn't be modified
  • ✅ Working with strings
  • ✅ Ensuring immutability

Use ArrayPool<T> when:

  • ✅ Need buffers larger than ~1KB
  • ✅ Buffers are temporary
  • ✅ Want to reduce GC pressure
  • ✅ Can ensure proper Return() calls

Use stackalloc when:

  • ✅ Buffers are small (less than 1KB)
  • ✅ Synchronous methods only
  • ✅ Short-lived allocations
  • ✅ Maximum performance needed

Common Pitfalls

// ❌ Don't capture Span in lambdas
ReadOnlySpan<int> data = stackalloc int[10];
Task.Run(() => Process(data)); // ERROR!

// ❌ Don't store Span in fields
class Container
{
private Span<byte> _buffer; // ERROR!
}

// ❌ Don't forget to Return() ArrayPool buffers
var buffer = ArrayPool<byte>.Shared.Rent(1024);
// ... no try/finally
// LEAK: Buffer never returned!

// ❌ Don't assume rented size
var buffer = ArrayPool<byte>.Shared.Rent(100);
// buffer.Length might be 128!
// Use your requested size (100), not buffer.Length

// ❌ Don't use Span with async/await
async Task ProcessAsync()
{
Span<byte> data = stackalloc byte[128];
await Task.Delay(1); // ERROR!
}

Performance Comparison

// Traditional: Many allocations
string ProcessTraditional(string input)
{
var parts = input.Split('|');
return parts[0].Substring(0, 5).ToUpper();
}
// Allocates: array + substring + ToUpper result

// Span: Zero allocations (until final ToString)
string ProcessWithSpan(string input)
{
var span = input.AsSpan();
var pipe = span.IndexOf('|');
var part = span.Slice(0, pipe > 0 ? pipe : span.Length);
var slice = part.Slice(0, Math.Min(5, part.Length));

Span\<char\> upper = stackalloc char[slice.Length];
slice.ToUpperInvariant(upper);

return upper.ToString(); // Only allocation
}

Interview Questions

Q: Why can't Span<T> be used in async methods?

A: Span<T> is a ref struct that can point to stack memory. Async methods can suspend and resume on different threads with different stacks, making stack pointers invalid. Use Memory<T> for async scenarios.

Q: What's the difference between Span<T> and Memory<T>?

A: Span<T> is a ref struct for synchronous code with zero-allocation slicing. Memory<T> is a regular struct that can be used in async methods and as fields, but requires converting to Span<T> for actual operations.

Q: When should you use ArrayPool vs stackalloc?

A: Use stackalloc for small buffers (less than 1KB) in synchronous methods. Use ArrayPool for larger buffers or when you need to pass them to async methods. stackalloc is faster but limited to stack size.

Q: How does Span<T> achieve zero-copy slicing?

A: Span<T> stores a reference to the start of the memory and a length. Slicing just creates a new Span with an adjusted reference and length - no data is copied.

Exercises

See exercises/day5-6.md for hands-on practice with:

  • Span-based string parsing
  • ArrayPool buffer management
  • Memory<T> async operations
  • Zero-allocation algorithms
  • Performance benchmarking