Span<T> & ReadOnlySpan<T>
Access memory slices efficiently without allocation or copying
Span & ReadOnlySpan
Master high-performance memory management with free flashcards and spaced repetition practice. This lesson covers Span
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
π― 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?
Span
βββββββββββββββββββββββββββββββββββββββββββ β MEMORY OWNERSHIP VS VIEWS β βββββββββββββββββββββββββββββββββββββββββββ€ β β β Traditional Array (OWNS memory): β β βββββ¬ββββ¬ββββ¬ββββ¬ββββ β β β 1 β 2 β 3 β 4 β 5 β (heap) β β βββββ΄ββββ΄ββββ΄ββββ΄ββββ β β β β Span(VIEW into memory): β β βββββ¬ββββ¬ββββ¬ββββ¬ββββ β β β 1 β 2 β 3 β 4 β 5 β (original) β β βββββ΄ββ¬ββ΄ββ¬ββ΄ββββ΄ββββ β β β β β β βββββ΄β Span points here β β (no copy, no allocation) β βββββββββββββββββββββββββββββββββββββββββββ
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 - Immutable Views
ReadOnlySpan
string text = "Hello, World!";
ReadOnlySpan<char> span = text.AsSpan();
// span[0] = 'h'; // β Compile error!
π‘ Tip: Use ReadOnlySpan
Stack-Only Constraint (ref struct)
The ref struct constraint is what makes Span
| β Allowed | β Not Allowed |
|---|---|
| Local variables | Class/struct fields |
| Method parameters | Boxing to object |
| Return values | Async/await methods |
| Stack arrays (stackalloc) | Lambda captures |
| Synchronous LINQ | Generic type arguments (pre-C# 11) |
// β
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
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
Memory<int> memory = new int[] { 1, 2, 3 };
Span<int> span = memory.Span;
Slicing and Subviews
One of Span
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]
βββββββββββββββββββββββββββββββββββββββββββ β 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!) β βββββββββββββββββββββββββββββββββββββββββββ
Common Operations
Span
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
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
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
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
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 π
| Operation | Traditional | Span | Benefit |
|---|---|---|---|
| Substring | O(n) + allocation | O(1) + zero allocation | β‘ Much faster |
| Array slice | Array.Copy + allocation | View creation | β‘ Zero copy |
| Buffer operations | Heap allocation + GC | Stack allocation | β‘ No GC pressure |
| Element access | Bounds check | Bounds check | β Same safety |
| String comparison | Allocates temp strings | Direct comparison | β‘ Zero allocation |
π§ Memory device: Remember "SPAN" = Stack Performance And No-allocation!
Span vs Memory π
While Span
| Feature | Span | Memory |
|---|---|---|
| Location | Stack only (ref struct) | Heap (regular struct) |
| Async support | β No | β Yes |
| Field storage | β No | β Yes |
| Performance | β‘ Fastest | β‘ Fast |
| Use case | Sync hot paths | Async, storage |
| Get Span | Direct use | .Span property |
// 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
is a view , not ownershipβit points to existing memory without copying or allocatingStack-only constraint (ref struct) enables incredible performance but limits usage scenarios
Zero-allocation slicing makes substring and array segment operations essentially free
ReadOnlySpan
should be your default choice when you don't need to modify data Use stackalloc with Span
for small temporary buffers (typically < 1KB) Memory
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
Performance matters: In hot paths, Span
can reduce allocations by 90%+ compared to traditional approaches
π Quick Reference Card
π» Span Cheat Sheet
| Create from array | Span<int> s = array; |
| Create from stack | Span<int> s = stackalloc int[10]; |
| Slice | span.Slice(start, length) or span[start..end] |
| Copy | source.CopyTo(dest) |
| Fill | span.Fill(value) |
| Clear | span.Clear() |
| Search | span.IndexOf(value) |
| Compare | span.SequenceEqual(other) |
| String to span | text.AsSpan() |
| For async | Use Memory<T> instead |
β οΈ Restrictions:
- β No fields in classes
- β No async methods
- β No boxing
- β No lambda captures
- β Local variables only
- β Method parameters/returns
π Further Study
- Microsoft Docs - Memory and Span Usage Guidelines: https://docs.microsoft.com/en-us/dotnet/standard/memory-and-spans/
- Stephen Toub's Span
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
: https://www.youtube.com/watch?v=byvoPD15CXs
π Practice tip: Try refactoring string-heavy code in your projects to use ReadOnlySpan
π¬ Advanced challenge: Implement a zero-allocation JSON parser for simple key-value pairs using ReadOnlySpan