Span<T> and ReadOnlySpan<T>
Stack-only ref structs for zero-allocation slicing
Span<T> and ReadOnlySpan<T>
Master modern .NET memory management with Span<T> and ReadOnlySpan<T>, complete with free flashcards and spaced repetition practice. This lesson covers stack-only allocation principles, memory slicing techniques, and safe high-performance buffer manipulationβessential concepts for building efficient .NET applications that minimize heap allocations and garbage collection pressure.
Welcome π»
Welcome to one of the most transformative features introduced in modern .NET! Span<T> and ReadOnlySpan<T> represent a paradigm shift in how we work with contiguous memory regions. These types enable zero-allocation slicing, provide unified access to arrays, stack memory, and native memory, and deliver performance that rivals unsafe codeβall while maintaining type safety.
π€ Did you know? The introduction of Span<T> in .NET Core 2.1 led to massive performance improvements across the entire framework. The ASP.NET Core team reduced allocations by over 50% in some scenarios simply by adopting these types!
Core Concepts
What Are Span<T> and ReadOnlySpan<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 "window" into memoryβwhether that memory lives on the stack, heap, or even in unmanaged memory.
ReadOnlySpan<T> is the immutable counterpart, preventing modifications to the underlying data. It's similar to the relationship between List<T> and IReadOnlyList<T>, but with far greater performance implications.
// Traditional approach - creates new arrays
int[] original = { 1, 2, 3, 4, 5, 6, 7, 8 };
int[] firstHalf = original.Take(4).ToArray(); // β Heap allocation!
int[] secondHalf = original.Skip(4).ToArray(); // β Another allocation!
// Modern approach - zero allocations
Span<int> span = original;
Span<int> firstHalfSpan = span.Slice(0, 4); // β
Just a view!
Span<int> secondHalfSpan = span.Slice(4, 4); // β
Another view!
The ref struct Constraint β‘
Both Span<T> and ReadOnlySpan<T> are declared as ref struct, which means they can only live on the stack. This is a deliberate design decision that enables their incredible performance characteristics:
<table> <tr><th>Can Do β </th><th>Cannot Do β</th></tr> <tr><td>Use as local variables</td><td>Be fields in classes</td></tr> <tr><td>Pass as method parameters</td><td>Be boxed to object</td></tr> <tr><td>Return from methods</td><td>Be used in async methods</td></tr> <tr><td>Use in stackalloc scenarios</td><td>Be captured by lambdas</td></tr> <tr><td>Store in other ref structs</td><td>Implement interfaces</td></tr> </table>
π§ Memory Device: Think "STACK ONLY" - Span stays on the STACK, Only No exceptions, Lambdas Yield problems!
Memory Safety and the Compiler's Role π
The C# compiler performs extensive safety analysis to ensure Span<T> instances never outlive the memory they reference:
// β Compiler error: Cannot use local variable in method that returns to caller
Span<int> DangerousMethod()
{
Span<int> local = stackalloc int[10];
return local; // ERROR: Would reference invalid stack memory!
}
// β
Safe: Span references heap memory with longer lifetime
Span<int> SafeMethod()
{
int[] array = new int[10];
return array.AsSpan(); // OK: Array outlives the method
}
π‘ Tip: The compiler's safety analysis is called ref safety rules. These rules track the lifetime scope of memory and ensure references never dangle.
Unified Memory Access Pattern π
One of Span<T>'s superpowers is providing a single API for working with memory from different sources:
<pre> βββββββββββββββββββββββββββββββββββββββββββββββ β UNIFIED SPAN<T> API β βββββββββββββββββββββββββββββββββββββββββββββββ€ β β β π¦ Array π§ stackalloc β β β β β β ββββ Span<T> ββββ β β β β β β β β π Native Memory β β β β Single API for slice, indexing, β β iteration, comparison, searching β βββββββββββββββββββββββββββββββββββββββββββββββ </pre>
void ProcessData(Span<byte> data)
{
// Same code works regardless of source!
for (int i = 0; i < data.Length; i++)
{
data[i] = (byte)(data[i] ^ 0xFF); // XOR operation
}
}
// All these work with the same method:
byte[] heapArray = new byte[100];
ProcessData(heapArray); // From heap
Span<byte> stackSpan = stackalloc byte[100];
ProcessData(stackSpan); // From stack
unsafe
{
byte* ptr = (byte*)Marshal.AllocHGlobal(100);
ProcessData(new Span<byte>(ptr, 100)); // From native memory
Marshal.FreeHGlobal((IntPtr)ptr);
}
Slicing Without Allocating πͺ
The killer feature of Span<T> is zero-allocation slicing. Traditional substring and array operations create new objects; Span<T> just adjusts pointers:
string text = "Hello, World! Welcome to Span<T>.";
// β Old way - allocates new strings
string part1 = text.Substring(0, 5); // Allocates "Hello"
string part2 = text.Substring(7, 5); // Allocates "World"
// β
New way - zero allocations
ReadOnlySpan<char> span = text.AsSpan();
ReadOnlySpan<char> hello = span.Slice(0, 5); // Just a view
ReadOnlySpan<char> world = span.Slice(7, 5); // Another view
// Even better: range syntax (C# 8.0+)
ReadOnlySpan<char> hello2 = span[0..5]; // Equivalent to Slice(0, 5)
ReadOnlySpan<char> world2 = span[7..12]; // Equivalent to Slice(7, 5)
<table> <tr><th>Operation</th><th>Traditional</th><th>With Span<T></th><th>Savings</th></tr> <tr><td>Substring</td><td>New string allocation</td><td>Pointer + length</td><td>~40 bytes + data</td></tr> <tr><td>Array segment</td><td>New array allocation</td><td>Pointer + length</td><td>~24 bytes + data</td></tr> <tr><td>Memory copy</td><td>Array.Copy</td><td>Direct memory access</td><td>Function call overhead</td></tr> </table>
Performance Characteristics β‘
Understanding the internal structure explains Span<T>'s performance:
// Simplified conceptual representation
public readonly ref struct Span<T>
{
private readonly ref T _reference; // Pointer to first element
private readonly int _length; // Number of elements
// Just 16 bytes on 64-bit systems!
// 8 bytes for reference + 8 bytes for length
}
<pre> MEMORY LAYOUT COMPARISON
Array (heap object): ββββββββββββββββββββββββββββββββββββββββ β Sync Block | Type Pointer | Length β 24 bytes overhead ββββββββββββββββββββββββββββββββββββββββ€ β Element 0 | Element 1 | Element 2 ...β + actual data ββββββββββββββββββββββββββββββββββββββββ β β Heap allocation required
Span<T> (stack structure): ββββββββββββββββββ¬ββββββββββ β Reference β Length β 16 bytes total (stack) ββββββββββ¬ββββββββ΄ββββββββββ β ββββ Points to existing memory (no new allocation) </pre>
Conversion and Interoperability π
Span<T> provides rich conversion capabilities:
// From array to Span
int[] array = { 1, 2, 3, 4, 5 };
Span<int> span1 = array; // Implicit conversion
Span<int> span2 = array.AsSpan(); // Explicit method
Span<int> span3 = new Span<int>(array); // Constructor
// From array segment
Span<int> partial = array.AsSpan(1, 3); // Elements [1], [2], [3]
// From stackalloc
Span<int> stack = stackalloc int[10];
// To array (requires allocation)
int[] backToArray = span1.ToArray(); // Creates new array
// ReadOnlySpan conversions
ReadOnlySpan<int> readOnly = array; // Implicit
ReadOnlySpan<int> fromSpan = span1; // Span<T> β ReadOnlySpan<T>
// Span<int> notAllowed = readOnly; // β Cannot convert back!
π‘ Tip: Use ReadOnlySpan<T> for method parameters when you don't need to modify the data. This allows both Span<T> and ReadOnlySpan<T> to be passed, following the principle of least privilege.
Practical Examples
Example 1: High-Performance String Parsing π
Parsing CSV data traditionally creates many temporary string objects. With ReadOnlySpan<char>, we eliminate these allocations:
public static class CsvParser
{
// β Traditional approach - many allocations
public static string[] ParseLineOld(string line)
{
return line.Split(','); // Allocates array AND strings
}
// β
Modern approach - zero allocations (except result list)
public static List<string> ParseLine(ReadOnlySpan<char> line)
{
var results = new List<string>();
while (line.Length > 0)
{
int commaIndex = line.IndexOf(',');
if (commaIndex == -1)
{
// Last field
results.Add(line.ToString()); // Only allocate final strings
break;
}
// Extract field without allocation
ReadOnlySpan<char> field = line.Slice(0, commaIndex);
results.Add(field.ToString());
// Move to next field (no allocation)
line = line.Slice(commaIndex + 1);
}
return results;
}
// π₯ Even better: Parse without string allocation
public static int SumCsvIntegers(ReadOnlySpan<char> line)
{
int sum = 0;
while (line.Length > 0)
{
int commaIndex = line.IndexOf(',');
ReadOnlySpan<char> field = commaIndex == -1
? line
: line.Slice(0, commaIndex);
// Parse directly from span (no string allocation)
if (int.TryParse(field, out int value))
{
sum += value;
}
if (commaIndex == -1) break;
line = line.Slice(commaIndex + 1);
}
return sum;
}
}
// Usage
string csvData = "100,200,300,400,500";
int total = CsvParser.SumCsvIntegers(csvData.AsSpan());
Console.WriteLine($"Total: {total}"); // Output: Total: 1500
Performance impact: In benchmarks, the span-based approach is 3-5x faster and allocates 10-20x less memory than traditional string operations.
Example 2: Safe Buffer Manipulation π‘οΈ
Working with byte buffers for network protocols or file I/O becomes both safer and faster:
public class PacketProcessor
{
// Protocol: [2-byte length][4-byte type][payload]
public static bool TryReadPacket(
ReadOnlySpan<byte> buffer,
out int packetType,
out ReadOnlySpan<byte> payload)
{
packetType = 0;
payload = ReadOnlySpan<byte>.Empty;
// Validate minimum size
if (buffer.Length < 6)
return false;
// Read length (first 2 bytes) - zero allocation
short payloadLength = BitConverter.ToInt16(buffer.Slice(0, 2));
// Validate total size
if (buffer.Length < 6 + payloadLength)
return false;
// Read type (next 4 bytes) - zero allocation
packetType = BitConverter.ToInt32(buffer.Slice(2, 4));
// Extract payload - just a view, no copy
payload = buffer.Slice(6, payloadLength);
return true;
}
public static void WritePacket(
Span<byte> buffer,
int packetType,
ReadOnlySpan<byte> payload)
{
// Write length
BitConverter.TryWriteBytes(buffer.Slice(0, 2), (short)payload.Length);
// Write type
BitConverter.TryWriteBytes(buffer.Slice(2, 4), packetType);
// Write payload - efficient copy
payload.CopyTo(buffer.Slice(6));
}
}
// Usage
byte[] networkBuffer = new byte[1024];
int bytesReceived = 100; // From network read
if (PacketProcessor.TryReadPacket(
networkBuffer.AsSpan(0, bytesReceived),
out int type,
out ReadOnlySpan<byte> data))
{
Console.WriteLine($"Packet type: {type}, Data length: {data.Length}");
// Process data without any copying
}
Example 3: Stack Allocation for Temporary Buffers π
Combining stackalloc with Span<T> enables extremely fast temporary buffer creation:
public static class StringHelpers
{
// Reverse a string efficiently
public static string Reverse(string input)
{
if (string.IsNullOrEmpty(input))
return input;
// Allocate on stack for small strings (very fast!)
// Fall back to heap for large strings
Span<char> buffer = input.Length <= 128
? stackalloc char[input.Length]
: new char[input.Length];
// Copy and reverse
for (int i = 0; i < input.Length; i++)
{
buffer[i] = input[input.Length - 1 - i];
}
return new string(buffer);
}
// Convert to uppercase without allocation (reading only)
public static bool EqualsIgnoreCase(
ReadOnlySpan<char> left,
ReadOnlySpan<char> right)
{
if (left.Length != right.Length)
return false;
return left.Equals(right, StringComparison.OrdinalIgnoreCase);
}
// Build a formatted string with minimal allocations
public static string FormatCoordinates(double x, double y, double z)
{
// Stack-allocate buffer for formatting
Span<char> buffer = stackalloc char[100];
int pos = 0;
"Point(".AsSpan().CopyTo(buffer.Slice(pos));
pos += 6;
// Format each coordinate
x.TryFormat(buffer.Slice(pos), out int written);
pos += written;
buffer[pos++] = ',';
buffer[pos++] = ' ';
y.TryFormat(buffer.Slice(pos), out written);
pos += written;
buffer[pos++] = ',';
buffer[pos++] = ' ';
z.TryFormat(buffer.Slice(pos), out written);
pos += written;
buffer[pos++] = ')';
return new string(buffer.Slice(0, pos));
}
}
// Usage
string reversed = StringHelpers.Reverse("Hello"); // "olleH"
bool same = StringHelpers.EqualsIgnoreCase("Test".AsSpan(), "TEST".AsSpan());
string coords = StringHelpers.FormatCoordinates(1.5, 2.7, 3.9);
π§ Memory Device: "128 is great" - Use stackalloc for buffers 128 bytes or less to avoid stack overflow risks.
Example 4: Working with Memory<T> for Async π
Since Span<T> cannot be used in async methods, .NET provides Memory<T> as a heap-based alternative:
public class AsyncBufferProcessor
{
// β Cannot use Span in async method
// public async Task ProcessAsync(Span<byte> data) { ... }
// β
Use Memory<T> instead
public async Task<int> ProcessAsync(Memory<byte> data)
{
// Simulate async I/O
await Task.Delay(100);
// Convert to Span when doing actual work
Span<byte> span = data.Span;
int sum = 0;
foreach (byte b in span)
{
sum += b;
}
return sum;
}
// Pattern: Accept Memory<T>, work with Span<T>
public async Task WriteToStreamAsync(
Stream stream,
Memory<byte> buffer,
int count)
{
// Memory<T> can be stored across await
await stream.WriteAsync(buffer.Slice(0, count));
// After await, convert to Span for processing
Span<byte> written = buffer.Span.Slice(0, count);
LogWrittenData(written); // Synchronous processing
}
private void LogWrittenData(Span<byte> data)
{
// Work with Span in non-async context
Console.WriteLine($"Wrote {data.Length} bytes");
}
}
// Usage
var processor = new AsyncBufferProcessor();
byte[] buffer = new byte[1024];
int result = await processor.ProcessAsync(buffer);
<div style="border: 2px solid #4fd1c5; border-radius: 8px; padding: 16px; margin: 16px 0; background: rgba(79, 209, 197, 0.1);"> <h4>π Span vs Memory Quick Reference</h4> <table> <tr><th>Feature</th><th>Span<T></th><th>Memory<T></th></tr> <tr><td><strong>Storage</strong></td><td>Stack only (ref struct)</td><td>Heap (regular struct)</td></tr> <tr><td><strong>Async support</strong></td><td>β No</td><td>β Yes</td></tr> <tr><td><strong>Performance</strong></td><td>β‘ Fastest</td><td>β‘ Fast (converts to Span)</td></tr> <tr><td><strong>Use case</strong></td><td>Synchronous processing</td><td>Async/await scenarios</td></tr> <tr><td><strong>Conversion</strong></td><td>N/A</td><td>memory.Span β Span<T></td></tr> </table> </div>
Common Mistakes β οΈ
Mistake 1: Trying to Store Span in a Class Field
// β WRONG - Compiler error!
public class DataProcessor
{
private Span<byte> _buffer; // ERROR: Cannot be a field
public DataProcessor(Span<byte> buffer)
{
_buffer = buffer; // Won't compile
}
}
// β
CORRECT - Use Memory<T> for storage
public class DataProcessor
{
private Memory<byte> _buffer; // OK: Memory can be stored
public DataProcessor(Memory<byte> buffer)
{
_buffer = buffer;
}
public void Process()
{
Span<byte> span = _buffer.Span; // Convert when needed
// Work with span...
}
}
Mistake 2: Using Span in Async Methods
// β WRONG - Compiler error!
public async Task ProcessAsync(Span<byte> data)
{
await Task.Delay(100); // ERROR: Span cannot cross await
ProcessData(data);
}
// β
CORRECT - Use Memory<T> for async
public async Task ProcessAsync(Memory<byte> data)
{
await Task.Delay(100); // OK: Memory can cross await
ProcessData(data.Span); // Convert to Span after await
}
private void ProcessData(Span<byte> data)
{
// Synchronous processing with Span
}
Mistake 3: Assuming Span Copies Data
// β οΈ DANGER - Modifying through span affects original!
int[] original = { 1, 2, 3, 4, 5 };
Span<int> span = original.AsSpan();
span[0] = 999;
Console.WriteLine(original[0]); // Output: 999 (not 1!)
// β
CORRECT - Use ToArray() to create a copy
int[] original = { 1, 2, 3, 4, 5 };
Span<int> span = original.AsSpan();
int[] copy = span.ToArray(); // Create independent copy
copy[0] = 999;
Console.WriteLine(original[0]); // Output: 1 (unchanged)
Console.WriteLine(copy[0]); // Output: 999
Mistake 4: Returning Stack-Allocated Spans
// β EXTREMELY DANGEROUS - Compiler prevents this!
Span<int> CreateBuffer()
{
Span<int> buffer = stackalloc int[10];
return buffer; // ERROR: Would reference invalid stack memory
}
// β
CORRECT - Return heap-allocated or parameter-based spans
Span<int> GetSlice(int[] array, int start, int length)
{
return array.AsSpan(start, length); // OK: array outlives method
}
int[] CreateInitializedArray()
{
Span<int> buffer = stackalloc int[10];
buffer.Fill(42);
return buffer.ToArray(); // OK: Converts to heap array
}
Mistake 5: Capturing Span in Lambdas or Closures
// β WRONG - Compiler error!
void ProcessItems(Span<int> items)
{
var query = items.Where(x => x > 0); // ERROR: Cannot capture Span
}
// β
CORRECT - Convert to array first, or avoid LINQ
void ProcessItems(Span<int> items)
{
// Option 1: Manual iteration
for (int i = 0; i < items.Length; i++)
{
if (items[i] > 0)
{
ProcessItem(items[i]);
}
}
// Option 2: Convert to array (if allocation is acceptable)
var array = items.ToArray();
var query = array.Where(x => x > 0);
}
Mistake 6: Excessive stackalloc Causing Stack Overflow
// β DANGER - May overflow stack!
void ProcessLargeData()
{
Span<byte> huge = stackalloc byte[1_000_000]; // 1 MB on stack! π₯
// ...
}
// β
CORRECT - Use threshold pattern
void ProcessData(int size)
{
const int StackAllocThreshold = 512; // Conservative limit
Span<byte> buffer = size <= StackAllocThreshold
? stackalloc byte[size] // Small: use stack
: new byte[size]; // Large: use heap
// Process buffer...
}
π‘ Tip: The typical stack size is 1 MB on Windows (64-bit). Keep stackalloc allocations under 128-512 bytes to be safe, especially in recursive or deeply nested code.
Key Takeaways π―
β Span<T> and ReadOnlySpan<T> are ref structs that provide type-safe, zero-allocation views into contiguous memory regions
β Stack-only constraint enables incredible performance but prevents use in async methods, as class fields, or with lambdas
β Slicing operations (Slice, indexers) create new views without copying dataβmodifications affect the underlying memory
β Memory<T> is the heap-based alternative for scenarios requiring async support or storage in fields
β stackalloc with Span enables ultra-fast temporary buffers, but keep allocations small (β€128-512 bytes)
β Unified API works with arrays, stack memory, and native memory through a single, consistent interface
β ReadOnlySpan<T> provides immutability guarantees and should be preferred for method parameters that don't modify data
β BitConverter and parsing APIs work directly with spans, enabling zero-allocation data transformations
<div style="border: 2px solid #4fd1c5; border-radius: 8px; padding: 16px; margin: 16px 0; background: rgba(79, 209, 197, 0.1);"> <h4>π Quick Reference Card</h4>
<table> <tr><th>Scenario</th><th>Use This</th><th>Why</th></tr> <tr><td>Synchronous processing</td><td>Span<T></td><td>Maximum performance</td></tr> <tr><td>Read-only parameter</td><td>ReadOnlySpan<T></td><td>Prevents modifications</td></tr> <tr><td>Async/await</td><td>Memory<T></td><td>Can cross await boundaries</td></tr> <tr><td>Store in field</td><td>Memory<T></td><td>Not a ref struct</td></tr> <tr><td>Small temp buffer</td><td>stackalloc + Span</td><td>Zero allocation</td></tr> <tr><td>Large buffer</td><td>ArrayPool or heap</td><td>Avoid stack overflow</td></tr> <tr><td>String operations</td><td>ReadOnlySpan<char></td><td>Avoid substring allocations</td></tr> <tr><td>Byte protocols</td><td>Span<byte></td><td>Direct memory access</td></tr> </table>
<h4>Common Operations</h4> <table> <tr><th>Operation</th><th>Code</th></tr> <tr><td>Create from array</td><td><code>array.AsSpan()</code></td></tr> <tr><td>Slice range</td><td><code>span[start..end]</code></td></tr> <tr><td>Get length</td><td><code>span.Length</code></td></tr> <tr><td>Access element</td><td><code>span[index]</code></td></tr> <tr><td>Copy data</td><td><code>source.CopyTo(dest)</code></td></tr> <tr><td>Fill with value</td><td><code>span.Fill(value)</code></td></tr> <tr><td>Find element</td><td><code>span.IndexOf(value)</code></td></tr> <tr><td>Convert to array</td><td><code>span.ToArray()</code></td></tr> <tr><td>Memory to Span</td><td><code>memory.Span</code></td></tr> </table> </div>
π Further Study
Official Documentation: Span<T> Struct - Microsoft Docs - Comprehensive API reference and usage guidelines
Performance Deep Dive: All About Span - MSDN Magazine - Detailed exploration of Span's implementation and performance characteristics
Memory<T> and Async Patterns: Memory<T> and Span<T> usage guidelines - Best practices for choosing between Span and Memory in real-world applications