Reducing GC Pressure in C# with ReadOnlySpan<T> Slicing
Stop creating unnecessary string objects. Learn how to use ReadOnlySpan<T> and slicing to eliminate heap allocations in C# parsing logic and reduce GC overhead.
20 Sept 2025, 20:11 UTC

The Cost of String Slicing
When building parsers or processing large text files in C#, the most common pattern is using string.Substring(). While intuitive, Substring() creates a brand new string object on the managed heap for every call. In a high-throughput application processing thousands of lines per second, this creates massive amounts of short-lived objects, forcing the Garbage Collector (GC) to run frequently and causing "stop-the-world" pauses that spike latency.
The solution is ReadOnlySpan<T>. Instead of copying data into a new object, a ReadOnlySpan<T> acts as a window (or a "view") into existing memory. Slicing a span doesn't allocate new memory; it simply moves the window's start pointer and updates its length.
How ReadOnlySpan<T> Works
A ReadOnlySpan<T> is a ref struct. This is a critical distinction in C#: unlike standard structs, ref structs are guaranteed to live only on the stack. Because they cannot be moved to the managed heap, the runtime can provide direct, high-performance access to contiguous memory—whether that memory is in a managed array, a string, or unmanaged memory allocated via stackalloc.
The Slicing Mechanism
When you call .Slice(start, length) on a span, you aren't creating a subset of the data. You are creating a new span structure that points to the same memory address as the original, offset by the start value. This operation is O(1) and involves zero heap allocations.
Practical Example: Parsing a CSV Line
Consider a scenario where you need to extract a value from a comma-separated string. Using string.Split or Substring creates multiple string objects. Here is how to implement this using ReadOnlySpan<char>.
// Target: .NET Core 3.1+ or .NET 5/6/7/8
public static void ParseLine(string line)
{
// Convert the string to a ReadOnlySpan without copying
ReadOnlySpan<char> span = line.AsSpan();
int start = 0;
int commaIndex;
// Iterate through the span to find commas
while ((commaIndex = span.Slice(start).IndexOf(',')) != -1)
{
// Create a view of the segment between commas
ReadOnlySpan<char> segment = span.Slice(start, commaIndex);
// Process the segment (e.g., parse to int or compare)
ProcessSegment(segment);
start += commaIndex + 1;
}
// Handle the final segment
ProcessSegment(span.Slice(start));
}
private static void ProcessSegment(ReadOnlySpan<char> segment)
{
// Use Span-based parsing methods to avoid .ToString()
if (int.TryParse(segment, out int value))
{
// Use value
}
}
Verification and Risks
To verify the performance gain, run this logic through BenchmarkDotNet. You will observe that the ReadOnlySpan version reports 0 bytes of allocated memory per operation, whereas the Substring version scales linearly with the number of segments.
Critical Constraints of Ref Structs
Because ReadOnlySpan<T> is a ref struct, it comes with strict compiler limitations to ensure memory safety:
- No Class Fields: You cannot declare a
ReadOnlySpan<T>as a field in a class or a non-ref struct. It must be a local variable or a parameter. - No Async/Await: You cannot use spans inside
asyncmethods. This is becauseawaittransforms a method into a state machine that moves local variables to the heap, which violates the stack-only requirement ofref structs. - No Generics: You cannot use
ReadOnlySpan<T>as a type argument for generic classes (e.g.,List<ReadOnlySpan<char>>is illegal).
If you need to store a slice of memory in a class or use it across await boundaries, use Memory<T> instead. Memory<T> is a heap-compatible wrapper that can be converted back into a Span<T> using the .Span property when you are ready to perform the actual processing.
Summary Decision Matrix
| Requirement | Use Substring/Split | Use ReadOnlySpan<T> | Use Memory<T> |
|---|---|---|---|
| Simple logic, low volume | Yes | Overkill | Overkill |
| High-performance parsing | No (GC Pressure) | Yes | Only if needed for storage |
| Async/Await compatibility | Yes | No | Yes |
| Storage in a Class field | Yes | No | Yes |
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.