Span and ref-like types enable massive changes to the way that memory is managed in C#. You can absolutely write almost GC-less code. I have been tinkering with a toy no-GC database engine in C# based on Direct I/O and some object pooling. I have been amazed at how far i can get before resorting to GC heap allocations
Anything that uses classes and interfaces will be memory managed by the GC. So instead of using lists, dictionaries, IEnumerable, you will have to roll your own.
It would be better if the GC can be turned off with a switch and just add a delete operator to manually free memory.
>Anything that uses classes and interfaces will be memory managed by the GC...
Yes and no.
Yes, almost all of the standard library collection are allocation heavy and it is still the dominate pattern in C#, so if you want to avoid the GC you need to avoid these and resort to building your own primitives based on Memory/Span. Which sucks.
However, you can use interfaces in a no GC world since you can constrain those interfaces to be structs or ref-structs and the compiler will enforce rules that prevent them from being boxed onto the GC heap.
Also of recent note, the JIT can now automagically convert simple gc-heap allocations into stack allocations if it can trivially prove they don't escape the stack context.
> It would be better if the GC can be turned off with a switch and just add a delete operator to manually free memory.
It is a little know fact that you can actually swap out the GC of the runtime. So you could plug in a null implementation that never collects (at your own peril...)
As for a delete operator, you can just roll your own struct based allocation framework that uses IDisposable to reclaim memory. But then you need to deal with all the traditional bugs like use-after-free and double-free and the like.
For me, I think low-gc is the happy medium. Avoid the heap in 99% of cases but let the GC keep things air tight
>> It is a little know fact that you can actually swap out the GC of the runtime. So you could plug in a null implementation
How do you do this? Just so I can have another tool in my tool shed. Googling got me to an archived repo on GitHub with a sample GC - which is enough but Wonder if there’s something off the shelf.
In java land, the Epsilon GC (a do nothing GC) enables a pattern that’s handy in perf test jobs in CI pipelines occasionally for some projects (I.e. run with epsilon but constrain max memory for the process - ci builds will fail if memory usage increases)
> As for a delete operator, you can just roll your own struct based allocation framework that uses IDisposable to reclaim memory. But then you need to deal with all the traditional bugs like use-after-free and double-free and the like.
I forgot that there is built in support for this model using the MemoryManager<T> class [0]. A memory manager is an abstract class that represents a block of other memory, including possibly unmanaged memory. It implements IDisposable already so you can just plug into this.
The Memory<T> struct can optionally internally point to a MemoryManager instance allowing you to plug your perfered style of allocation and freeing of memory into parts of the framework.
There is a little irony that a MemoryManager<T> is itself a class and therefore managed on the gc-heap, but you can defeat this by using ObjectPool<T> to recycle those instances to keep allocation count steady state and not trigger the GC.
I have used this before (in the toy database i mentioned earlier) to allocate aligned blocks of unmanaged memory.
I think the comment just meant using generic constraints with structs.
e.g.
interface Foo {
int Calculate();
}
static void CalculateThing<T>(T impl)
where T: Foo {
var num = impl.Calculate() * 2;
Console.WriteLine(num);
}
Here if you pass a struct that implements 'Foo', 'CalculateThing' will be monomorphized and the dispatch will be zero-cost, same as in Rust.
You can apply additional constraints like `where T: struct` or `allows ref struct`. The last one is a new addition which acts like a lifetime restriction that says that you are not allowed to box T because it may be a ref struct. Ref structs are for all intents and purposes regular structs that can hold so-called "managed references" aka byrefs, which have syntax 'ref T', which is discussed in detail by the article this submission links to (ref structs can also hold other ref structs, you are not limited in nesting, but you are limited in cyclicality).
Given that ref structs can now be generic arguments and cannot be boxed - you have more ways to enforce that no boxing occurs at compile-time. It is true that you have to roll your own collections, but even dispatching on interfaces by making them generic constraints (which is zero-cost) instead of boxing is a good start.
As for delete operator, 'dispose' works well enough. I have a toy native vector that I use for all sorts of one-off tasks:
// A is a shorthand for default allocator, a thin wrapper on top of malloc/realloc/free
// this allows for Zig-style allocator specialization
using var nums = (NVec<int, A>)[1, 2, 3, 4];
nums.Add(5);
...
// underlying pointer is freed at the end of the scope
It is very easy to implement and I assume C and C++ developers would feel right at home, except with better UX.
This retains full compatibility with the standard library through interfaces and being convertible to Span<T>, which almost everything accepts nowadays.
System-provided allocators are slower at small allocations than GC, but Jemalloc easily fixes that.
Okay, I see where you are coming from. This is a common ask, but it works against the principles that make generational GCs performant. You can't "delete" an object from the heap, because dead objects are not deallocated. Instead, live objects are preserved and moved to an older generation, with memory now occupied by only dead objects made available for subsequent allocations immediately.
In addition, objects that hold references to other objects internally would need an implementation that would allow to traverse and recursively free references in a statically understood way. This gets nasty quick since a List<T> can hold, let's say, strings, which may or may not have other locations referring to them. Memory safety goes out of the window for dubious performance wins (not even necessarily, since this is where GC has better throughput).
> Okay, I see where you are coming from. This is a common ask, but it works against the principles that make generational GCs performant.
In my comment I already suggested a context where GC can be turned off. I said: "It would be better if the GC can be turned off with a switch and just add a delete operator to manually free memory."
And that'd totally break down as soon as some underlying class does something you didn't expect. C++ RAII patterns and Rust's ownership systems are required for a very good reason (that the GC sidesteps but also makes all code dependent of), the NVec further up in the thread works because it's an explicit abstraction.
> It would be better if the GC can be turned off with a switch and just add a delete operator to manually free memory.
This breaks the fundamental assumptions built into pretty much every piece of software ever written in the language - it's a completely inviable option.
Incorporating a borrow checker allows for uncollected code to be incorporated without breaking absolutely everything else at the same time.
The biggest difference is the number of people involved and the target. C# is built for collaboration by a large number of people (of sometimes little experience), for everything from Windows GUI to microservice AWS lambdas
Of course, the point is that this all traces back to Java being a language originally designed for settop boxes, leaving the features of Oberon/Cedar/Modula-3/Eiffel/... behind, C# being born out of Sun's lawsuit when J++ was the original language for Ext-VOS, WinDev resistance to anything not C and C++, Singularity, Midori, Phoenix, languages like D, Go, Rust gaining attention, and so on and on.
Lots of zig-zags.
I am a firm believer that if languages like Java and C# had been like those languages that predated them, most likely C and C++ would have been even less relevant in the 2010's, and revisions like C++11 wouldn't have been as important as they turned out to be.
There is very little new under the sun. It reminds me of the wheel of time books, as the wheel turns we forget about the learnings of the previous age and reinvent them for ourselves. Often worse.
Yeah I feel like we are taking "reinventing the wheel" to a whole new level, and with enough time, people forget, same with Lisp and Forth (i.e. how they just re--implement stuff that were already a thing in those two languages, but perhaps under a different name).
As completely off topic as my response will be, I'll at least keep to the theme of this thread. I was reminded the other day of an article that called out React and Flux for reinventing the Windows 1.0 dispatch architecture, and it made me laugh: https://www.bitquabit.com/post/the-more-things-change/
Also, can't miss the opportunity to bring up Graydon's iconic 2010 talk "Technology from the past come to save the future from itself". http://venge.net/graydon/talks/
Thanks for the article. Hopefully your laugh wasn't a cynical laugh...
It seems more relevant than ever to study the fundamental discoveries made in the early history of Windows. We don't know the magnitude of how much the rendering API affects the success of an operating system. It would be safe for a novel OS to heed the importance of the rendering API.
But more abstractly, it could be that the best novel OS competitor to Windows is simply the open-source flavor. It would be stronger evidence to see someone building Windows 1.0 in a modern sense, stronger than any other evidence, that a serious competitor is incoming.
The next OS won't be written in Javascript (sorry React).
Many of these features exist since .NET 1.0, given its scope of languages to support, including C++.
So even those that weren't initially exposed in unsafe mode, were available at the MSIL level and could be generated via helper methods making use of "System.Reflection.Emit".
Naturally having them as C# language features is more ergonomic and safer than a misuse of MSIL opcodes.
There is a runtime (not C#) feature that has been added that is relevant to the article: ref fields in structs. Before these, only certain runtime-blessed types like Span<T> could contain refs directly.
In case anyone is interested, here is the spec about refs in structs and other lifetime features mentioned in the article:
I believe you can avoid most of not all of the P/invoke overhead these days by using unmanaged function pointers and not using the automatic marshalling.
Whenever you use [DllImport], the analyzer will nudge you to auto-fix it to [LibraryImport] which source-generates a marshalling stub (if any is needed) that then calls an inner [DllImport] that does not require runtime marshalling. This is very cheap since function address gets cached into a readonly static which then gets baked into the machine code once the JIT produces Tier-1 compilation for your method.
On NativeAOT, you can instead use "DirectPInvoke" which links against specified binary and relies on system loader just like C/C++ code would. Then, you can also statically link and embed the dependency into your binary (if .lib/.a is available) instead which will turn pinvokes into direct calls (marshalling if applicable and GC frame transition remain, on that read below).
Lastly, it is beneficial to annotate short-lived PInvoke calls with [SuppressGCTransition] which avoids some deoptimizations and GC frame transition calls around interop and makes the calls as cheap as direct calls in C + GC poll (a single usually not-taken branch). With this the cost of interop effectively evaporates which is one of the features that makes .NET as a relatively high-level runtime so good at systems programming.
Unmanaged function pointers have similar overhead, and identical if you apply [SuppressGCTransition] to them in the same way.
* LibraryImport is not needed if pinvoke signature only has primitives, structs that satisfy 'unmanaged' constraint or raw pointers since no marshalling is required for these.
I'm not sure I follow. Where are GUI workloads being discussed in the article?
If anything, article doesn't talk about MSIL or CLR, but C# language features. CLR is not the only target C# supports.
NativeAOT is supported in Avalonia (cross-platform UI framework), Razor Slices (dynamically render HTML from Minimal APIs) and I think there is also some support for AOT in MonoGame & FNA (game dev frameworks).
However, it's still early and a lot of the ecosystem doesn't support NativeAOT.
I wish more people would talk about it. Thank you for such an interesting article!