CLR Architecture & Managed Execution
How the .NET runtime loads, verifies, and executes your code from assembly to native instructions.
The Common Language Runtime is the execution engine that runs all .NET code. When you ship a .NET assembly, you're shipping IL bytecode β the CLR loads it, verifies it for type safety, JIT-compiles it to native code on first use, and manages its memory through the garbage collector. Understanding this pipeline explains why 'managed code' behaves so differently from C++ and why certain performance patterns work the way they do.
When you run a .NET app, the CLR's Assembly Loader reads the PE file, validates its structure, and maps it into memory. The AssemblyLoadContext (ALC) determines isolation boundaries β each ALC has its own type namespace, so the same type loaded in two ALCs are different types at runtime.
The CLR reads the assembly's metadata tables β type definitions, method signatures, field layouts. The verifier checks IL for type safety before execution. This is why managed code can guarantee no buffer overruns and no type confusion without hardware memory protection.
Methods are compiled to native code on first call (tiered: Tier 0 = quick unoptimized, Tier 1 = full RyuJIT optimized after call count threshold). The JIT stub in the method table gets replaced with the native pointer after compilation. Subsequent calls hit native directly.
The GC manages the managed heap with generational collection (Gen0/1/2 + LOH + POH). It knows about all object references because the JIT emits GC info alongside native code β every stack frame's live refs are tracked. The GC can move objects, which is why you need 'fixed' or GCHandle to pin them.
The CLR thread pool manages worker and I/O threads with a hill-climbing algorithm that adjusts thread count based on throughput. The SynchronizationContext abstraction (used by async/await) lets UI frameworks like WinForms or ASP.NET marshal continuations back to their specific threads.
Key Concepts
Isolation boundary for assemblies in .NET Core+. Replaces AppDomain for plugin isolation. Each ALC has independent type identity β same type name in two ALCs = two different types.
GC-controlled memory region divided into Gen0 (short-lived), Gen1 (survivors), Gen2 (long-lived), LOH (>85KB objects), and POH (.NET 5+ for pinned objects that won't move).
The unified type system spanning all .NET languages. Defines how types are declared, used, and managed. Enables cross-language inheritance β a C# class can inherit from a VB.NET class.
The core CLR component that manages method dispatch, virtual calls, interface dispatch, and interop. Maintains method tables for each type with pointers to JIT-compiled native code.
.NET 6+ feature where methods start at Tier 0 (quick compile, no inlining) and are recompiled at Tier 1 (full RyuJIT optimization) after enough calls. Reduces startup latency while maximizing throughput.
Pre-.NET Core isolation mechanism. Appeared to provide process-level isolation within one process but had severe performance costs. Removed in .NET Core β use AssemblyLoadContext instead.
1// .NET 9 β AssemblyLoadContext isolation (plugin system)2// β οΈ this bites everyone eventually34public class PluginHost5{6 private readonly Dictionary<string, AssemblyLoadContext> _contexts = new();78 public IPlugin LoadPlugin(string pluginPath)9 {10 var alc = new AssemblyLoadContext(pluginPath, isCollectible: true);11 _contexts[pluginPath] = alc;1213 var assembly = alc.LoadFromAssemblyPath(pluginPath);14 var pluginType = assembly.GetType("MyPlugin.Plugin")!;1516 // DON'T do this β IPlugin is loaded in two different ALCs17 // typeof(IPlugin) from host != typeof(IPlugin) from plugin18 // This throws InvalidCastException at runtime, not compile time19 return (IPlugin)Activator.CreateInstance(pluginType)!;20 }2122 public void UnloadPlugin(string pluginPath)23 {24 if (_contexts.TryGetValue(pluginPath, out var alc))25 {26 alc.Unload(); // triggers collectible ALC cleanup27 _contexts.Remove(pluginPath);28 // GC needs to collect before memory is actually freed29 GC.Collect();30 GC.WaitForPendingFinalizers();31 }32 }33}3435// CORRECT: share the interface assembly through the default ALC36// Load it as a dependency in the plugin's ALC, not independently37public class SafePluginHost38{39 public IPlugin LoadPlugin(string pluginPath, string sharedInterfacePath)40 {41 var alc = new PluginLoadContext(pluginPath, sharedInterfacePath);42 var assembly = alc.LoadFromAssemblyPath(pluginPath);43 var pluginType = assembly.GetType("MyPlugin.Plugin")!;44 return (IPlugin)Activator.CreateInstance(pluginType)!;45 }46}4748public class PluginLoadContext : AssemblyLoadContext49{50 private readonly AssemblyDependencyResolver _resolver;51 private readonly string _sharedPath;5253 public PluginLoadContext(string pluginPath, string sharedPath)54 : base(isCollectible: true)55 {56 _resolver = new AssemblyDependencyResolver(pluginPath);57 _sharedPath = sharedPath;58 }5960 protected override Assembly? Load(AssemblyName assemblyName)61 {62 // Route shared interface types through default ALC63 // so type identity is consistent across plugin boundaries64 if (assemblyName.Name == "Shared.Interfaces")65 return null; // null = fall through to default ALC6667 var path = _resolver.ResolveAssemblyToPath(assemblyName);68 return path != null ? LoadFromAssemblyPath(path) : null;69 }70}
The CLR's layered architecture is why .NET can offer memory safety, cross-language interop, and dynamic plugin loading without requiring a VM interpreter. Each layer β loading, verification, JIT, GC β is independently tunable. In .NET 9 the JIT has loop unrolling, on-stack replacement, and tiered PGO. Understanding where your code lives in this pipeline (IL β Tier0 β Tier1) helps you reason about startup vs. throughput tradeoffs.
Common Pitfalls
1Plugin System Type Identity Crisis
Our SaaS platform shipped a plugin marketplace. Plugins were .dll files customers could drop into a folder. Friday night at 2 AM, our on-call got paged: every plugin load started throwing InvalidCastException even though the types looked identical in the debugger.
We loaded the shared IPlugin interface assembly once in the plugin's ALC and once in the host ALC. typeof(IPlugin) returned two different Type objects. The cast succeeded at compile time (same type name) but failed at runtime because CLR uses ALC identity for type equality, not assembly name.
We implemented a PluginLoadContext that overrides Load() and returns null for the shared interfaces assembly, forcing the CLR to fall through to the default ALC. Now both host and plugin see the same Type object for IPlugin because they both resolve through the same ALC.
Takeaway: In .NET, type identity is ALC-scoped. typeof(Foo) == typeof(Foo) can be false if the two Type objects were loaded into different AssemblyLoadContexts. Always route shared contract assemblies through the default ALC in plugin architectures.
2LOH Fragmentation Killing Production Throughput
Our image processing service handled PDF to PNG conversions. After about 6 hours of uptime, GC pauses started hitting 800ms+. Memory usage was stable but p99 latency was climbing. Rolling restart every 6 hours became our SLA workaround β until we actually diagnosed it.
Each PDF page was being decoded into a byte[] of 90β200KB (above the 85KB LOH threshold). The LOH is collected only during full GC (Gen2) and is never compacted by default. After hours of alloc/free cycles at different sizes, the LOH was Swiss-cheesed with fragmentation β we had 2GB of address space but couldn't allocate a 150KB contiguous buffer.
We switched to ArrayPool<byte>.Shared for all intermediate buffers, keeping allocation size under 85KB or reusing pooled arrays. For unavoidably large allocations we set GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce before a full GC during off-peak hours.
Takeaway: The LOH threshold is 85KB and it is never compacted by default. High-throughput services that repeatedly allocate and free large byte arrays will fragment the LOH over hours. Use ArrayPool<T> or fixed-size pooled buffers to keep large objects off the LOH entirely.