How it works
The explanation, with examples you can run.
Splitting without substrings
string line = "12,7,30,5,46";SumSplit(line); SumSpan(line); // run both once so JIT work is not countedlong t0 = GC.GetAllocatedBytesForCurrentThread();int s1 = SumSplit(line);long t1 = GC.GetAllocatedBytesForCurrentThread();int s2 = SumSpan(line);long t2 = GC.GetAllocatedBytesForCurrentThread();Console.WriteLine($"Split: sum {s1}, {t1 - t0} bytes allocated");Console.WriteLine($"Span: sum {s2}, {t2 - t1} bytes allocated"); static int SumSplit(string s){ int total = 0; foreach (var part in s.Split(',')) total += int.Parse(part); // an array plus five strings return total;}static int SumSpan(ReadOnlySpan<char> s){ int total = 0; foreach (var range in s.Split(',')) // span Split, .NET 9 and later total += int.Parse(s[range]); // parses a slice in place return total;}On .NET 10 this printed Split: sum 100, 208 bytes allocated and Span: sum 100, 0 bytes allocated. Two hundred bytes is nothing once, but a service parsing millions of lines a second turns that into constant gen 0 collections.
Why spans have rules
A span can point into the stack, so the compiler must guarantee it never outlives the frame it points into. Span<T> is therefore a ref struct, and ref structs:
- can't be boxed, converted to
objector used as a type argument (C# 13 addedallows ref structfor generics that opt in); - can't be fields of a class or a normal struct;
- can't be captured by lambdas or used across an
awaitoryield return, because locals that live across those move into a heap object. Since C# 13 an async method may use a span between awaits; using one after anawaitfails with error CS4007.
The compiler enforces all of this, so spans are as safe as arrays while being as cheap as pointers.
Memory<T> when you need the heap
Memory<T> describes the same kind of slice but is an ordinary struct, so it can be stored in a field and passed through async methods. It cannot point at stack memory. The usual pattern is to hold Memory<T> and take .Span at the point where you do synchronous work:
byte[] rented = ArrayPool<byte>.Shared.Rent(4096);try{ Memory<byte> buffer = rented; int read = await stream.ReadAsync(buffer); Process(buffer.Span[..read]);}finally { ArrayPool<byte>.Shared.Return(rented); }Combined with ArrayPool, this keeps both the large-object heap and gen 0 quiet. Treat spans as an optimisation for hot paths you have measured. Plain strings and arrays remain the clearest choice elsewhere.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.