Span<T> & ReadOnlySpan<T>
Access memory slices efficiently without allocation or copying
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.
<pre> βββββββββββββββββββββββββββββββββββββββββββ β 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) β βββββββββββββββββββββββββββββββββββββββββββ </pre>
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:
<table> <tr><th>β Allowed</th><th>β Not Allowed</th></tr> <tr><td>Local variables</td><td>Class/struct fields</td></tr> <tr><td>Method parameters</td><td>Boxing to object</td></tr> <tr><td>Return values</td><td>Async/await methods</td></tr> <tr><td>Stack arrays (stackalloc)</td><td>Lambda captures</td></tr> <tr><td>Synchronous LINQ</td><td>Generic type arguments (pre-C# 11)</td></tr> </table>
// β
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]
<pre> βββββββββββββββββββββββββββββββββββββββββββ β 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!) β βββββββββββββββββββββββββββββββββββββββββββ </pre>
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 π
<table> <tr><th>Operation</th><th>Traditional</th><th>Span<T></th><th>Benefit</th></tr> <tr><td>Substring</td><td>O(n) + allocation</td><td>O(1) + zero allocation</td><td>β‘ Much faster</td></tr> <tr><td>Array slice</td><td>Array.Copy + allocation</td><td>View creation</td><td>β‘ Zero copy</td></tr> <tr><td>Buffer operations</td><td>Heap allocation + GC</td><td>Stack allocation</td><td>β‘ No GC pressure</td></tr> <tr><td>Element access</td><td>Bounds check</td><td>Bounds check</td><td>β Same safety</td></tr> <tr><td>String comparison</td><td>Allocates temp strings</td><td>Direct comparison</td><td>β‘ Zero allocation</td></tr> </table>
π§ Memory device: Remember "SPAN" = Stack Performance And No-allocation!
Span vs Memory<T> π
While Span<T> is powerful, sometimes you need Memory<T>:
<table> <tr><th>Feature</th><th>Span<T></th><th>Memory<T></th></tr> <tr><td><strong>Location</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>Field storage</strong></td><td>β No</td><td>β Yes</td></tr> <tr><td><strong>Performance</strong></td><td>β‘ Fastest</td><td>β‘ Fast</td></tr> <tr><td><strong>Use case</strong></td><td>Sync hot paths</td><td>Async, storage</td></tr> <tr><td><strong>Get Span</strong></td><td>Direct use</td><td>.Span property</td></tr> </table>
// 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 π―
Span<T> is a view, not ownershipβit points to existing memory without copying or allocating
Stack-only constraint (ref struct) enables incredible performance but limits usage scenarios
Zero-allocation slicing makes substring and array segment operations essentially free
ReadOnlySpan<T> should be your default choice when you don't need to modify data
Use stackalloc with Span<T> for small temporary buffers (typically < 1KB)
Memory<T> bridges the gap when you need async support or field storage
Avoid ToString() on spans when possibleβuse SequenceEqual, StartsWith, or other span methods
Check bounds before slicing to avoid runtime exceptions
String parsing becomes allocation-free with ReadOnlySpan<char>
Performance matters: In hot paths, Span<T> can reduce allocations by 90%+ compared to traditional approaches
π Quick Reference Card
<div style="border: 2px solid #4fd1c5; border-radius: 8px; padding: 16px; margin: 16px 0; background: rgba(79, 209, 197, 0.1);"> <h4>π» Span<T> Cheat Sheet</h4> <table> <tr><td><strong>Create from array</strong></td><td><code>Span<int> s = array;</code></td></tr> <tr><td><strong>Create from stack</strong></td><td><code>Span<int> s = stackalloc int[10];</code></td></tr> <tr><td><strong>Slice</strong></td><td><code>span.Slice(start, length)</code> or <code>span[start..end]</code></td></tr> <tr><td><strong>Copy</strong></td><td><code>source.CopyTo(dest)</code></td></tr> <tr><td><strong>Fill</strong></td><td><code>span.Fill(value)</code></td></tr> <tr><td><strong>Clear</strong></td><td><code>span.Clear()</code></td></tr> <tr><td><strong>Search</strong></td><td><code>span.IndexOf(value)</code></td></tr> <tr><td><strong>Compare</strong></td><td><code>span.SequenceEqual(other)</code></td></tr> <tr><td><strong>String to span</strong></td><td><code>text.AsSpan()</code></td></tr> <tr><td><strong>For async</strong></td><td>Use <code>Memory<T></code> instead</td></tr> </table>
<strong>β οΈ Restrictions:</strong> <ul> <li>β No fields in classes</li> <li>β No async methods</li> <li>β No boxing</li> <li>β No lambda captures</li> <li>β Local variables only</li> <li>β Method parameters/returns</li> </ul> </div>
π Further Study
- Microsoft Docs - Memory and Span Usage Guidelines: https://docs.microsoft.com/en-us/dotnet/standard/memory-and-spans/
- 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
- 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.