C# & .NET (C# 14, .NET 10 LTS) Module 17 – Memory, Span and performance
ref structs, ref fields and scoped
Write ref struct types such as a packet reader over a span, use ref fields and scoped parameters, and fix the escape errors the C# compiler reports.
What you will learn
- Write a ref struct that reads data through a span, and work within its restrictions
- Use ref fields and scoped parameters so that callers may pass stack memory
- Read and fix the compiler's escape errors, such as CS8352 and CS8350
- Use ref returns, and accept ref structs in generic code with allows ref struct
Before you start
What you’ll learn · Before you start
What you will learn
- Write a ref struct that reads data through a span, and work within its restrictions
- Use ref fields and scoped parameters so that callers may pass stack memory
- Read and fix the compiler's escape errors, such as CS8352 and CS8350
- Use ref returns, and accept ref structs in generic code with allows ref struct
Before you start
On this page
A reader that walks through a 12-byte packet must keep the bytes it has not read yet, and to copy nothing it keeps them as a ReadOnlySpan<byte>. A class cannot hold that field: the compiler refuses it with CS8345. Declared as a ref struct instead, the reader below read four values, and a million such readers allocated 0 bytes.
Three features make such types possible: ref structs can hold spans because they never leave the stack, ref fields hold a reference to a variable, and the scoped modifier tells the compiler how far a reference may travel. You will write a ref struct, let callers pass stack memory, fix escape errors and use ref structs in generic code.
A ref struct can hold a span because it never leaves the stack
reader.cs declares PacketReader as a ref struct: A struct declared with ref struct, such as Span<T>. Its instances live only on the stack: it cannot be boxed, be a field of a class, be captured by a lambda or be kept across an await. with a span field, reads a type, a
sensor number, a temperature and a battery level from 12 bytes, then builds a million readers over the same array:
// A ref struct that reads big-endian numbers from a 12-byte packet without copying it.
using System.Buffers.Binary;
byte[] packet = [0x00, 0x2A, 0x00, 0x00, 0x30, 0x39, 0x41, 0xD8, 0x00, 0x00, 0x01, 0xF4];
var reader = new PacketReader(packet);
Console.WriteLine($"type {reader.ReadUInt16()}, sensor {reader.ReadUInt32()}, temperature {reader.ReadSingle()}, battery {reader.ReadUInt16()} mV");
Console.WriteLine($"{reader.Remaining} bytes left, and reading on: {reader.TryReadUInt16(out _)}");
long before = GC.GetAllocatedBytesForCurrentThread();
long sum = 0;
for (int i = 0; i < 1_000_000; i++)
{
var r = new PacketReader(packet);
sum += r.ReadUInt16() + r.ReadUInt32();
}
Console.WriteLine($"1,000,000 readers: {GC.GetAllocatedBytesForCurrentThread() - before} bytes allocated (sum {sum})");
ref struct PacketReader(ReadOnlySpan<byte> packet)
{
private ReadOnlySpan<byte> _rest = packet; // a span as a field: allowed in a ref struct only
public readonly int Remaining => _rest.Length;
public ushort ReadUInt16() => TryReadUInt16(out ushort value) ? value : throw new InvalidOperationException("not enough bytes");
public bool TryReadUInt16(out ushort value)
{
value = 0;
if (_rest.Length < 2) return false;
value = BinaryPrimitives.ReadUInt16BigEndian(_rest);
_rest = _rest[2..];
return true;
}
public uint ReadUInt32()
{
uint value = BinaryPrimitives.ReadUInt32BigEndian(_rest); // throws if fewer than 4 bytes are left
_rest = _rest[4..];
return value;
}
public float ReadSingle()
{
float value = BinaryPrimitives.ReadSingleBigEndian(_rest);
_rest = _rest[4..];
return value;
}
} Output
type 42, sensor 12345, temperature 27, battery 500 mV 0 bytes left, and reading on: False 1,000,000 readers: 0 bytes allocated (sum 12387000000)
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run reader.cs
BinaryPrimitives read each value with its most significant byte first, the order network protocols use, and each
read moved the span past what it had read. The million readers allocated nothing: every one was a struct on the stack,
viewing the same array. Step through the reads:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The reader starts. new PacketReader(packet) stores a ReadOnlySpan<byte> over the whole array: a reference to byte 0 and a length of 12. The bytes are not copied, and the reader itself lives on the stack.
- Two bytes, big-endian. ReadUInt16 reads bytes 00 2A with the most significant byte first: 0x002A is 42. Then it slices its span past them.
- Four bytes. ReadUInt32 reads 00 00 30 39, which is 12345, the sensor number, and moves on four bytes.
- A big-endian float. ReadSingle reads 41 D8 00 00 as an IEEE 754 float, which is 27: the temperature.
- The last two bytes. ReadUInt16 reads 01 F4, which is 500, the battery in millivolts. The span is now empty.
- Nothing left to read. TryReadUInt16 checks the length first and returns false instead of throwing. A million readers built the same way allocated 0 bytes, because each one is a struct on the stack over the same array.
Ref structs pay for this with rules that keep them on the stack, which the compiler enforces:
- no array of them, and no field of one in a class or in an ordinary struct;
- no boxing, so no conversion to
objector to an interface they implement; - no capture by a lambda or a local function;
- in an async method or an iterator, never alive across an
awaitor ayield return(allowed at all since C# 13); - as a type argument only where the type parameter says
allows ref struct(also C# 13).
The rules grew version by version, as the C# version history records:
- C# 7.0
ref locals and ref returns
A method can return a reference to a variable, such as an array element, and a local can be another name for it, so a large struct is used in place instead of copied.
- C# 7.2
ref struct, in and ref readonly returns new kind of type
ref struct declares a type that must always live on the stack, which made Span<T> possible. in parameters and ref readonly returns pass and return by reference for reading only.
- C# 8.0
Disposable ref structs
A ref struct with a public Dispose method works with using, although it cannot implement IDisposable yet.
- C# 11
ref fields and scoped new syntax
A ref struct can hold a ref field, a reference to a variable, and scoped limits how far a parameter or local may travel, so the compiler can accept more calls.
- C# 12
ref readonly parameters
A parameter can be declared ref readonly: passed by reference, never written, and expected to be a variable rather than a temporary copy.
- C# 13
Interfaces and allows ref struct new rules
A ref struct can implement interfaces, a generic type parameter can say allows ref struct, and ref locals and ref struct locals may appear in async methods and iterators as long as they do not cross an await or a yield.
- C# 14
Span conversions and scoped lambdas
Arrays convert to Span<T> and ReadOnlySpan<T> in more places, including extension method receivers, and a simple lambda parameter can be marked scoped without spelling out its type.
The timeline has 7 events, from C# 7.0 to C# 14.
Checkpoint
Check: Ref structs and their restrictions
Ref fields and scoped say how far a reference may go
A ref field: A field declared with ref inside a ref struct, added in C# 11: it holds a reference to a variable rather than a value, so writes through it reach that variable. Span<T> is built on one. is a field that refers to a variable instead of holding a value, and only ref
structs may declare one; Span<T> itself is built on a ref field. The scoped: A modifier on a parameter or local of a ref struct type, or on a ref variable, which promises that the value does not leave the current method. The compiler checks the promise and lets callers pass shorter-lived memory. modifier
works the other way round: it promises that a parameter or local does not leave the current method. scoped.cs uses
both:
// A ref field refers to a variable of its creator, and scoped promises that a parameter does not escape.
int count = 0;
var counter = new Counter(ref count);
counter.Add();
counter.Add();
counter.Add();
Console.WriteLine($"count is {count} after three calls of counter.Add()");
var line = new LineBuilder(new char[64]); // a builder over a heap array, which may outlive this method's stack memory
Span<char> digits = stackalloc char[11]; // stack memory, gone when this method returns
41_250.TryFormat(digits, out int length);
line.Append("total: ");
line.Append(digits[..length]); // allowed because Append's parameter is scoped
Console.WriteLine(line.ToString());
ref struct Counter
{
private ref int _count; // a ref field (C# 11): another name for the caller's variable
public Counter(ref int count) => _count = ref count;
public void Add() => _count++;
}
ref struct LineBuilder(Span<char> buffer)
{
private readonly Span<char> _buffer = buffer;
private int _length;
// scoped: Append promises not to keep text, so callers may pass memory that lives shorter than the builder.
public void Append(scoped ReadOnlySpan<char> text)
{
text.CopyTo(_buffer[_length..]);
_length += text.Length;
}
public override readonly string ToString() => _buffer[.._length].ToString();
} Output
count is 3 after three calls of counter.Add() total: 41250
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run scoped.cs
Counter keeps a ref field to the local count, so three calls of Add changed the local itself. LineBuilder
writes into a heap array, and its caller wanted to append digits from stack memory. Without scoped on text, the
compiler would have to assume that Append might keep the span inside the builder, which lives longer than the stack
memory, and it would refuse the call. With scoped, Append promises not to keep it, the compiler checks that promise
inside Append, and the call compiles.
The compiler reasons about how far each reference may travel, which the C# documentation calls its ref safe context: How far a reference to a variable may travel: for a local, the block that declares it; for a ref or in parameter or a field of a class, back to the caller. A reference may be returned or stored only within it., and for a span or another ref struct value its safe context:
| What the reference refers to | How far it may travel |
|---|---|
| a local variable | the block that declares it |
a ref or in parameter, a field of a class |
back to the caller |
a span over stackalloc memory |
the method that allocated it |
| a scoped parameter | the method itself |
A reference may be returned or stored only where it cannot outlive what it refers to; every escape error is this one rule.
Checkpoint
Check: Ref fields and the scoped modifier
Escape errors reproduced, each with its fix Pro: opens with a Pro, Premium or Ultimate pass
The escape rules are easiest to learn from the errors themselves. This part breaks the one rule four times in one program, reads what the compiler says about each, and fixes all four.
In this part:
- Four escapes in one program, and what the compiler reports
- How the compiler decides: two contexts for every reference
3 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
ref returns, generics that accept ref structs, and a bit reader to build Pro: opens with a Pro, Premium or Ultimate pass
A ref struct is one tool for avoiding copies; references to ordinary structs and generic code over ref structs are the others. This part measures a ref return against a copy, runs a generic method over a ref struct without boxing it, and ends with a lab.
In this part:
- Handing out the element itself, measured against a copy
- A ref struct in generic code, without boxing
- readonly ref structs, and ref structs that clean up
- A lab: a bit reader that copies nothing
3 runnable examples (C#) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
Test yourself on the rules of ref structs and on what ref fields and scoped change. The questions adapt to your answers, weakest idea first, and each one explains its right answer and every wrong one:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is a ref struct, and why can it not be boxed?
A ref struct is a struct declared with ref struct, such as Span<T>, whose instances may live only on the stack.
That lets it hold references that would be unsafe on the heap: a span over stack memory, or a ref field to a local
variable. Boxing would copy the value into an object on the heap, where it could outlive the memory it refers to, so
the compiler forbids it, and with it every conversion to object or to an interface. For the same reason a ref struct
cannot be a field of a class, an element of an array, captured by a lambda or kept across an await. Since C# 13 it can
implement interfaces and be a type argument where the type parameter allows ref struct, but it is still never boxed.
-
Mid to senior
Design a type that reads big-endian integers and floats from a received network packet without copying it. Why should it be a ref struct, what can its callers no longer do with it, and how does it report a packet that is too short?
-
Senior to staff
A ref struct builder writes into a heap buffer, and callers get CS8350 when they append text from a stackalloc buffer. Explain the compiler's reasoning, how scoped changes the contract, and when scoped is the wrong fix.
-
Senior to staff
You maintain a generic helper used with arrays and lists, and a team wants to call it with Span<T> and their own ref struct readers. What does allows ref struct permit, what does it forbid inside the helper, and is adding it a breaking change?
Key takeaways
- Ref structs live only on the stack, so they can hold spans and ref fields, and the compiler forbids everything that could move them to the heap: boxing, class fields, arrays, lambda captures and locals alive across an await.
- A reader or builder over a span can be a ref struct and allocate nothing; a million readers in
reader.csallocated 0 bytes. - Ref fields refer to a variable instead of holding a value, and the scoped modifier promises that a parameter does not leave the method, which lets callers pass stack memory.
- Every escape error is one rule: a reference may be returned or stored only where it cannot outlive what it refers to.
Terms in this lesson
- ref field
- A field declared with ref inside a ref struct, added in C# 11: it holds a reference to a variable rather than a value, so writes through it reach that variable. Span<T> is built on one.
- ref safe context
- How far a reference to a variable may travel: for a local, the block that declares it; for a ref or in parameter or a field of a class, back to the caller. A reference may be returned or stored only within it.
- ref struct
- A struct declared with ref struct, such as Span<T>. Its instances live only on the stack: it cannot be boxed, be a field of a class, be captured by a lambda or be kept across an await.
- scoped
- A modifier on a parameter or local of a ref struct type, or on a ref variable, which promises that the value does not leave the current method. The compiler checks the promise and lets callers pass shorter-lived memory.
References
- ref struct types (Microsoft Learn)
- Errors and warnings associated with ref struct types (Microsoft Learn)
- Avoid memory allocations and data copies (Microsoft Learn)
- Declaration statements: reference variables and scoped ref (Microsoft Learn)
- What's new in C# 13 (Microsoft Learn)
- The history of C# (Microsoft Learn)
- BinaryPrimitives class (Microsoft Learn)
- dotnet run command (Microsoft Learn)
- C# compiler options: code generation (Optimize) (Microsoft Learn)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress