Zero-Allocation Memory Slicing with Span in C
This article provides an overview of Span<T> and
ReadOnlySpan<T> in C#, examining how these types
represent contiguous regions of arbitrary memory and enable
zero-allocation memory slicing. You will learn the structural
differences between traditional heap-allocated slicing and span-based
slicing, how Span<T> works under the hood as a
ref struct, and how to implement it to eliminate Garbage
Collection (GC) overhead in performance-critical applications.
What Are Span<T> and ReadOnlySpan<T>?
Introduced in .NET Core 2.1 and C# 7.2, Span<T>
and ReadOnlySpan<T> are value types that provide a
type-safe, memory-safe representation of a contiguous region of
arbitrary memory. Unlike arrays, which are always managed objects on the
heap, a Span<T> can point to:
- Managed heap memory (such as a standard C# array)
- Stack-allocated memory (allocated via
stackalloc) - Unmanaged/native heap memory (allocated via native APIs or pointers)
ReadOnlySpan<T> provides the same capabilities as
Span<T>, but enforces read-only semantics. This makes
ReadOnlySpan<char> an ideal replacement for strings
during parsing and text-processing tasks.
The Problem with Traditional Memory Slicing
In standard C#, extracting a subset of data typically requires allocating new memory on the managed heap:
- Calling
string.Substring()creates a newSystem.Stringinstance on the heap and copies the characters into it. - Extracting a segment of an array typically requires creating a new
array and using
Array.Copyor LINQ's.Skip().Take().
In high-throughput scenarios—such as web servers, serialization pipelines, or file parsers—these repetitive allocations create significant pressure on the Garbage Collector (GC), leading to CPU spikes and latency pauses.
How Span Enables Zero-Allocation Slicing
Under the hood, Span<T> is defined as a
ref struct that contains only two fields:
- A managed pointer/reference (
ref T) to the start of the memory block. - An integer representing the length of the block.
Because a span merely holds a reference and a length, "slicing" a
span does not copy the underlying data. Instead, the
.Slice(int start, int length) method calculates a new
offset for the pointer and sets the new length, returning a new
Span<T> that views the exact same memory.
Traditional Slicing vs. Span Slicing Example
Consider parsing a date string formatted as
2026-03-30:
// Traditional approach: Allocates three new strings on the heap
string date = "2026-03-30";
string yearStr = date.Substring(0, 4); // Allocation 1
string monthStr = date.Substring(5, 2); // Allocation 2
string dayStr = date.Substring(8, 2); // Allocation 3
int year = int.Parse(yearStr);
int month = int.Parse(monthStr);
int day = int.Parse(dayStr);Using ReadOnlySpan<char>, the operation produces
zero heap allocations:
// Span approach: Zero heap allocations
string date = "2026-03-30";
ReadOnlySpan<char> dateSpan = date.AsSpan();
ReadOnlySpan<char> yearSpan = dateSpan.Slice(0, 4); // View into original string
ReadOnlySpan<char> monthSpan = dateSpan.Slice(5, 2); // View into original string
ReadOnlySpan<char> daySpan = dateSpan.Slice(8, 2); // View into original string
// .NET parses directly from ReadOnlySpan<char>
int year = int.Parse(yearSpan);
int month = int.Parse(monthSpan);
int day = int.Parse(daySpan);In this example, dateSpan.Slice() merely performs
pointer arithmetic. No new memory is requested from the GC.
Working with Stack-Allocated and Native Memory
Span<T> unifies memory access patterns across
different memory types. For short-lived, small buffers, memory can be
allocated directly on the stack to bypass the heap entirely:
Span<byte> buffer = stackalloc byte[128];
buffer[0] = 255;
Span<byte> slice = buffer.Slice(0, 64);
slice.Fill(0);Regardless of whether the data comes from a managed array, the
execution stack, or native interop, APIs accepting
Span<T> or ReadOnlySpan<T>
interact with the memory identically.
Constraints of Span<T>
Because Span<T> and
ReadOnlySpan<T> can reference memory on the execution
stack, the C# compiler enforces strict rules to prevent
stack-use-after-return bugs:
- Stack-only life cycle:
Span<T>is aref structand can only exist on the thread stack. - No boxing: A span cannot be cast to
object,ValueType, or any interface. - No class fields: A span cannot be a field of a regular class or a non-ref struct.
- No asynchronous boundaries: Spans cannot be used
across
awaitexpressions or withinyield returniterators, as compiler-generated state machines move state to the heap.
For asynchronous scenarios requiring heap-safe memory
representations, .NET provides Memory<T> and
ReadOnlyMemory<T>, which can live on the heap and be
converted to spans on demand via .Span.