Collectible assemblies in .NET Core 3.0

Since the beginning of .NET Core, the one feature that I have been most anxiously waiting for, has been support for collectible assemblies. It took a while (a while!), but finally, in .NET Core 3.0 (at the time of writing 3.0.0-preview-27122-01 from 2018-12-04), it’s here.

It’s going to be a killer functionality, that will support some excellent use cases in .NET Core – especially around application plugins, extensibility and dynamic assembly generation.

Let’s have a quick look at how we can load and unload assemblies in .NET Core.

Prerequisites

To get started, you need to install .NET Core SDK 3.0 from here.

You may want to use Visual Studio 2019 Preview (from here) but it’s not mandatory. The sample should work in Visual Studio 2017 too, as long as you go to Tools > Options > Project and Solutions > .NET Core and check the Use previews of the .NET Core SDK (I believe you need version 15.9+ for that option to be there though).

Getting started

To make sure we pin the SDK version correctly, let’s start by generating a global.json too. In my case I am using the current 3.0 SDK version (note that the SDK version is different from the runtime version we mentioned earlier – that’s “normal”).

Now let’s create a new .NET Core 3.0 project now. My project looks like this:

I also already added a reference to a Roslyn NuGet package – it’s not necessary yet, but we will need it later.

Collectible AssemblyLoadContext

Collectible assemblies are implemented in .NET Core 3.0 using AssemblyLoadContext. They have been part of .NET Core since the beginning and allowed developers to separate loaded assemblies from each other, for example to avoid name collisions. Before you go on any further with this blog post, I recommend that you read up on AssemblyLoadContext in the CoreCLR, as it has some excellent information.

Starting in .NET Core 3.0, AssemblyLoadContext can be unloaded too. This is controlled through a newly added bool constructor argument – isCollectible. Once a collectible AssemblyLoadContext is created, it can be unloaded, including all of the assemblies that were loaded into it, by calling the Unload() method.

What is mildly amusing, is that if you create an instance of an AssemblyLoadContext, that is not collectible, and then try to Unload() it, it will throw ¯\_(ツ)_/¯.

AssemblyLoadContext is actually abstract so in order to do anything with it, we need to subclass it. This is shown next, where we create our custom CollectibleAssemblyLoadContext.

Note that the mandatory abstract method to load an assembly by name, doesn’t necessarily need to have a working implementation body – in our case we will never be loading assemblies by name anyway.

At that point we can start testing whether (and how) collecting those contexts actually works. We will explore two scenarios:

  • loading, executing and unloading a physical assembly (DLL)
  • compiling, emitting, executing and unload a dynamic assembly (using Roslyn)

Collecting a loaded DLL

To try this scenario out, I will create a very simple netstandard2.0 library. This is the entire code:

Now, let’s assume it is already compiled to a DLL – it will also be available in the source that accompanies this article (link at the end, for the impatient ones – here).

We can write a program that simply runs a loop an X amounts of time, loads that DLL, picks up the Greeter type, instantiates it and invokes the Hello() method. And of course then unloads it. That code for that is shown next.

In our example we iterate through the loop 3000 times, and for each of the executions, we will create an instance of our CollectibleAssemblyLoadContext. We then load the physical DLL into that load context.

Actually, for our use case, AssemblyLoadContext exposes a promisingly looking method called LoadFromAssemblyPath(). However, this is not the one that we want to use – the reason is that it would lock the loaded assembly on disk, and we’d like to avoid that. Because of that, I prefer to load the assembly into memory using a file stream first, and then load the assembly from that stream, and that’s exactly what happens in the code snippet.

Afterwards it is pretty straight forward, once the assembly is loaded, we use reflection to invoke the code that we wrote earlier. We should see the console output, as our library class (Greeter) is actually going to be printing stuff out. And of course the most important thing – at the end of the execution, we unload the AssemblyLoadContext.

It’s worth mentioning at this point that, calling Unload() doesn’t immediately collect the assemblies. It just initiates the process, and the actual collection will happen when the garbage collection runs.

It’s also important that there are no remaining GC references to anything that lives inside the AssemblyLoadContext on which you call the Unload() on – otherwise it will not be collected. In that sense, that behavior is semantically consistent with how AppDomain.Unload() worked on Desktop .NET. In fact, this is one of the reasons why I marked the entire method with [MethodImpl(MethodImplOptions.NoInlining)] – so that no reference to our load context leaks into the Main method and impacts the behavior of the GC.

Before we exit from our program, I manually call garbage collection to try to clean up as much as possible. To be honest this is not a very scientific approach, as there are much more accurate ways of achieving this, but they’d unnecessarily obfuscate the example, so I think this is good enough for a simple demo.

Let’s look at the memory consumption from this code – gathered using dotMemory.

In the case of using collectible AssemblyLoadContext, the memory usage tops at around 80MB, and after final GC stabilizes at around 29MB.

If I replace the collectible AssemblyLoadContext with a “standard”, old school one that cannot be collected, the results are the following:

It looks a lot more dramatic – the memory usage tops at 140MB, and actually stays there, since we can’t collect anything.

Collecting a dynamically emitted assembly

We can also collect an assembly that is not a physical DLL, but is actually compiled on the fly in our app. This is a very attractive scenario, especially as dynamically emitting assemblies is very easy with Roslyn.

Here is similar code to the one we used above, but instead of loading a DLL from disk, we emit in memory, into a MemoryStream.

I will not go into the Roslyn API details – they are not really that relevant here. Suffice to say, we create a reusable compilation that we later emit into an Assembly 3000 times. The emitted assembly behaves exactly the same as our previously used physical DLL.

Let’s look at the memory consumption again – but the results will, unsurprisingly, be in line with the previous ones.

In the case of using collectible AssemblyLoadContext, the memory usage tops at 120MB, and after the last GC round stabilizes around 70MB. And similarly as before, replacing the load context with a one that cannot be collected yields some quite depressing results:

The memory usage tops at 180MB, and actually stays there, since we can’t collect anything.

Final thoughts

Hopefully you will find this feature useful – because I am extremely excited about it. There are still some APIs that are missing, for example, a flag on an Assembly that will indicate whether it’s collectible or not. It is however already planned.

I really look forward to using this in my plugin and dynamic code architectures!

All the code from this article, as usually, is available on Github.