Mechanism for Unloading Assemblies

This article is for the 10th day of the C# Advent Calendar 2019.

An application I create for work (made with .NET Framework) has a user plugin (DLL) loading feature.

This feature allows users to add functionality to the development tool by creating their own DLLs according to rules.

This feature involves the development tool loading the relevant DLLs once, querying them, and then unloading DLLs that don’t need to be loaded.

When porting this development tool to .NET Core, a different method than .NET Framework was required, which I will introduce.

AppDomain Cannot Be Used

In .NET Framework, AppDomain was used to unload assemblies.

Besides the application’s default AppDomain, another domain is created, and assemblies are loaded within it.

After using the assembly’s functionality via interfaces, etc., the AppDomain’s Unload method is called to unload the assembly along with the domain.

However, in .NET Core, only one AppDomain is supported, so this method cannot be used.

Instead, .NET Core provides AssemblyLoadContext for unloading assemblies, so I will try using this.

What is AssemblyLoadContext?

AssemblyLoadContext, similar to AppDomain, allows assembly loading within a closed scope.

Assemblies loaded into it can be unloaded together by calling the AssemblyLoadContext’s Unload method, but there are points to note.

Note 1: Set isCollectible to true

The isCollectible constructor argument of AssemblyLoadContext must be set to true.

This is because, for performance reasons, it defaults to false.

Calling Unload while it’s false will cause an exception.

System.InvalidOperationException
  HResult=0x80131509
  Message=Cannot unload non-collectible AssemblyLoadContext.
  Source=System.Private.CoreLib

Note 2: Unloading is Cooperative, Not Forced

Calling the AssemblyLoadContext’s Unload method only initiates the process; it does not complete it immediately.

Unloading completes only when the following conditions are met:

  • No threads have methods from assemblies loaded into the AssemblyLoadContext on their call stacks.
  • Types from assemblies loaded into the AssemblyLoadContext, instances of those types, and the assemblies themselves are no longer referenced.

In other words, without careful design, unloading might never complete.

Creating a Sample

Let’s actually implement a mechanism to unload assemblies.

1. Create the Class Library to be Loaded

Anything will do, but I defined a simple class like the one below. I specified netstandard2.1 for the TargetFramework. Make sure this is output as ClassLibrary1.dll.

namespace ClassLibrary1
{
    public class Class1
    {
        public void Hello(int arg)
        {
            Console.WriteLine(arg);
        }
    }
}

2. Implement AssemblyLoadContext on the Loading Side

First, create an AssemblyLoadContext for loading assemblies. This time, I created a simple class like the one below.

The important part is setting isCollectible to true in the constructor. Setting this flag to true enables unloading support.

class TestAssemblyLoadContext : AssemblyLoadContext
{
    public TestAssemblyLoadContext() : base(isCollectible: true)
    {
    }
}

3. Implement the DLL Loading & Unloading Mechanism

Create a function to load the ClassLibrary1.dll created earlier, call a function, and then unload it.

[MethodImpl(MethodImplOptions.NoInlining)]
static void ExecuteAndUnload(string assemblyPath, out WeakReference alcWeakRef)
{
    // Create AssemblyLoadContext to load the assembly
    var alc = new TestAssemblyLoadContext();

    // Load the assembly
    Assembly a = alc.LoadFromAssemblyPath(assemblyPath);

    // Set a weak reference to detect unloading from outside
    alcWeakRef = new WeakReference(alc, trackResurrection: true);

    // Call function via reflection
    var type = a.GetType("ClassLibrary1.Class1");
    var instance = Activator.CreateInstance(type);
    var helloMethod = type.GetMethod("Hello");
    helloMethod.Invoke(instance, new object[] { 1 });

    // Perform unload
    alc.Unload();
}

Add MethodImplOptions.NoInlining to the function just in case.

This is to prevent the ExecuteAndUnload function from being inlined.

Unloading using AssemblyLoadContext cannot be performed if types or instances within the AssemblyLoadContext are referenced from outside.

Therefore, if inlined, there is a risk that references might remain in the caller of ExecuteAndUnload (in this case, the Main function).

(* However, in the sample created this time, unloading completed even with MethodImplOptions.AggressiveInlining)

[MethodImpl(MethodImplOptions.NoInlining)]
static void ExecuteAndUnload(string assemblyPath, out WeakReference alcWeakRef)

Create an instance of AssemblyLoadContext and load the assembly into it.

assemblyPath contains the file path of the dll or exe.

 // Create AssemblyLoadContext to load the assembly
var alc = new TestAssemblyLoadContext();

 // Load the assembly
Assembly a = alc.LoadFromAssemblyPath(assemblyPath);

Keep a weak reference to the AssemblyLoadContext.

This reference is used by the caller of ExecuteAndUnload to determine if unloading is complete.

// Set a weak reference to detect unloading from outside
alcWeakRef = new WeakReference(alc, trackResurrection: true);

For this experiment, I specified the class name to create an instance and called the function using reflection.

When creating a plugin, it’s more practical to have ClassLibrary1.dll implement an interface defined in a separate assembly and call it via the interface.

// Call function via reflection
var type = a.GetType("ClassLibrary1.Class1");
var instance = Activator.CreateInstance(type);
var helloMethod = type.GetMethod("Hello");
helloMethod.Invoke(instance, new object[] { 1 });

Call the Unload method to perform unloading.

// Perform unload
alc.Unload();

Trying to Unload Assembly Using ExecuteAndUnload

I implemented the Main function as follows and actually tried dynamically loading and then unloading the assembly.

This time, I checked if unloading was successful by seeing if the dll file could be deleted. If unloading is not complete, Delete will fail.

static void Main(string[] args)
{
    var myDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);

    // Path to the assembly (dll) to load
    var assemblyPath = Path.Combine(myDirectory, @"..\..\..\..\ClassLibrary1\bin\Debug\netstandard2.1\ClassLibrary1.dll");

    // Load the assembly and call the function
    ExecuteAndUnload(assemblyPath, out WeakReference alcWeakRef);

    try
    {
        File.Delete(assemblyPath);
    }
    catch(UnauthorizedAccessException)
    {
        Console.WriteLine("Cannot delete because unloading is not complete");
    }

    // Wait until unloaded
    int counter = 0;
    for (counter = 0; alcWeakRef.IsAlive && (counter < 10); counter++)
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
    }

    if (counter < 10)
    {
        // At this stage, it's unloaded, so it can be deleted
        File.Delete(assemblyPath);

        Console.WriteLine("Unload successful");
    }
    else
    {
        Console.WriteLine("Unload failed");
    }

    Console.ReadKey();
}

After calling ExecuteAndUnload to load the assembly -> execute the function within the assembly -> unload, I checked the completion of unloading using WeakReference.

To remove references to the AssemblyLoadContext being unloaded, I forced garbage collection using GC.Collect().

In the results I tried this time, when unloading was successful, the value of counter was 2 when exiting the loop.

Also, immediately after exiting ExecuteAndUnload, deleting the dll always failed, but after waiting for unloading to complete, deletion succeeded, confirming that assembly unloading was successful.

Trying to Be Mean

The above sample was a case where unloading succeeded, but I tried a bit to see what kind of description would cause it to fail.

Case Where Thread Inside Loaded Assembly Doesn’t End

I changed the content of the Hello method in ClassLibrary1.dll as follows.

Once Hello is called, a non-terminating thread is created with Task.Run.

public void Hello(int arg)
{
    Task.Run(() =>
    {
        while(true)
        {
            Thread.Sleep(1);
        }
    });
    Console.WriteLine(arg);
}

Loading this assembly and attempting to unload it failed.

Trying to Hold AssemblyLoadContext

Created a static variable like the one below in the class that loads the assembly,

private static AssemblyLoadContext assemblyLoadContext;

And tried holding the reference to TestAssemblyLoadContext within ExecuteAndUnload.

var alc = new TestAssemblyLoadContext();
assemblyLoadContext = alc;

This case also failed to unload because a reference to AssemblyLoadContext remained.

Trying to Hold Type Within Loaded Assembly

Created a static variable like the one below in the class that loads the assembly,

private static Type class1Type;

And tried holding the Type of ClassLibrary1.Class1 within ExecuteAndUnload.

var type = a.GetType("ClassLibrary1.Class1");
class1Type = type;

This case also failed to unload because a reference to the Type remained.

Summary

Even in a .NET Core environment, it was possible to load and unload assemblies using AssemblyLoadContext.

However, if you forget to clean up the assembly being unloaded, unloading will fail, so it seems best to perform the process from loading to unloading within a closed area as much as possible.

Realistically, cases requiring assembly unloading might not be that common, but knowing about it might be useful somewhere.

Sample Project

The sample created this time is in the repository below.

Reference Sites

How to use and debug assembly unloadability in .NET Core

Collectible assemblies in .NET Core 3.0 | StrathWeb. A free flowing web tech monologue.