Skip to Content
Course content

84: Span<T> and Memory<T> for Performance

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you have a massive, 50-foot-long scroll of parchment containing a giant list of data. You need to give a specific section of that list to a colleague so they can analyze it. In the "old way" of doing things, you'd take a pair of scissors, cut out the relevant section, and hand them a brand new, smaller piece of paper. That's fine for one person, but if you do that a thousand times a second, you're going to end up with a floor covered in paper scraps (or in our world, a Garbage Collector that's working overtime and freezing your app).

Now, imagine a different approach. Instead of cutting the scroll, you just hand your colleague a viewfinder—a small cardboard frame. You slide that frame over the specific part of the scroll they need and say, "Look only at what's inside this box." You haven't created any new paper; you've just defined a window into the existing data.

That's exactly what Span<T> and Memory<T> do for your memory:

  • The Scroll is your original array, string, or unmanaged memory buffer.
  • The Viewfinder is the Span<T>. It doesn't own the data; it just points to a start position and a length.
  • Sliding the frame is what we call "slicing." You can move the window around without ever copying the underlying data.

Stop Copying Everything

I've spent way too many hours profiling apps where the biggest bottleneck was string.Substring(). Every time you call Substring, .NET allocates a brand new string on the heap. If you're parsing a 100MB log file and slicing it into pieces, you're generating massive amounts of garbage for the GC to clean up.

With ReadOnlySpan<char>, you can parse that same log file without a single new allocation. Here is how that looks in practice:

string logEntry = "2023-10-01|ERROR|Database connection failed";

// The old way: This creates 3 new strings on the heap
string date = logEntry.Substring(0, 10); 
string level = logEntry.Substring(11, 5);
string message = logEntry.Substring(17);

// The high-performance way: No new strings created
ReadOnlySpan<char> span = logEntry.AsSpan();
ReadOnlySpan<char> dateSpan = span.Slice(0, 10);
ReadOnlySpan<char> levelSpan = span.Slice(11, 5);
ReadOnlySpan<char> messageSpan = span.Slice(17);

// You can still use them for comparisons or parsing
if (levelSpan.SequenceEqual("ERROR")) 
{
    // Handle error...
}

The Stack-Only Nature of Span

Here is the catch: Span<T> is a ref struct. In plain English, that means it must live on the stack. It cannot be a field in a class, it cannot be boxed, and it definitely cannot be used inside an async method (because async methods transform your code into a state machine that lives on the heap).

I've seen a lot of developers get frustrated when the compiler screams at them for trying to use a Span in an await block. This isn't a bug; it's a safety feature to prevent the "viewfinder" from pointing to memory that has already been cleared or moved while the method was suspended.

When you need the heap: Memory<T>

This is where Memory<T> comes in. Think of Memory<T> as a "stored version" of a span. It's a regular struct, so it can live on the heap, be a field in a class, or be passed across async boundaries. It's slightly less performant than Span, but it's far more flexible.

The workflow usually looks like this: You store your data as Memory<T>, and the moment you need to actually do work with it (like a loop or a calculation), you call .Span to get a high-speed Span<T> for the local operation.

public class LogProcessor
{
    private Memory<char> _buffer;

    public async Task ProcessLogAsync(Memory<char> data)
    {
        _buffer = data;
        await Task.Delay(10); // We can hold Memory<T> across an await

        // Now we convert it to a Span to do the actual work
        ReadOnlySpan<char> span = _buffer.Span;
        if (span.StartsWith("ERROR"))
        {
            Console.WriteLine("Found an error!");
        }
    }
}

A quick word on Slicing

The real magic is in Slice(). Whether you are using Span or Memory, slicing doesn't copy. It just adjusts the pointer and the length. If you have an array of 1 million integers and you slice it to get the last 10, you aren't creating a new array of 10; you're just creating a tiny object that says "start at index 999,990 and stop after 10 elements." This is why these types are game-changers for high-throughput systems.




📋 Practical Task

Exercise: The Zero-Allocation CSV Field Extractor

You are building a high-frequency trading parser where every microsecond and every byte of GC pressure matters. Your goal is to extract a specific field from a CSV line without using string.Split or string.Substring, as both of those create unnecessary heap allocations.

The Challenge: Write a method called ExtractField that takes a string and an index, and returns a ReadOnlySpan<char> representing the value of that field.

Requirements:

  • Do NOT use Split().
  • Do NOT use Substring().
  • Use AsSpan() and Slice() to locate the field.
  • Assume the CSV is comma-separated.

Starter Code:

public static ReadOnlySpan<char> ExtractField(string line, int fieldIndex)
{
    // Your implementation here
}

// Test Case:
string csvLine = "Apple,Banana,Cherry,Date";
var result = ExtractField(csvLine, 2); 
// result should be a span pointing to "Cherry"
Rating
0 0

There are no comments for now.

to be the first to leave a comment.