How to use and debug assembly unloadability in .NET
.NET (Core) introduced the ability to load and later unload a set of assemblies. In .NET Framework, custom app domains were used for this purpose, but .NET (Core) only supports a single default app domain.
Unloadability is supported through AssemblyLoadContext. You can load a set of assemblies into a collectible AssemblyLoadContext
, execute methods in them or just inspect them using reflection, and finally unload the AssemblyLoadContext
. That unloads the assemblies loaded into the AssemblyLoadContext
.
There's one noteworthy difference between the unloading using AssemblyLoadContext
and using AppDomains. With AppDomains, the unloading is forced. At unload time, all threads running in the target AppDomain are aborted, managed COM objects created in the target AppDomain are destroyed, and so on. With AssemblyLoadContext
, the unload is "cooperative". Calling the AssemblyLoadContext.Unload method just initiates the unloading. The unloading finishes after:
- No threads have methods from the assemblies loaded into the
AssemblyLoadContext
on their call stacks. - None of the types from the assemblies loaded into the
AssemblyLoadContext
, instances of those types, and the assemblies themselves are referenced by:- References outside of the
AssemblyLoadContext
, except for weak references (WeakReference or WeakReference<T>). - Strong garbage collector (GC) handles (GCHandleType.Normal or GCHandleType.Pinned) from both inside and outside of the
AssemblyLoadContext
.
- References outside of the
Use collectible AssemblyLoadContext
This section contains a detailed step-by-step tutorial that shows a simple way to load a .NET (Core) application into a collectible AssemblyLoadContext
, execute its entry point, and then unload it. You can find a complete sample at https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.
Create a collectible AssemblyLoadContext
Derive your class from the AssemblyLoadContext and override its AssemblyLoadContext.Load method. That method resolves references to all assemblies that are dependencies of assemblies loaded into that AssemblyLoadContext
.
The following code is an example of the simplest custom AssemblyLoadContext
:
class TestAssemblyLoadContext : AssemblyLoadContext
{
public TestAssemblyLoadContext() : base(isCollectible: true)
{
}
protected override Assembly? Load(AssemblyName name)
{
return null;
}
}
As you can see, the Load
method returns null
. That means that all the dependency assemblies are loaded into the default context, and the new context contains only the assemblies explicitly loaded into it.
If you want to load some or all of the dependencies into the AssemblyLoadContext
too, you can use the AssemblyDependencyResolver
in the Load
method. The AssemblyDependencyResolver
resolves the assembly names to absolute assembly file paths. The resolver uses the .deps.json file and assembly files in the directory of the main assembly loaded into the context.
using System.Reflection;
using System.Runtime.Loader;
namespace complex
{
class TestAssemblyLoadContext : AssemblyLoadContext
{
private AssemblyDependencyResolver _resolver;
public TestAssemblyLoadContext(string mainAssemblyToLoadPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(mainAssemblyToLoadPath);
}
protected override Assembly? Load(AssemblyName name)
{
string? assemblyPath = _resolver.ResolveAssemblyToPath(name);
if (assemblyPath != null)
{
return LoadFromAssemblyPath(assemblyPath);
}
return null;
}
}
}
Use a custom collectible AssemblyLoadContext
This section assumes the simpler version of the TestAssemblyLoadContext
is being used.
You can create an instance of the custom AssemblyLoadContext
and load an assembly into it as follows:
var alc = new TestAssemblyLoadContext();
Assembly a = alc.LoadFromAssemblyPath(assemblyPath);
For each of the assemblies referenced by the loaded assembly, the TestAssemblyLoadContext.Load
method is called so that the TestAssemblyLoadContext
can decide where to get the assembly from. In this case, it returns null
to indicate that it should be loaded into the default context from locations that the runtime uses to load assemblies by default.
Now that an assembly was loaded, you can execute a method from it. Run the Main
method:
var args = new object[1] {new string[] {"Hello"}};
_ = a.EntryPoint?.Invoke(null, args);
After the Main
method returns, you can initiate unloading by either calling the Unload
method on the custom AssemblyLoadContext
or removing the reference you have to the AssemblyLoadContext
:
alc.Unload();
This is sufficient to unload the test assembly. Next, you'll put all of this into a separate noninlineable method to ensure that the TestAssemblyLoadContext
, Assembly
, and MethodInfo
(the Assembly.EntryPoint
) can't be kept alive by stack slot references (real- or JIT-introduced locals). That could keep the TestAssemblyLoadContext
alive and prevent the unload.
Also, return a weak reference to the AssemblyLoadContext
so that you can use it later to detect unload completion.
[MethodImpl(MethodImplOptions.NoInlining)]
static void ExecuteAndUnload(string assemblyPath, out WeakReference alcWeakRef)
{
var alc = new TestAssemblyLoadContext();
Assembly a = alc.LoadFromAssemblyPath(assemblyPath);
alcWeakRef = new WeakReference(alc, trackResurrection: true);
var args = new object[1] {new string[] {"Hello"}};
_ = a.EntryPoint?.Invoke(null, args);
alc.Unload();
}
Now you can run this function to load, execute, and unload the assembly.
WeakReference testAlcWeakRef;
ExecuteAndUnload("absolute/path/to/your/assembly", out testAlcWeakRef);
However, the unload doesn't complete immediately. As previously mentioned, it relies on the garbage collector to collect all the objects from the test assembly. In many cases, it isn't necessary to wait for the unload completion. However, there are cases where it's useful to know that the unload has finished. For example, you might want to delete the assembly file that was loaded into the custom AssemblyLoadContext
from disk. In such a case, the following code snippet can be used. It triggers garbage collection and waits for pending finalizers in a loop until the weak reference to the custom AssemblyLoadContext
is set to null
, indicating the target object was collected. In most cases, just one pass through the loop is required. However, for more complex cases where objects created by the code running in the AssemblyLoadContext
have finalizers, more passes might be needed.
for (int i = 0; testAlcWeakRef.IsAlive && (i < 10); i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Limitations
Assemblies loaded in a collectible AssemblyLoadContext
must abide by the general restrictions on collectible assemblies. The following limitations additionally apply:
- Assemblies written in C++/CLI are not supported.
- ReadyToRun generated code will be ignored.
The Unloading event
In some cases, it might be necessary for the code loaded into a custom AssemblyLoadContext
to perform some cleanup when the unloading is initiated. For example, it might need to stop threads or clean up strong GC handles. The Unloading
event can be used in such cases. You can hook a handler that performs the necessary cleanup to this event.
Troubleshoot unloadability issues
Due to the cooperative nature of the unloading, it's easy to forget about references that might be keeping the stuff in a collectible AssemblyLoadContext
alive and preventing unload. Here's a summary of entities (some of them nonobvious) that can hold the references:
- Regular references held from outside of the collectible
AssemblyLoadContext
that are stored in a stack slot or a processor register (method locals, either explicitly created by the user code or implicitly by the just-in-time (JIT) compiler), a static variable, or a strong (pinning) GC handle, and transitively pointing to:- An assembly loaded into the collectible
AssemblyLoadContext
. - A type from such an assembly.
- An instance of a type from such an assembly.
- An assembly loaded into the collectible
- Threads running code from an assembly loaded into the collectible
AssemblyLoadContext
. - Instances of custom, noncollectible
AssemblyLoadContext
types created inside of the collectibleAssemblyLoadContext
. - Pending RegisteredWaitHandle instances with callbacks set to methods in the custom
AssemblyLoadContext
.
Tip
Object references that are stored in stack slots or processor registers and that could prevent unloading of an AssemblyLoadContext
can occur in the following situations:
- When function call results are passed directly to another function, even though there is no user-created local variable.
- When the JIT compiler keeps a reference to an object that was available at some point in a method.
Debug unloading issues
Debugging issues with unloading can be tedious. You can get into situations where you don't know what can be holding an AssemblyLoadContext
alive, but the unload fails. The best tool to help with that is WinDbg (or LLDB on Unix) with the SOS plugin. You need to find what's keeping a LoaderAllocator
that belongs to the specific AssemblyLoadContext
alive. The SOS plugin lets you look at GC heap objects, their hierarchies, and roots.
To load the SOS plugin into the debugger, enter one of the following commands in the debugger command line.
In WinDbg (if it's not already loaded):
.loadby sos coreclr
In LLDB:
plugin load /path/to/libsosplugin.so
Now you'll debug an example program that has problems with unloading. The source code is available in the Example source code section. When you run it under WinDbg, the program breaks into the debugger right after attempting to check for the unload success. You can then start looking for the culprits.
Tip
If you debug using LLDB on Unix, the SOS commands in the following examples don't have the !
in front of them.
!dumpheap -type LoaderAllocator
This command dumps all objects with a type name containing LoaderAllocator
that are in the GC heap. Here's an example:
Address MT Size
000002b78000ce40 00007ffadc93a288 48
000002b78000ceb0 00007ffadc93a218 24
Statistics:
MT Count TotalSize Class Name
00007ffadc93a218 1 24 System.Reflection.LoaderAllocatorScout
00007ffadc93a288 1 48 System.Reflection.LoaderAllocator
Total 2 objects
In the "Statistics:" part, check the MT
(MethodTable
) that belongs to the System.Reflection.LoaderAllocator
, which is the object you care about. Then, in the list at the beginning, find the entry with MT
that matches that one, and get the address of the object itself. In this case, it's "000002b78000ce40".
Now that you know the address of the LoaderAllocator
object, you can use another command to find its GC roots:
!gcroot 0x000002b78000ce40
This command dumps the chain of object references that lead to the LoaderAllocator
instance. The list starts with the root, which is the entity that keeps the LoaderAllocator
alive and thus is the core of the problem. The root can be a stack slot, a processor register, a GC handle, or a static variable.
Here's an example of the output of the gcroot
command:
Thread 4ac:
000000cf9499dd20 00007ffa7d0236bc example.Program.Main(System.String[]) [E:\unloadability\example\Program.cs @ 70]
rbp-20: 000000cf9499dd90
-> 000002b78000d328 System.Reflection.RuntimeMethodInfo
-> 000002b78000d1f8 System.RuntimeType+RuntimeTypeCache
-> 000002b78000d1d0 System.RuntimeType
-> 000002b78000ce40 System.Reflection.LoaderAllocator
HandleTable:
000002b7f8a81198 (strong handle)
-> 000002b78000d948 test.Test
-> 000002b78000ce40 System.Reflection.LoaderAllocator
000002b7f8a815f8 (pinned handle)
-> 000002b790001038 System.Object[]
-> 000002b78000d390 example.TestInfo
-> 000002b78000d328 System.Reflection.RuntimeMethodInfo
-> 000002b78000d1f8 System.RuntimeType+RuntimeTypeCache
-> 000002b78000d1d0 System.RuntimeType
-> 000002b78000ce40 System.Reflection.LoaderAllocator
Found 3 roots.
The next step is to figure out where the root is located so you can fix it. The easiest case is when the root is a stack slot or a processor register. In that case, the gcroot
shows the name of the function whose frame contains the root and the thread executing that function. The difficult case is when the root is a static variable or a GC handle.
In the previous example, the first root is a local of type System.Reflection.RuntimeMethodInfo
stored in the frame of the function example.Program.Main(System.String[])
at address rbp-20
(rbp
is the processor register rbp
and -20 is a hexadecimal offset from that register).
The second root is a normal (strong) GCHandle
that holds a reference to an instance of the test.Test
class.
The third root is a pinned GCHandle
. This one is actually a static variable, but unfortunately, there's no way to tell. Statics for reference types are stored in a managed object array in internal runtime structures.
Another case that can prevent unloading of an AssemblyLoadContext
is when a thread has a frame of a method from an assembly loaded into the AssemblyLoadContext
on its stack. You can check that by dumping managed call stacks of all threads:
~*e !clrstack
The command means "apply to all threads the !clrstack
command". The following is the output of that command for the example. Unfortunately, LLDB on Unix doesn't have any way to apply a command to all threads, so you must manually switch threads and repeat the clrstack
command. Ignore all threads where the debugger says "Unable to walk the managed stack".
OS Thread Id: 0x6ba8 (0)
Child SP IP Call Site
0000001fc697d5c8 00007ffb50d9de12 [HelperMethodFrame: 0000001fc697d5c8] System.Diagnostics.Debugger.BreakInternal()
0000001fc697d6d0 00007ffa864765fa System.Diagnostics.Debugger.Break()
0000001fc697d700 00007ffa864736bc example.Program.Main(System.String[]) [E:\unloadability\example\Program.cs @ 70]
0000001fc697d998 00007ffae5fdc1e3 [GCFrame: 0000001fc697d998]
0000001fc697df28 00007ffae5fdc1e3 [GCFrame: 0000001fc697df28]
OS Thread Id: 0x2ae4 (1)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process
Failed to start stack walk: 80070057
OS Thread Id: 0x61a4 (2)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process
Failed to start stack walk: 80070057
OS Thread Id: 0x7fdc (3)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process
Failed to start stack walk: 80070057
OS Thread Id: 0x5390 (4)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process
Failed to start stack walk: 80070057
OS Thread Id: 0x5ec8 (5)
Child SP IP Call Site
0000001fc70ff6e0 00007ffb5437f6e4 [DebuggerU2MCatchHandlerFrame: 0000001fc70ff6e0]
OS Thread Id: 0x4624 (6)
Child SP IP Call Site
GetFrameContext failed: 1
0000000000000000 0000000000000000
OS Thread Id: 0x60bc (7)
Child SP IP Call Site
0000001fc727f158 00007ffb5437fce4 [HelperMethodFrame: 0000001fc727f158] System.Threading.Thread.SleepInternal(Int32)
0000001fc727f260 00007ffb37ea7c2b System.Threading.Thread.Sleep(Int32)
0000001fc727f290 00007ffa865005b3 test.Program.ThreadProc() [E:\unloadability\test\Program.cs @ 17]
0000001fc727f2c0 00007ffb37ea6a5b System.Threading.Thread.ThreadMain_ThreadStart()
0000001fc727f2f0 00007ffadbc4cbe3 System.Threading.ExecutionContext.RunInternal(System.Threading.ExecutionContext, System.Threading.ContextCallback, System.Object)
0000001fc727f568 00007ffae5fdc1e3 [GCFrame: 0000001fc727f568]
0000001fc727f7f0 00007ffae5fdc1e3 [DebuggerU2MCatchHandlerFrame: 0000001fc727f7f0]
As you can see, the last thread has test.Program.ThreadProc()
. This is a function from the assembly loaded into the AssemblyLoadContext
, and so it keeps the AssemblyLoadContext
alive.
Example source code
The following code that contains unloadability issues is used in the previous debugging example.
Main testing program
using System;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;
namespace example
{
class TestAssemblyLoadContext : AssemblyLoadContext
{
public TestAssemblyLoadContext() : base(true)
{
}
protected override Assembly? Load(AssemblyName name)
{
return null;
}
}
class TestInfo
{
public TestInfo(MethodInfo? mi)
{
_entryPoint = mi;
}
MethodInfo? _entryPoint;
}
class Program
{
static TestInfo? entryPoint;
[MethodImpl(MethodImplOptions.NoInlining)]
static int ExecuteAndUnload(string assemblyPath, out WeakReference testAlcWeakRef, out MethodInfo? testEntryPoint)
{
var alc = new TestAssemblyLoadContext();
testAlcWeakRef = new WeakReference(alc);
Assembly a = alc.LoadFromAssemblyPath(assemblyPath);
if (a == null)
{
testEntryPoint = null;
Console.WriteLine("Loading the test assembly failed");
return -1;
}
var args = new object[1] {new string[] {"Hello"}};
// Issue preventing unloading #1 - we keep MethodInfo of a method
// for an assembly loaded into the TestAssemblyLoadContext in a static variable.
entryPoint = new TestInfo(a.EntryPoint);
testEntryPoint = a.EntryPoint;
var oResult = a.EntryPoint?.Invoke(null, args);
alc.Unload();
return (oResult is int result) ? result : -1;
}
static void Main(string[] args)
{
WeakReference testAlcWeakRef;
// Issue preventing unloading #2 - we keep MethodInfo of a method for an assembly loaded into the TestAssemblyLoadContext in a local variable
MethodInfo? testEntryPoint;
int result = ExecuteAndUnload(@"absolute/path/to/test.dll", out testAlcWeakRef, out testEntryPoint);
for (int i = 0; testAlcWeakRef.IsAlive && (i < 10); i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
System.Diagnostics.Debugger.Break();
Console.WriteLine($"Test completed, result={result}, entryPoint: {testEntryPoint} unload success: {!testAlcWeakRef.IsAlive}");
}
}
}
Program loaded into the TestAssemblyLoadContext
The following code represents the test.dll passed to the ExecuteAndUnload
method in the main testing program.
using System;
using System.Runtime.InteropServices;
using System.Threading;
namespace test
{
class Test
{
}
class Program
{
public static void ThreadProc()
{
// Issue preventing unloading #4 - a thread running method inside of the TestAssemblyLoadContext at the unload time
Thread.Sleep(Timeout.Infinite);
}
static GCHandle handle;
static int Main(string[] args)
{
// Issue preventing unloading #3 - normal GC handle
handle = GCHandle.Alloc(new Test());
Thread t = new Thread(new ThreadStart(ThreadProc));
t.IsBackground = true;
t.Start();
Console.WriteLine($"Hello from the test: args[0] = {args[0]}");
return 1;
}
}
}