Collectible assemblies in .NET Core 3.0

Β· 1558 words Β· 4 minutes to read

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

dotnet new globaljson -sdk-version 3.0.100-preview-009812```

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

```xml
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>  
<OutputType>Exe</OutputType>  
<TargetFramework>netcoreapp3.0</TargetFramework>  
</PropertyGroup>

<ItemGroup>  
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="2.10.0" />  
</ItemGroup>

</Project>```

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

### Collectible AssemblyLoadContext {#toc_3}

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][3], 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_.

```csharp
public class CollectibleAssemblyLoadContext : AssemblyLoadContext  
{  
public CollectibleAssemblyLoadContext() : base(isCollectible: true)  
{ }

protected override Assembly Load(AssemblyName assemblyName)  
{  
return null;  
}  
}

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:

using System;

namespace SampleLibrary  
{  
public class Greeter  
{  
public void Hello(int iteration)  
{  
Console.WriteLine($"Hello {iteration}!");  
}  
}  
}

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.

class Program  
{  
[MethodImpl(MethodImplOptions.NoInlining)]  
private static void ExecuteAssembly(int i)  
{  
var context = new CollectibleAssemblyLoadContext();  
var assemblyPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "SampleLibrary", "bin", "Debug", "netstandard2.0", "SampleLibrary.dll");  
using (var fs = new FileStream(assemblyPath, FileMode.Open, FileAccess.Read))  
{  
var assembly = context.LoadFromStream(fs);

var type = assembly.GetType("SampleLibrary.Greeter");  
var greetMethod = type.GetMethod("Hello");

var instance = Activator.CreateInstance(type);  
greetMethod.Invoke(instance, new object[] { i });  
}

context.Unload();  
}

static void Main(string[] args)  
{  
for (var i = 0; i < 3000; i++) { ExecuteAssembly(i); } GC.Collect(); GC.WaitForPendingFinalizers(); Console.ReadKey(); } }

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.

class Program  
{  
private static Assembly SystemRuntime = Assembly.Load(new AssemblyName("System.Runtime"));

[MethodImpl(MethodImplOptions.NoInlining)]  
private static void ExecuteInMemoryAssembly(Compilation compilation, int i)  
{  
var context = new CollectibleAssemblyLoadContext();

using (var ms = new MemoryStream())  
{  
var cr = compilation.Emit(ms);  
ms.Seek(0, SeekOrigin.Begin);  
var assembly = context.LoadFromStream(ms);

var type = assembly.GetType("Greeter");  
var greetMethod = type.GetMethod("Hello");

var instance = Activator.CreateInstance(type);  
var result = greetMethod.Invoke(instance, new object[] { i });  
}

context.Unload();  
}

static void Main(string[] args)  
{  
var compilation = CSharpCompilation.Create("DynamicAssembly", new[] { CSharpSyntaxTree.ParseText(@"  
public class Greeter  
{  
public void Hello(int iteration)  
{  
System.Console.WriteLine($""Hello in memory {iteration}!"");  
}  
}") },  
new[]  
{  
MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location),  
MetadataReference.CreateFromFile(typeof(Console).GetTypeInfo().Assembly.Location),  
MetadataReference.CreateFromFile(SystemRuntime.Location),  
},  
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

for (var i = 0; i < 3000; i++) { ExecuteInMemoryAssembly(compilation, i); } GC.Collect(); GC.WaitForPendingFinalizers(); Console.ReadKey(); } }

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.

About


Hi! I'm Filip W., a cloud architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github and on Mastodon.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP