AI WisdomArchitecture & guides β†—
HT
How Things Work

CLR Architecture & Managed Execution

How the .NET runtime loads, verifies, and executes your code from assembly to native instructions.

How It Works

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.

1
Assembly Loading

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.

2
Metadata & Type System Verification

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.

3
JIT Compilation

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.

4
Garbage Collector

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.

5
Thread Pool & Synchronization

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

πŸ“¦AssemblyLoadContext

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.

πŸ—‚Managed Heap

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).

πŸ”—CTS (Common Type System)

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.

βš™οΈExecution Engine

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.

πŸš€Tiered Compilation

.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.

⚰️AppDomain (legacy)

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.

AssemblyLoadContext β€” Plugin Isolation (.NET 9)
tsx
1// .NET 9 β€” AssemblyLoadContext isolation (plugin system)
2// ⚠️ this bites everyone eventually
3
4public class PluginHost
5{
6 private readonly Dictionary<string, AssemblyLoadContext> _contexts = new();
7
8 public IPlugin LoadPlugin(string pluginPath)
9 {
10 var alc = new AssemblyLoadContext(pluginPath, isCollectible: true);
11 _contexts[pluginPath] = alc;
12
13 var assembly = alc.LoadFromAssemblyPath(pluginPath);
14 var pluginType = assembly.GetType("MyPlugin.Plugin")!;
15
16 // DON'T do this β€” IPlugin is loaded in two different ALCs
17 // typeof(IPlugin) from host != typeof(IPlugin) from plugin
18 // This throws InvalidCastException at runtime, not compile time
19 return (IPlugin)Activator.CreateInstance(pluginType)!;
20 }
21
22 public void UnloadPlugin(string pluginPath)
23 {
24 if (_contexts.TryGetValue(pluginPath, out var alc))
25 {
26 alc.Unload(); // triggers collectible ALC cleanup
27 _contexts.Remove(pluginPath);
28 // GC needs to collect before memory is actually freed
29 GC.Collect();
30 GC.WaitForPendingFinalizers();
31 }
32 }
33}
34
35// CORRECT: share the interface assembly through the default ALC
36// Load it as a dependency in the plugin's ALC, not independently
37public class SafePluginHost
38{
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}
47
48public class PluginLoadContext : AssemblyLoadContext
49{
50 private readonly AssemblyDependencyResolver _resolver;
51 private readonly string _sharedPath;
52
53 public PluginLoadContext(string pluginPath, string sharedPath)
54 : base(isCollectible: true)
55 {
56 _resolver = new AssemblyDependencyResolver(pluginPath);
57 _sharedPath = sharedPath;
58 }
59
60 protected override Assembly? Load(AssemblyName assemblyName)
61 {
62 // Route shared interface types through default ALC
63 // so type identity is consistent across plugin boundaries
64 if (assemblyName.Name == "Shared.Interfaces")
65 return null; // null = fall through to default ALC
66
67 var path = _resolver.ResolveAssemblyToPath(assemblyName);
68 return path != null ? LoadFromAssemblyPath(path) : null;
69 }
70}
πŸ’‘
Why This Matters

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

⚠typeof(Foo) == typeof(Foo) can return false β€” if two AssemblyLoadContexts both load the same assembly, each gets its own Type objects. Cross-ALC casts throw InvalidCastException at runtime with no compile-time warning.
⚠LOH fragmentation is silent until it isn't β€” allocating and freeing objects above 85KB repeatedly will fragment the Large Object Heap over hours. You won't see high memory usage, just eventually-failing allocations and 800ms GC pauses.
⚠Collectible ALCs don't unload until all references are gone β€” including references in static fields, event handlers, or long-lived delegates. A single forgotten reference to a type or object from the ALC keeps the entire ALC (and all its loaded assemblies) in memory.
⚠Tiered compilation invalidates assumptions about inlining at startup β€” Tier 0 code doesn't inline. If you benchmark a method on first call you'll measure Tier 0 perf, not steady-state. Use BenchmarkDotNet which handles warmup, or add [MethodImpl(MethodImplOptions.AggressiveOptimization)].
Real-World Use Cases

1Plugin System Type Identity Crisis

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

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.

Solution

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.