Modern .NET applications increasingly rely on plugin architectures to extend functionality, enhance modularity, and allow for independent updates. A cornerstone of a robust plugin system is effective dependency isolation: ensuring that a plugin’s dependencies don’t conflict with the host application or other plugins. While AssemblyLoadContext
(ALC) in .NET (Core and later versions) provides powerful mechanisms for isolating managed assemblies, managing native library dependencies introduces unique challenges, especially when plugins need to share common native resources.
This article delves into leveraging AssemblyLoadContext
to achieve strong dependency isolation for plugins, with a particular focus on strategies for resolving and managing native libraries. We’ll explore how to build custom ALCs, use AssemblyDependencyResolver
for streamlined dependency loading, and implement techniques for both isolating and controllably sharing native libraries. You can find more details on general plugin creation in the .NET documentation on creating applications with plugins.
The Challenge: Isolated Plugins with Native Code
Plugins often come with their own set of managed dependencies (DLLs). If Plugin A requires Newtonsoft.Json
v12 and Plugin B needs v13, loading them into the same default application context would lead to conflicts. AssemblyLoadContext
solves this by allowing each plugin to load its assemblies into a separate context.
However, the complexity increases when plugins use native libraries (e.g., .dll
on Windows, .so
on Linux, .dylib
on macOS) via P/Invoke:
- Native Library Version Conflicts: Similar to managed dependencies, different plugins might require different versions of the same native library.
- Process-Wide Loading: Unlike managed assemblies scoped to an ALC, native libraries are typically loaded at the process level by the operating system. This means care must be taken to avoid symbol collisions or unintended side-effects if multiple plugins load different versions of a native library not designed for side-by-side execution.
- Shared Native Dependencies: Sometimes, multiple plugins or the host and plugins must share a single instance of a native library (e.g., a hardware SDK, a common cryptography library).
- Resolution Paths: Ensuring that each plugin’s ALC can correctly locate its specific native dependencies, which might be platform-specific, is crucial. This is especially true when considering how .NET finds native dependencies.
Core Concepts: AssemblyLoadContext
and AssemblyDependencyResolver
At its heart, AssemblyLoadContext
is the .NET runtime’s service for locating, loading, and caching managed assemblies. By creating custom ALC instances, developers can isolate loaded assemblies.
Key characteristics include:
- Isolation: Each ALC instance provides a unique scope for assembly instances. This means
AssemblyA
version 1.0 inPluginAlc1
is distinct fromAssemblyA
version 2.0 inPluginAlc2
. - Customizable Loading Logic: Developers can override methods in a custom ALC to control how assemblies and their dependencies are found and loaded.
- Native Library Loading: ALCs provide the
LoadUnmanagedDll
method, which is the primary hook for controlling how native libraries are loaded for P/Invokes originating from assemblies within that ALC.
Introduced in .NET Core 3.0, AssemblyDependencyResolver
simplifies dependency resolution for plugins. Initialized with the path to a plugin’s main assembly, it uses the plugin’s .deps.json
file (generated during build) to find paths for both managed assemblies and native libraries.
Building a Custom AssemblyLoadContext
for Plugins
Let’s construct a PluginLoadContext
to isolate a plugin’s dependencies. This context will be responsible for loading the plugin’s assemblies and its native dependencies.
|
|
Explanation:
- Constructor:
- Takes the full path to the plugin’s main DLL (
pluginMainAssemblyPath
). - Assigns a name to the ALC using
base(name, isCollectible)
, which is helpful for debugging. - Optionally allows the ALC to be
isCollectible
for unloading. - Initializes
AssemblyDependencyResolver
with the plugin’s path.
- Takes the full path to the plugin’s main DLL (
Load(AssemblyName assemblyName)
Override:- Uses
_resolver.ResolveAssemblyToPath
to find managed dependencies listed in the plugin’s.deps.json
. - If found,
LoadFromAssemblyPath
loads it into the currentPluginLoadContext
. - Returning
null
allows the runtime to try resolving the assembly inAssemblyLoadContext.Default
or other contexts, crucial for shared framework assemblies or host-provided shared contract assemblies.
- Uses
LoadUnmanagedDll(string unmanagedDllName)
Override:- Uses
_resolver.ResolveUnmanagedDllToPath
to find native libraries associated with the plugin (also via.deps.json
). - If found,
LoadUnmanagedDllFromPath
attempts to load the native library. The OS typically loads native libraries process-wide, but this call associates the P/Invoke resolution with this ALC. - Returning
IntPtr.Zero
instructs the runtime to use its default native library search mechanism. This is important if the native library is expected to be already loaded by the host or found via system paths.
- Uses
Loading a Plugin
Here’s how you might load a plugin into its own PluginLoadContext
:
|
|
This approach creates a new PluginLoadContext
for each plugin, ensuring their dependencies are isolated.
Handling Shared Native Libraries
A common scenario is when a native library must be shared across the host and multiple plugins, or among plugins themselves (e.g., a logging library with native components, a graphics driver interface). Naively loading it multiple times can lead to errors or unexpected behavior if the library isn’t designed for it.
Strategy 1: Host-Provided Shared Native Library
If the host application is responsible for providing and initializing a shared native library, plugins should bind to this already loaded instance.
- Host Loads the Native Library: The host explicitly loads the shared native library using
NativeLibrary.Load("path/to/shared_native.dll")
during its startup. TheNativeLibrary
class offers methods for this. - Plugin ALC Defers Loading: The plugin’s custom
PluginLoadContext
(specifically itsLoadUnmanagedDll
override) should defer the loading of this specific shared native library to the runtime’s default mechanism.
|
|
By returning IntPtr.Zero
for the SharedNativeLibraryName
, the runtime’s default P/Invoke resolution mechanism takes over. If the host has already loaded this library into the process, the OS typically provides a handle to the existing instance.
Strategy 2: First Plugin Loads, Others Share (More Complex)
This is trickier and generally less robust. If the native library isn’t explicitly loaded by the host, the first plugin ALC to request it might load it. Subsequent ALCs requesting the same library (by the same resolved path) would typically get the handle to the already loaded instance from the OS. However, managing its lifecycle (especially unloading) becomes complicated.
Key Consideration for Native Libraries: The operating system loader is the ultimate authority for loading native libraries. AssemblyLoadContext.LoadUnmanagedDll
and NativeLibrary.Load
are .NET’s ways to interact with this OS-level mechanism. An ALC’s LoadUnmanagedDll
primarily resolves a library name to a path. The OS then handles the actual loading from that path. If a library from a specific canonical path is already loaded in the process, the OS usually returns a handle to the existing module.
Fine-Grained Control with NativeLibrary.SetDllImportResolver
For even more precise control over how P/Invoke calls resolve native libraries within a specific assembly, .NET provides NativeLibrary.SetDllImportResolver
. This method allows you to register a callback that the runtime invokes when a DllImport cannot be satisfied for a given assembly. The P/Invoke marshalling system checks for a registered DllImportResolver
for an assembly before falling back to the AssemblyLoadContext.LoadUnmanagedDll
method of the ALC that loaded the assembly. See the .NET documentation on P/Invoke source generation and resolvers for more context.
This is particularly useful if a plugin needs to:
- Load native libraries from very specific, dynamically determined paths not covered by
.deps.json
. - Implement complex fallback logic for native library resolution.
- Handle platform-specific naming conventions directly.
|
|
When to Use LoadUnmanagedDll
vs. SetDllImportResolver
:
AssemblyLoadContext.LoadUnmanagedDll
: Broader control for all P/Invokes from assemblies within that ALC. Good for general plugin dependency resolution via.deps.json
.NativeLibrary.SetDllImportResolver
: Finer-grained, assembly-specific control. Useful for complex scenarios or when a plugin needs to manage its native dependencies in a very particular way, bypassing or augmenting the ALC’s default behavior for specific libraries.
Sharing Types Between Host and Plugins
When plugins are isolated in their own ALCs, a Type
from the host (e.g., IPluginService
in Host.exe
) is different from the “same” Type
if the plugin loads its own copy of Host.Interfaces.dll
. This leads to InvalidCastException
.
Solution: Define shared interfaces and types in a separate contracts assembly (e.g., Plugin.Contracts.dll
).
- The host references this contracts assembly.
- Plugins reference this contracts assembly.
- Crucially, configure the plugin’s ALC to prefer loading these shared types from the host’s ALC (
AssemblyLoadContext.Default
).
The McMaster.NETCore.Plugins
library (available on GitHub) offers an elegant way to handle this with its sharedTypes
parameter. If implementing manually, your PluginLoadContext.Load
method would need logic similar to the following (simplified):
|
|
Alternatively, prevent the shared assembly (e.g., Plugin.Contracts.dll
) from being copied to the plugin’s output directory. This forces the ALC’s _resolver
to fail for that assembly, and if the Load
override then returns null
, the runtime will try the default context. Use <Private>false</Private>
or <Publish>false</Publish>
metadata on the ProjectReference
in the plugin’s .csproj
file.
|
|
Unloading Plugins and Native Libraries
For scenarios requiring plugins to be updated or removed without restarting the host, ALCs can be made “collectible.” This is detailed in the .NET guide on assembly unloadability.
|
|
Caveats with Native Libraries and Unloading:
- Default Behavior: Native libraries loaded via P/Invoke (even through
LoadUnmanagedDllFromPath
) are generally not automatically unloaded when the ALC is unloaded. This is by design, as many native libraries are not safe to unload and reload repeatedly, potentially causing crashes or resource leaks. - Explicit Unloading: If a native library is designed to be unloadable, you must manage its lifetime explicitly using
NativeLibrary.Load()
to get a handle andNativeLibrary.Free(handle)
to unload it. This typically requires the plugin itself to manage these calls, perhaps viaIDisposable
patterns tied to the plugin’s lifecycle. - File Locks: Even if an ALC is unloaded, native DLL files on disk might remain locked by the process if they were loaded.
Common Pitfalls and Best Practices
- Type Identity Errors: Always ensure shared types are loaded from a common ALC to avoid
InvalidCastException
. - Native Library Paths: Ensure
.deps.json
correctly lists native assets. For complex scenarios,SetDllImportResolver
offers more control. Incorrect paths or missing libraries are common sources ofDllNotFoundException
. - Transitive Native Dependencies: If
PluginNativeA.dll
itself loadsHelperNativeB.dll
,LoadUnmanagedDll
forPluginNativeA
doesn’t automatically manageHelperNativeB
. Its loading depends on OS search paths or howPluginNativeA
is built (e.g., RPATH on Linux). This can be a significant challenge. - Unloading Complexity: Truly making an ALC unloadable requires meticulous management of all references to its types and assemblies. Native library unloading adds another layer of complexity.
- Framework Assemblies: Avoid loading copies of framework assemblies (e.g.,
System.*
,Microsoft.Extensions.*
) into custom ALCs. Defer toAssemblyLoadContext.Default
. - Debugging: Use
AssemblyLoadContext.Resolving
andAssemblyLoadContext.ResolvingUnmanagedDll
events for diagnostics. Environment variables likeCOREHOST_TRACE=1
can provide detailed load logs. - Security:
AssemblyLoadContext
does not provide a security boundary. Code in any ALC runs with the same permissions as the host. For security isolation, process boundaries are necessary.
Conclusion
AssemblyLoadContext
is an indispensable tool for building modular and maintainable .NET applications with plugin architectures. It provides robust isolation for managed dependencies and offers extensible hooks like LoadUnmanagedDll
and integration with AssemblyDependencyResolver
for managing native libraries.
Successfully handling native dependencies, especially shared ones, requires a clear strategy. Whether it’s host-provided shared libraries, ALC-resolved private native assets, or fine-grained control via NativeLibrary.SetDllImportResolver
, understanding the interplay between your ALC, the .NET runtime, and the operating system’s native library loader is key. While unloading contexts with native libraries presents challenges, careful design and explicit lifecycle management can mitigate issues, enabling dynamic and resilient plugin systems that embrace both managed and native code.