Why can’t ref structs be boxed? #107839
-
Spans are an amazing feature, but they have a limitation that they can’t exist on the heap. Instead, one must use What’s the reasoning behind this limitation? The GC obviously had to be augmented to handle interior pointers on the stack, so why was that logic not extended to scanning the heap? |
Beta Was this translation helpful? Give feedback.
Replies: 5 comments 6 replies
-
Basically, lifetime safety issues. You can have stack to heap references but not vice versa because it is really easy to shoot yourself in a foot (e.g. stack can be already used for something else or discarded). Depending on your use case you can go unsafe and use pointers to pass ref structs around but you're in your own then. Another possible thing is that arbitrary heap to heap references might be costly for gc to scan/walk but I'm not sure how much it is. |
Beta Was this translation helpful? Give feedback.
-
Thread safety. And by the stack-only nature, they are by construction thread-safe, so no other measure therefore need to be taken, and hence that's a net win for perf. Span is larger than the machine's word size, so reading / writing a span is not atomic. Stack memory is per thread, so by definition only the associated thread can access that memory (in a safe context). The problem with torn reads / writes doesn't exist here. |
Beta Was this translation helpful? Give feedback.
-
You know how Span is super fast and efficient because it lets you work with slices of memory without having to make extra copies? The trick to making it fast is that it always lives on the stack, not the heap. The stack is a smaller part of memory that’s quick to access but temporary—it's where things like local variables in a method live. Once the method finishes, anything on the stack is wiped out. Now, the heap is where stuff that sticks around longer lives (like objects you create with new). But if Span (or any ref struct) were allowed to be placed on the heap, it would break. Why? Because Span often holds references (or "pointers") to stack memory. If the stack memory disappears (when the method finishes), and you have something pointing to that memory from the heap, you’d be left with a dangling pointer—basically a reference to garbage data that could crash the program. To avoid that mess, C# says: "Nope, no ref struct on the heap." This keeps everything safe and ensures that Span stays fast without any complicated memory tracking. The garbage collector (GC), which cleans up stuff on the heap, isn’t designed to handle these tricky pointers from the heap to the stack. Modifying the GC to do that would slow everything down and add a ton of complexity. So instead, C# just keeps ref struct types limited to the stack. If you need something similar that can live on the heap (like when you're working with async methods), you have to use Memory, which is kind of like Span but can be heap-allocated. The trade-off is that Memory is a bit slower since it allows heap allocation, but it gets the job done in more flexible situations, like when working asynchronously. So, basically, it's all about balancing speed, safety, and not breaking the system by letting Span point to memory that might disappear! |
Beta Was this translation helpful? Give feedback.
-
This is a similar discussion on the CSharp language board. To answer your question:
That discussion quotes this article:
So it's a performance optimization. I don't know much about the CLR GC, but I went looking for what might cause it to be expensive. It appears that the VM side of CoreCLR passes |
Beta Was this translation helpful? Give feedback.
-
Others already answered the question in opening. However, Span is a mere pointer + length, and passing such to heap and to other threads is completely normal in other languages. It can be done unsafely in C# too as long as you understand that the Span cannot be used after the underlying resource is released. The following example allocates memory from stack and passes the pointer to other threads to process. using System.Runtime.CompilerServices;
// Number of elements and tasks
int length = 1024;
// Allocate from stack
Span<long> buf = stackalloc long[length];
// Local variable for closure
SpanUnsafe<long> @unsafe = buf;
// Concurrent work
Parallel.For(0, length, (int threadId) => {
Span<long> _buf = @unsafe;
_buf[threadId] = 1;
});
// Print results
for (int i = 0; i < length; i++)
Console.WriteLine($"buf[{i}] = {buf[i]}");
// Done
Console.WriteLine();
public unsafe struct SpanUnsafe<T>
{
nint ptr;
public readonly int Length;
public ref T this[int index]
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get {
if ((uint)index >= (uint)Length) throw new IndexOutOfRangeException();
return ref Unsafe.AsRef<T>((void*)(ptr + index * sizeof(T)));
}
}
public SpanUnsafe(nint ptr, int length)
{
this.ptr = ptr;
this.Length = length;
}
public static implicit operator Span<T>(SpanUnsafe<T> unmanagedSpan) => new Span<T>((void*)unmanagedSpan.ptr, unmanagedSpan.Length);
public static implicit operator SpanUnsafe<T>(Span<T> span) => new SpanUnsafe<T>((nint)Unsafe.AsPointer<T>(ref span.GetPinnableReference()), span.Length);
} |
Beta Was this translation helpful? Give feedback.
Basically, lifetime safety issues. You can have stack to heap references but not vice versa because it is really easy to shoot yourself in a foot (e.g. stack can be already used for something else or discarded). Depending on your use case you can go unsafe and use pointers to pass ref structs around but you're in your own then.
Another possible thing is that arbitrary heap to heap references might be costly for gc to scan/walk but I'm not sure how much it is.