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 new System.String instance on the heap and copies the characters into it.
  • Extracting a segment of an array typically requires creating a new array and using Array.Copy or 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:

  1. A managed pointer/reference (ref T) to the start of the memory block.
  2. 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 a ref struct and 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 await expressions or within yield return iterators, 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.