Understanding System.Runtime.Loader.AssemblyLoadContext

The AssemblyLoadContext class is unique to .NET Core. This article attempts to supplement the AssemblyLoadContext API documentation with conceptual information.

This article is relevant to developers implementing dynamic loading, especially dynamic loading framework developers.

What is the AssemblyLoadContext?

Every .NET Core application implicitly uses the AssemblyLoadContext. It's the runtime's provider for locating and loading dependencies. Whenever a dependency is loaded, an AssemblyLoadContext instance is invoked to locate it.

  • It provides a service of locating, loading, and caching managed assemblies and other dependencies.

  • To support dynamic code loading and unloading, it creates an isolated context for loading code and its dependencies in their own AssemblyLoadContext instance.

When do you need multiple AssemblyLoadContext instances?

A single AssemblyLoadContext instance is limited to loading exactly one version of an Assembly per simple assembly name, AssemblyName.Name.

This restriction can become a problem when loading code modules dynamically. Each module is independently compiled and may depend on different versions of an Assembly. This problem commonly occurs when different modules depend on different versions of a commonly used library.

To support dynamically loading code, the AssemblyLoadContext API provides for loading conflicting versions of an Assembly in the same application. Each AssemblyLoadContext instance provides a unique dictionary mapping each AssemblyName.Name to a specific Assembly instance.

It also provides a convenient mechanism for grouping dependencies related to a code module for later unload.

What is special about the AssemblyLoadContext.Default instance?

The AssemblyLoadContext.Default instance is automatically populated by the runtime at startup. It uses default probing to locate and find all static dependencies.

It solves the most common dependency loading scenarios.

How does AssemblyLoadContext support dynamic dependencies?

AssemblyLoadContext has various events and virtual functions that can be overridden.

The AssemblyLoadContext.Default instance only supports overriding the events.

The articles Managed assembly loading algorithm, Satellite assembly loading algorithm, and Unmanaged (native) library loading algorithm refer to all the available events and virtual functions. The articles show each event and function's relative position in the loading algorithms. This article doesn't reproduce that information.

This section covers the general principles for the relevant events and functions.

  • Be repeatable. A query for a specific dependency must always result in the same response. The same loaded dependency instance must be returned. This requirement is fundamental for cache consistency. For managed assemblies in particular, we're creating a Assembly cache. The cache key is a simple assembly name, AssemblyName.Name.
  • Typically don't throw. It's expected that these functions return null rather than throw when unable to find the requested dependency. Throwing will prematurely end the search and be propagate an exception to the caller. Throwing should be restricted to unexpected errors like a corrupted assembly or an out of memory condition.
  • Avoid recursion. Be aware that these functions and handlers implement the loading rules for locating dependencies. Your implementation shouldn't call APIs that trigger recursion. Your code should typically call AssemblyLoadContext load functions that require a specific path or memory reference argument.
  • Load into the correct AssemblyLoadContext. The choice of where to load dependencies is application-specific. The choice is implemented by these events and functions. When your code calls AssemblyLoadContext load-by-path functions call them on the instance where you want the code loaded. Sometime returning null and letting the AssemblyLoadContext.Default handle the load may be the simplest option.
  • Be aware of thread races. Loading can be triggered by multiple threads. The AssemblyLoadContext handles thread races by atomically adding assemblies to its cache. The race loser's instance is discarded. In your implementation logic, don't add extra logic that doesn't handle multiple threads properly.

How are dynamic dependencies isolated?

Each AssemblyLoadContext instance represents a unique scope for Assembly instances and Type definitions.

There's no binary isolation between these dependencies. They're only isolated by not finding each other by name.

In each AssemblyLoadContext:

How are dependencies shared?

Dependencies can easily be shared between AssemblyLoadContext instances. The general model is for one AssemblyLoadContext to load a dependency. The other shares the dependency by using a reference to the loaded assembly.

This sharing is required of the runtime assemblies. These assemblies can only be loaded into the AssemblyLoadContext.Default. The same is required for frameworks like ASP.NET, WPF, or WinForms.

It's recommended that shared dependencies should be loaded into AssemblyLoadContext.Default. This sharing is the common design pattern.

Sharing is implemented in the coding of the custom AssemblyLoadContext instance. AssemblyLoadContext has various events and virtual functions that can be overridden. When any of these functions return a reference to an Assembly instance that was loaded in another AssemblyLoadContext instance, the Assembly instance is shared. The standard load algorithm defers to AssemblyLoadContext.Default for loading to simplify the common sharing pattern. See Managed assembly loading algorithm.

Complications

Type conversion issues

When two AssemblyLoadContext instances contain type definitions with the same name, they're not the same type. They're the same type if and only if they come from the same Assembly instance.

To complicate matters, exception messages about these mismatched types can be confusing. The types are referred to in the exception messages by their simple type names. The common exception message in this case would be of the form:

Object of type 'IsolatedType' cannot be converted to type 'IsolatedType'.

Debugging type conversion issues

Given a pair of mismatched types it's important to also know:

Given two objects a and b, evaluating the following in the debugger will be helpful:

// In debugger look at each assembly's instance, Location, and FullName
a.GetType().Assembly
b.GetType().Assembly
// In debugger look at each AssemblyLoadContext's instance and name
System.Runtime.AssemblyLoadContext.GetLoadContext(a.GetType().Assembly)
System.Runtime.AssemblyLoadContext.GetLoadContext(b.GetType().Assembly)

Resolving type conversion issues

There are two design patterns for solving these type conversion issues.

  1. Use common shared types. This shared type can either be a primitive runtime type, or it can involve creating a new shared type in a shared assembly. Often the shared type is an interface defined in an application assembly. See also: How are dependencies shared?.

  2. Use marshaling techniques to convert from one type to another.