前回は、.NET のアプリケーションドメイン(AppDomain)について見ました。
.NET Framework では、アセンブリの分離やアンロードを考えるときに AppDomain が重要でした。
しかし、現在の .NET(.NET Core / .NET 5 以降)では、アセンブリの読み込み、分離、アンロードには Application Load Context、つまり AssemblyLoadContext を使うのが基本です。
今回は「Application Load Context によるアセンブリ分離」をテーマに、AssemblyLoadContext が何を解決するのかを整理します。
アセンブリ分離とは何か
.NET アプリケーションは、実行時に複数のアセンブリを読み込みます。
アセンブリとは、.dll や .exe として配置される .NET の実行単位です。
通常のアプリケーションでは、必要なアセンブリは既定の読み込み領域に読み込まれます。
しかし、プラグイン機構や拡張機能を作る場合、次のような問題が出てきます。
- プラグインごとに異なるバージョンの依存アセンブリを使いたい
- アプリ本体とプラグインの依存関係を分離したい
- 不要になったプラグインをアンロードしたい
- 実行時に外部 DLL を読み込んで処理したい
このような場面で使うのが AssemblyLoadContext です。
AssemblyLoadContext は、アセンブリを読み込むための「文脈」または「読み込み領域」です。
読み込み領域を分けることで、同じ名前でも異なるバージョンのアセンブリを扱ったり、プラグインごとに依存関係を分けたりできます。
AssemblyLoadContext の役割
AssemblyLoadContext は System.Runtime.Loader 名前空間にあるクラスです。
using System.Runtime.Loader;
主な役割は次の 3 つです。
- アセンブリを読み込む
- アセンブリの依存関係を解決する
- 必要に応じて読み込んだアセンブリをアンロードする
アプリケーションを起動すると、まず既定の AssemblyLoadContext が作られます。
通常の参照アセンブリは、この既定コンテキストに読み込まれます。
using System;
using System.Runtime.Loader;
AssemblyLoadContext context = AssemblyLoadContext.Default;
Console.WriteLine(context.Name);
Console.WriteLine(context.IsCollectible);
AssemblyLoadContext.Default は、アプリケーションの標準的な読み込み先です。
IsCollectible は、そのコンテキストをアンロードできるかどうかを示します。既定コンテキストはアンロードできません。
既定の Application Load Context
既定の AssemblyLoadContext は、アプリケーション本体が通常使う読み込み領域です。
次のコードでは、現在読み込まれているアセンブリがどの AssemblyLoadContext に属しているかを確認しています。
using System;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (Assembly assembly in assemblies.OrderBy(a => a.GetName().Name))
{
AssemblyLoadContext? context = AssemblyLoadContext.GetLoadContext(assembly);
Console.WriteLine($"Assembly: {assembly.GetName().Name}");
Console.WriteLine($"Context: {context?.Name}");
Console.WriteLine($"Collectible: {context?.IsCollectible}");
Console.WriteLine();
}
多くのアセンブリは、既定のコンテキストに読み込まれているはずです。
カスタム AssemblyLoadContext を作る
プラグインのように、本体とは別の場所からアセンブリを読み込みたい場合は、独自の AssemblyLoadContext を作ります。
using System.Reflection;
using System.Runtime.Loader;
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver resolver;
public PluginLoadContext(string pluginPath)
: base(isCollectible: true)
{
resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
string? assemblyPath = resolver.ResolveAssemblyToPath(assemblyName);
if (assemblyPath == null)
{
return null;
}
return LoadFromAssemblyPath(assemblyPath);
}
}
この例では、AssemblyDependencyResolver を使って、プラグイン側の依存アセンブリを解決しています。
Load() が null を返した場合、ランタイムは別の解決方法を試します。
つまり、独自コンテキストで解決できるものは読み込み、解決できないものは通常の仕組みに委ねる、という流れになります。
プラグインアセンブリを読み込む
次のコードは、カスタム AssemblyLoadContext を使ってプラグイン DLL を読み込む例です。
using System;
using System.Reflection;
string pluginPath = @"C:\Plugins\SamplePlugin\SamplePlugin.dll";
var loadContext = new PluginLoadContext(pluginPath);
Assembly pluginAssembly = loadContext.LoadFromAssemblyPath(pluginPath);
Console.WriteLine($"読み込み完了: {pluginAssembly.FullName}");
Type? pluginType = pluginAssembly.GetTypes()
.FirstOrDefault(type => type.Name == "SamplePlugin");
if (pluginType == null)
{
Console.WriteLine("対象の型が見つかりませんでした。");
return;
}
object? instance = Activator.CreateInstance(pluginType);
MethodInfo? method = pluginType.GetMethod("Run");
method?.Invoke(instance, null);
この例では、SamplePlugin 型を探して、Run メソッドを呼び出しています。
実務では、リフレクションで毎回メソッド名を探すよりも、プラグイン用の共通インターフェイスを別アセンブリに定義し、本体とプラグインの両方から参照する設計が扱いやすいです。
collectible なコンテキストとアンロード
AssemblyLoadContext の大きな特徴は、isCollectible: true で作成するとアンロード可能になることです。
var loadContext = new PluginLoadContext(pluginPath);
Assembly assembly = loadContext.LoadFromAssemblyPath(pluginPath);
// プラグイン処理を実行する
loadContext.Unload();
ただし、Unload() を呼んだからといって、即座にメモリから消えるとは限りません。
アンロードは GC と連動します。読み込んだアセンブリ、型、インスタンス、デリゲートなどへの参照が残っていると、コンテキストは解放されません。
アンロード確認をしたい場合は、WeakReference を使って参照が解放されたかを確認します。
using System;
using System.Reflection;
static WeakReference LoadAndUnload(string pluginPath)
{
var loadContext = new PluginLoadContext(pluginPath);
Assembly assembly = loadContext.LoadFromAssemblyPath(pluginPath);
Console.WriteLine($"Loaded: {assembly.FullName}");
var weakReference = new WeakReference(loadContext, trackResurrection: false);
loadContext.Unload();
return weakReference;
}
WeakReference reference = LoadAndUnload(@"C:\Plugins\SamplePlugin\SamplePlugin.dll");
for (int i = 0; reference.IsAlive && i < 10; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Console.WriteLine(reference.IsAlive
? "まだアンロードされていません。"
: "アンロードされました。");
アンロードされない場合は、どこかに参照が残っています。
特に、静的フィールド、イベント購読、キャッシュ、長寿命のデリゲートには注意が必要です。
AppDomain との違い
AppDomain と AssemblyLoadContext は、どちらも「分離」に関係します。
ただし、分離する対象と時代背景が異なります。
AppDomain: .NET Framework 時代の実行境界。コード実行の分離、セキュリティ境界、アセンブリのまとめてアンロードに使われたAssemblyLoadContext: 現在の .NET におけるアセンブリ読み込みの分離単位。プラグイン、依存関係の分離、アンロードに使う
現在の .NET で「プラグインごとに DLL を分けたい」「読み込んだ DLL を後で外したい」と考えるなら、まず AssemblyLoadContext を検討します。
使いどころ
AssemblyLoadContext が役立つのは、次のような場面です。
- プラグインアーキテクチャを作る
- 外部 DLL を実行時に読み込む
- プラグインごとに依存アセンブリのバージョンを分ける
- 使い終わったプラグインをアンロードする
- テストやツールでアセンブリ読み込み状態を分離する
一方で、通常のアプリケーションで参照アセンブリを読み込むだけなら、明示的に AssemblyLoadContext を作る必要はありません。
標準の参照解決で十分な場合は、既定コンテキストに任せる方がシンプルです。
まとめ
AssemblyLoadContext は、現代の .NET におけるアセンブリ分離の中心です。
通常のアプリケーションは既定のコンテキストで動きますが、プラグインや動的ロードでは、独自のコンテキストを作ることで依存関係を分離できます。
特に isCollectible: true のコンテキストは、読み込んだアセンブリを後でアンロードしたい場合に重要です。
ただし、アンロードには参照の解放が必要なので、設計段階から「読み込む」「使う」「参照を切る」「アンロードする」という流れを意識しておく必要があります。