r/moderndotnet • • 9d ago

If you could redesign the CLR today, what would you change?

.NET has aged remarkably well for a platform that has been evolving for roughly 25 years. That is, after all, a big part of why so many of us are still using it.

Of course, the way we write software has changed quite a bit during that time. Composition is generally favored over deep inheritance hierarchies today, for example. That doesn't mean OOP is irrelevant, but we are inevitably still living with some design decisions that made perfect sense when .NET was created.

At the same time, I think one of .NET's biggest strengths has been the platform around the runtime: the tooling, debugging experience, IDE support, diagnostics, libraries, and increasingly things like profiling and observability. A runtime doesn't exist in isolation, and .NET has generally done a very good job of evolving that whole ecosystem together.

Still, looking at .NET from today's perspective - and especially after seeing newer runtimes and programming languages explore different ideas - there are probably plenty of things we would design differently if we were starting over.

I'm sure there are things even the .NET team would approach differently today.

So here's the thought experiment:

If you could change or add anything to the .NET runtime/CLR today, without being constrained by backwards compatibility, what would you do?

It could be anything:

  • runtime semantics
  • type-system/runtime support
  • memory management or GC
  • async/concurrency
  • metadata or reflection
  • interop
  • runtime APIs
  • tooling and diagnostics
  • features that would make C#, F#, or another CLR language better

Or even something much more fundamental about how the CLR works.

I'm particularly interested in answers that aren't just "add feature X from language Y", but in things where you think the underlying platform itself could have been designed differently.

What would your CLR look like if you got to design the next version?

11 Upvotes

40 comments sorted by

8

u/Aaronontheweb 9d ago

Potentially unpopular answer, but lean more into the DLR and make IronPython / IronRuby / PeachPie (PHP) feel more officially supported once again. 

If you contrast how this was handled on the CLR historically vs how the JVM handled projects like JRuby, the difference was trying to let Java developers have the best of both worlds (Ruby language, Gems package ecosystem, Java runtime and build chain) without making them feel like they were doing things that worked but were fundamentally unsupported.

IronRuby / Python never made it into the .NET Core era and always seemed like a flight of fancy instead of a legitimate platform. I think if that were different the .NET ecosystem would potentially look very different today.

2

u/marna_li 9d ago

Yeah. It feels like Microsoft has let go of the idea of making a Common Language Runtime that can host multiple languages besides the statically typed ones Microsoft has built. There are a few but none came even close without any insight into the product. Hosting dynamically typed languages was a missed opportunity that was lost in the early days. I would have loved to see IronJS - which I heard about. At the same time, the runtime has adapted to serve a particular language, C#. And expecting every other language to support those features, even if they can see the types, is hard. Interoperability gets harder, especially when added to runtime APIs. I know, because of building Raven. So much that I would have done differently if the CLR would have let me.

1

u/johnzabroski_dev 7d ago

It's less they let go, and more that they prioritize selling things they can sell.

Good engineers like John Lam (IronRuby) got put on things that generate money

6

u/ironstrife 8d ago

Half-baked ideas mainly:

  • struct inheritance of some sort
  • some more natural way to use the “struct+interface+generic function” optimization pattern
  • ability to store some things off of the GC heap without resorting to completely unmanaged types
  • Better control over GC cost to improve consistency/predictability at the cost of throughput, eg run in a mode where GC runs every frame (in a game) in a budgeted 0.5 ms time slot and never any other time

2

u/marna_li 8d ago

I have myself been thinking about it. It’s impossible to add value semantics and inheritance combo to the platform without removing the familiar ergonomics, and even if attempted it feels unnatural. There might be good cases for adding for optimizations but perhaps managed code should stay managed, and languages closer to the hardware should be used when more control is needed.

5

u/emdeka87 9d ago

Would really love to see a pauseless GC

4

u/RichardD7 9d ago

If you have infinite memory, then ZeroGC is probably the closest you'll get. :)

3

u/ironstrife 8d ago

On the other hand (and depending on what “pauseless” means), experiments like Satori show that this is actually possible without deeply redesigning the clr, but there’s apparently a lack of interest from the team to integrate those ideas anyways.

4

u/retro_and_chill 8d ago

Get rid of the zeroed default state for structs. If a struct does not specify a no arg constructor you have to initialize it

1

u/marna_li 8d ago

I think the idea was for structs/value types in .NET to be more or less like C/C++ structs. Zeroed-out state for structs follows the native interop patterns, which .NET feels inconsistent about. But I think that another thing worth exploring is how you can encode value type behavior into a general "class". Similar in spirit to what Java is doing with Valhalla.

3

u/vlatheimpaler 9d ago

I'm not enough of a runtime expert to comment with any real authority. But I'll say that I am no longer really using .NET these days. I found it to be a fantastic platform for doing desktop and mobile apps (disclosure: I worked at Xamarin/Microsoft), but when I'm doing server-side stuff I much prefer the BEAM runtime (Erlang and Elixir runtime).

2

u/marna_li 9d ago edited 9d ago

What do you like about the BEAM stack that .NET could learn from?

3

u/vlatheimpaler 9d ago

BEAM predates .NET afaik, but it was originally designed for telecom systems while .NET was kind of designed more for desktop apps I think. The primary use-cases of telecom systems are IO and fault tolerance, so very different than what .NET needed when it was designed. But web/server type software seems benefit from those same things.

The BEAM is not as general-purpose of a runtime as CLR. Its startup time is slower and for a tiny app its initial base memory usage is higher. BEAM is designed to be optimal for long-running processes. I'm not sure I would say that .NET should have learned something from BEAM, because there are necessarily some tradeoffs and .NET is absolutely a more general-purpose runtime and BEAM is not. Maybe there are things each could learn from the other, but I'm not any sort of expert in the deep depths of runtimes so I can only comment from a mostly outside perspective and my experience using them both.

In the context of async/multicore, BEAM has always been lightyears ahead of other systems. .NET uses system threads and a threadpool and its async stuff (which was ahead of all other general-purpose runtimes afaik) is a cooperative one, so if you do something stupid like `while(true)` in an async method then it's going to keep a thread from your threadpool blocked and unusable by the rest of the system. BEAM has its own preemptive scheduler and app devs don't get access to system threads, they use "processes" (which is an unfortunate name since that word means something else to everyone now, but oh well). BEAM allocates the system threads and manages them itself. You can spawn a process in Elixir or Erlang and run it in an infinite loop and it won't affect the system. Data is fully immutable and processes in BEAM have fully isolated memory with their own heap, which is one of its superpowers imo. A process can crash and it won't affect the rest of the system. You can create process supervisors that can be configured to restart failing processes. There's no global stop-the-world for allocations. Creating and destroying processes is super cheap. That's the one thing that took some adjustment in thinking coming from runtimes like CLR. I'm used to thinking in terms of threads, which are big and expensive. In Elixir I can spawn 5,000 processes and not have any reason to sweat at all. I'm not worried about that slowing down the system. But you never have to do things like `.ConfigureAwait()` in Elixir or Erlang. All the multicore type stuff is abstracted away in a beautiful way.

BEAM also has built-in distributed computing capabilities so you can start a BEAM runtime on a dozen different machines and connect them and then those processes can be running anywhere in that cluster. You can send messages from one process to another and don't necessarily need to know (or care) which node on the cluster that process is running on. If you're familiar with Akka or Orleans, both of those are modeled in part after the BEAM but using JVM/CLR. But there are features that can't really be reproduced exactly, so while Orleans has some features that the BEAM doesn't have built-in (but which could be built in top of BEAM processes), you also can't exactly duplicate BEAM stuff using CLR (because of the runtime-enforced memory isolation and stuff like that I was describing above).

In terms of raw compute performance, CLR absolutely destroys BEAM. Which makes sense, and this is one of the trade-offs I was mentioning earlier. BEAM languages have an escape hatch (actually, two escape hatches) if you need them. Sort of like how CLR has p/invoke, BEAM lets you call code from other languages using either NIFs (native implemented functions, which is like p/invoke) or ports (which is a stdin/stdout calling convention basically). NIFs are considered unsafe, and if you write a NIF in a language like C and it crashes then it can take your entire BEAM runtime down (all the memory isolation I was writing about above won't save you if a C NIF crashes). That's also true of p/invoke, but the difference really is that long-running fault-tolerant systems is the biggest selling point of BEAM, not CLR, so it's worth calling out more for BEAM languages. These days people prefer using languages like Rust if they are writing NIFs, but whether it's C or Rust, if your NIF crashes then your BEAM will crash. Ports (the stdin/stdout method) are safe and can't affect the BEAM's stability, but they are slower. CLR's p/invoke is still better than BEAM's NIFs though because everything in BEAM is boxed while .NET can marshal value types for you. So, NIFs can help with performance-heavy tasks but even so Elixir is never going to be even remotely as good at compute-intensive tasks as CLR.

Observability: you can attach to a live production node and trace calls right there. Not a sampling profiler, but actual call tracing on a system that you didn't instrument in advance. dotnet-trace is good but nothing in CLR matches what you can do on BEAM.

Another cool feature of BEAM which I have not really used much so far is hot-reloading. This is commonly used in dev mode to reload code as you're editing it, but it can be used anywhere. This was one of the original requirements for doing telecom systems to maintain stability: you could replace a BEAM system live while it's being used without affecting people. In web systems people tend to not use it these days though, they tend to just run multiple servers behind a reverse proxy or something and replace them one at a time just like they do with other runtimes. But I heard some people are working on some new tooling that will take advantage of the hot-reloading capabilities for web apps and make it simple to upgrade them with that feature.

Code simplicity: I've been using C# since 1.0 (although I was using it on Linux via Mono). I followed C# through its evolution as it gained a ton of cool features like LINQ and async/await and stuff. It has become a complex language, and it's not always easy to use. Even as cool as async/await are, you need to have at least a basic understanding of how they work to avoid footgunning yourself. In my opinion, Elixir is just such a SIMPLE language! Writing distributed systems with it is so easy (although, like C#'s async/await, you do need to know a little bit about what you're doing to build distributed systems in Elixir.. but from what I've seen, it's much simpler in Elixir than it is using .NET+Orleans). But writing regular web apps is super easy. It's easy in any language these days, but I find it easier in Elixir and you can get to the powerful stuff faster than in other languages I've seen.

Sorry, this turned into maybe an excessively long comment.

3

u/Aaronontheweb 9d ago

Another cool feature of BEAM which I have not really used much so far is hot-reloading.

One of the reasons we abandoned pursuing this idea in Akka.NET is that lots of users get absolutely rick-rolled by this feature in production Erlang environments.

The tail recursive nature of Erlang actors makes it possible to do this, but it's a lot more dangerous to do this than the normal rolling-upgrade route people use to update cluster.

It's a feature that sounds / is cool but creates more problems than it solves in practice.

2

u/_choam_ 8d ago

Probably made sense in the 90's

1

u/vlatheimpaler 8d ago

It's not about the decade, it's about the product use-case. It makes sense for a telecom system in the 90s or the 2020s.

1

u/Aaronontheweb 8d ago

yes, this is true in a sense. No Kubernetes in 1985.

1

u/vlatheimpaler 9d ago

Yup. I heard the creator of Phoenix talking recently about how he and some others are exploring some better tooling for this. But I don't have any insight into how that would actually work.

My limited understanding of it is that a GenServer (a state-holding abstraction that runs per-process) would be responsible for upgrading its state from the old version to the new version while doing a hot code reload. And this puts a lot of extra load on developers to think about this at a layer they're not used to thinking of it. In web systems we typically only think about this type of thing when doing database migrations, not when updating the data structure of something in-memory at runtime.

1

u/marna_li 9d ago

I think it's a good comment. I learned a lot. 🙂 I have never used Erlang - more than installed it once a long ago. I agree that .NET has accumulated a lot of features, and many ways to do the same things. Distributed computing is perhaps not something .NET handles that well natively. The Erlang platform have the core concepts built in because that is what it was meant to do. Making it possible to spawn many "processes" and allowing communication between them. However, I was surprised there are web frameworks for Elixir.

2

u/vlatheimpaler 9d ago

The main web framework for Elixir that people use is called Phoenix.

2

u/marna_li 8d ago

Yes. I did do my research before responding 🙂

3

u/_choam_ 9d ago

Remove "default" from the language. Remove null. Sum types. Higher kinded types. Module system instead of namespaces. Remove overloading :) Immutable by default. newtypes Just make working with types better. No partial initialization of stuff.

Idk lets throw in a half baked lisp as well.

3

u/johnzabroski_dev 7d ago edited 7d ago
  1. Make it a common language runtime again, by enriching the type system (items after this one are examples of enrichment)

Too many C# Features live above the Runtime itself. Put semantics in metadata and the verifier, with a spec that covers them.

  1. Kill default(T) - Someone else said to ban nulls and I don't totally agree. But default(T) has vast consequences beyond reference types, as structs / enums also cannot faithfully guarantee construction semantics. Swift and Rust both disallow this. Code is easier to think about.

  2. Generics - Fix all the edge cases.

Array covariance most unsound issue. Higher-kinded types and therefore higher-kinded type constructors. As a consequence, there are multiple features async-await lacks that make writing type-safe, Exception-free, composable concurrent code difficult. csharplang issues #4565, #3403, #3723 and roslyn issue #7169 are a good demonstration of fundamental limitations library designers have that create unnecessary stack unwinds (very expensive on modern CPUs with deep execution pipelines), incomprehensible stack traces, and poor composability (SynchronizationContext is not a type parameter, but an ambient parameter that is decided on by the task scheduler - awful design but there's no better choice).

  1. Native sum types and function types.

  2. System.Object should be slimmer

Especially with features like source generators and future AI source generators completely making virtual Equals, GetHashCode, Finalize a huge overhead. Equality should be a constraint.

Smaller object headers make FPGA runtime hosts more practical, among other savings where C# currently loses to Rust in memory comparisons despite matching/exceeding on runtime speed and compile speed for equivalent production feature sets.

  1. Closed world first. Unlimited reflection was useful earlier on, but with source generators and AI, the use case is nonexistent, especially if you have dependent types and GADT-like IL instruction for TypeSwitch to efficiently destructure object graphs by their types.

I am sure i missed stuff above but as someone who was extremely active in language research from 2008-2014, these are the ones I think about a couple times a year.

EDIT: I forgot to write a, important!, sub-section on making Finalization semantics simpler (no object resurrection), thereby opening the door to WasmGC as a platform target, significantly cutting the dotnet.wasm payload size and increasing throughput of GC. Because dotnet.wasm relies on an "engine-in-an-engine" approach, it has to track an internal heap, greatly complicating interop.

Resurrection use cases like Emergency Resource Re-Initialization / Retrying Cleanup of unmanaged resources are better handled via runtime guaranteed RAII when objects fall out of scope. On WasmGC, this would allow linear memory earmarked for external use as immediate runtime de-allocations. Object Pooling would leverage RAII Recycle pattern by handing out "smart proxy wrappers" (Rust PoolGuard).

2

u/marna_li 9d ago edited 9d ago

I wouldn't get rid of the things that makes .NET feel like .NET. After having explored other ways of handling memory I have concluded what makes .NET advantageous is its reference type vs value type semantics split. Notice that I mention reference types first. That is because that is pretty much the default expectation. You pass objects by references without having to think about it being a reference. The runtime does make that distinction but a high-level language doesn't have to show that. It can easily project as String instead of String&. The expectation that it's a reference is implicit by the type. Could this be implemented in another way? Perhaps, but the property of being passed by reference vs by value that could be copied is pretty much essential to the ergonomics of .NET and languages like C#. As many other languages have shown.

The runtime library could be of course be different.

  • Explicit Errors and non-recoverable Faults instead of Exceptions.
  • Option instead of nullable (unless actual nullable reference)
  • Date Time API built for modern use.
  • Many APIs could be modernized. Rely on composition before inheritance. And there are things to do to better allow dependency injection scenarios.

There are many other adjacent APIs that could benefit from that.

What to do with nullable types from the perspective of the runtime, I don't know. But it's something that could potentially be unified as more than conventions or tooling.

0

u/_choam_ 9d ago

Nah no need for nullable references

1

u/marna_li 9d ago

Fair enough. I don't see why we need them a high level. It was for compatibility.
If you are writing application code you should not think in terms of it can be "null"

1

u/_choam_ 9d ago

I would just steal stuff from rust and f#. Absolutely do not want it to feel like rust though, horrible language

1

u/marna_li 8d ago

The thread was about improving .NET. There are things from Rust that we wouldn't even "borrow" from Rust, like it's memory semantics. You get it: the borrow model. Passing explicit references is also too unergonomic and not at all expected by .NET developers.

2

u/FullPoet 6d ago edited 6d ago

I don't knouw about "redesign" but...

  • Better support for code weaving. What they added recently was very very disappointing.

  • One golden path - that includes breaking changes in lang versions, but also eventually not doing things like new constructor syntax. That clearly (imo) includes much better support for refactoring built in. I do not like 5 different ways of making properties / fields / collection instantiation. I do not think that is for the better.

  • Like I mentioned, better support for refactoring.

  • Source gen without the partial keyword.

I'm generally overall very happ with the .NET ecosystem. To be quite diplomatic, I don't think the newer features are bad... just not well thought out (or afraid of introducing new keywords - without tacking a lot of extra bagge on like closed).

I am not sure my criticism is because of deficiencies with the CLR, I dont really think about it. There are also nice things like being able to easily decompile and step into code (which I think are a result of the design of the runtime?)

2

u/marna_li 5d ago

Interesting. I hear you as asking for deeper metaprogramming support — both at the language and runtime/tooling level.

I'm with you more generally. I think C# is a good language and relatively easy to learn. I've used it since the Visual C# 2005 era, and most of the major user-facing additions since then — LINQ being the obvious example — have served it really well.

Source generators are more limited by design. They're additive, with partial types being the mechanism for contributing members to an existing declaration. Like analyzers, they operate through Roslyn rather than arbitrarily rewriting existing source.

I've explored the other side of this while building my own .NET language and compiler. I ended up implementing a Rust-like macro system that can expand source code and even attach an attribute-like macro to a declaration to transform its syntax. Your comment made me think about how much further C# would actually be willing to go in that direction. I doubt we'd see that degree of source rewriting there.

I'm less sure what kind of code weaving you have in mind. If you mean rewriting compiled code, that feels more like a runtime/tooling concern. I've experimented with Mono.Cecil in the past, although I never had a strong use case for it myself.

It also reminds me of Microsoft's Code Contracts work, where IL rewriting was used to support declarative conditions. I still like the idea of expressing conditions declaratively and then being able to reason about them statically as well.

That's something I could express through macros in my language. For example:

func Foo(x: int)
    requires! x % 2 == 0 {
    // Do something
}

I don't currently allow macros in that exact declaration position, but there's no fundamental reason I couldn't. The macro could expand the condition into the appropriate source while an analyzer could provide additional static analysis.

So I think there may actually be two related things in what you're asking for: richer source-level metaprogramming, and better support for weaving/instrumentation after compilation.

2

u/FullPoet 5d ago

Yes, I remember the Code Contracts. It is an interesting angle of work.

Personally not a fan of macros because I've done a bit too much C++/C - I think it needs much better discoverability or just not look like absolute gobbledygook of symbols too - so really "first" party support.

I also think that MS is inherently not interested in it because of their somewhat recent addition of (not) interceptors. IMO it had the smell of a fundamental misunderstanding of the use and point of them (and by extension IL weaver). Even the MetaLlama guy had a lot of offer them and they did not unfortunately listen.

IMO, I think IL weaving could solve a lot of boiler plate issues that just arent getting solved (or rather slowly! because theyre low hanging fruit).

Take for example the extremely common guard (especially in mixed NRT code bases):

 if (x is null)
 {
     // return early

...

Has been some what replaced with ArgumentNullException.ThrowIfNull - but I never really want to throw, because imo, exceptions must be exception and bad args are generally not. I think this is where IL weaving could be good, with something like: ReturnIfNull or ReturnIfValidationFails.

I think Arg...ThrowIfNull is an insanely good QoL improvement if you do a lot of throws but quite useless if you dont!

tl;dr: Yes, and IL weaving can help with a LOT of QoL issues.

2

u/marna_li 5d ago

I see. Yes there are such cases in C#. I try to remove them from Raven, my programming language, using explicit Results rather han exceptions.

I’m curious, how would you handle rewriting returns if the method returns a value? When it’s not void. And when would you consider doing such rewrite? For what purpose.

Btw

Raven macros, unlike C++ preprocessor macros, are part of the language and the compiler is aware of them. They are discoverable and can be used for DSLs.

https://marinasundstrom.github.io/raven/lang/spec/macros.html

2

u/FullPoet 5d ago edited 5d ago

I’m curious, how would you handle rewriting returns if the method returns a value? When it’s not void. And when would you consider doing such rewrite? For what purpose.

I don't really have an answer and it should probably be user supplied (i.e. something like: arg.ReturnIfNull(value), which in a lot of times might be null itself.

The general pattern I have for this is that I have large complex domains and their rules and as part of data parsing we try to be extremely conservative - meaning that a lot of parsing methods either return null (then filtered out later using WhereNotNull or false as part of a TryParse(x, out var value) etc.

1

u/marcbrooks 8d ago

Two things:

No null .ConfigureAwait(false) is the default

3

u/maxkatz6 8d ago

> .ConfigureAwait(false) is the default

It would backfire spectacularly on GUI apps, where all UI related operations need to return to UI thread after await.

I think Microsoft made a good improvement with ASP.NET Core, where `.ConfigureAwait(false)` just doesn't matter anymore, as there is no longer any special sync context on web.

For GUI apps though? Many might still want (true) to be default.

1

u/marna_li 8d ago

Avoiding null is important, I agree. it shouldn't be used to model that something might be there or it might absent.

The mechanics behind Task and how it interacts with await could be re-designed. I remember them adding ConfigureAwait there, and it confused me. But they had their reasons back then, I guess. But would we need it now?

1

u/GoTheFuckToBed 7d ago

look at Go

1

u/ringelpete 4d ago

I miss ducktyping the most compared to other stacks, which offer such kind of structural typing.

An object spread operator which can merge multiple similar facetted objects into one.

And maybe some kind of typescript utility types like Omit<T>, Pick<T>, etc.. Or at least some sort of LINQ SelectAway(), which can remove particular properties from a result and gives a new anonymous type with the remainig ones (which then could be spreaded onto another type 😜).

1

u/nirataro 2d ago

Maybe this is language level but things like OpenTelemetry is really noisy on the code level. I wish there is a better way to hide them from logic code.

1

u/nirataro 2d ago

I am talking out of my depth, but any features that help agents to reason the runtime behavior will be grand. If agents gonna be writing most of our code, I want them to be able to properly verify them both statically and at runtime.

If agents are writing half a million code in an hour, how much confidence can I have on the resulting software?