.NET Coreでアセンブリをアンロードする


アセンブリをアンロードする仕組み

この記事は C# Advent Calendar 2019 の10日目です。

私がお仕事で作っているアプリケーション(.NET Framework製)にユーザープラグイン(DLL)読み込み機能を持つものがあります。

これは利用者がルールに則ったDLLを自分で作ることで、開発ツールに機能を足すことのできる機能です。

この機能は開発ツール側でいったん該当のDLL達をロードし問い合わせを行い、読み込む必要のないDLLはアンロードする処理が入っています。

この開発ツールを.NET Coreに移植する際に.NET Frameworkとは異なる手法を使う必要があったので紹介します。

AppDomainは使えない

.NET Frameworkではアセンブリのアンロードを行うにはAppDomainを使いました。

アプリケーション規定のAppDomainの他にドメインを生成し、その中でアセンブリをロードします。

インターフェイスなどを経由してアセンブリの機能を利用し終わったら、AppDomainのUnloadメソッドをコールしてドメインごとアセンブリをアンロードします。

ただし.NET CoreではAppDomainは1つしかサポートされていないため、この方法は使えません。

その代わり.NET Coreではアセンブリのアンロードを行うために AssemblyLoadContextが用意されているので、今回はこれを使ってみます。

AssemblyLoadContextとは

AssemblyLoadContextとはAppDomainと同様にアセンブリ読み込みを閉じたスコープ内で行えるものです。

この中にロードしたアセンブリは、AssemblyLoadContextのUnloadメソッドをコールすることでまとめてアンロードできますが注意することがあります。

注意点1 isCollectibleをtrueにする

AssemblyLoadContextのコンストラクタ引数にあるisCollectibleはtrueにする必要があります。

これはパフォーマンスの観点からデフォルトではfalseになっているためです。

falseのままUnloadをコールすると例外が発生します。

System.InvalidOperationException
  HResult=0x80131509
  Message=Cannot unload non-collectible AssemblyLoadContext.
  Source=System.Private.CoreLib

注意点2 アンロードは強制的ではなく協調的である

AssemblyLoadContextのUnloadメソッドはコールされた時点では開始されるだけで、完了していません。

アンロードが完了されるのは、以下の条件を満たしたときです。

つまりちゃんと考えて設計をしないと、いつまでたってもアンロードが完了しない事も起こります。

サンプル作成

それでは実際にアセンブリをアンロードする仕組みを実装してみます。

1. ロードされるクラスライブラリを作成

なんでもよいのですが、以下のような簡単なクラスを定義しました。 TargetFrameworkにはnetstandard2.1を指定しています。 これはClassLibrary1.dllという名前で出力されるようにしておきます。

namespace ClassLibrary1
{
    public class Class1
    {
        public void Hello(int arg)
        {
            Console.WriteLine(arg);
        }
    }
}

2. ロードする側にAssemblyLoadContextを実装

まずはアセンブリを読み込むためのAssemblyLoadContext を作成します。 今回作成したのは以下のような簡単なクラスです。

重要なのはコンストラクタにてisCollectibleをtrueにしている部分です。 このフラグをtrueにすることでアンロードがサポートされます。

class TestAssemblyLoadContext : AssemblyLoadContext
{
    public TestAssemblyLoadContext() : base(isCollectible: true)
    {
    }
}

3. DLLのロード&アンロードする仕組みの実装

先程作ったClassLibrary1.dllをロードして関数をコールし、アンロードするための関数を作ります。

[MethodImpl(MethodImplOptions.NoInlining)]
static void ExecuteAndUnload(string assemblyPath, out WeakReference alcWeakRef)
{
    // アセンブリをロードするAssemblyLoadContextを作成
    var alc = new TestAssemblyLoadContext();

    // アセンブリをロード
    Assembly a = alc.LoadFromAssemblyPath(assemblyPath);

    // 外からアンロードを検知するために弱参照を設定
    alcWeakRef = new WeakReference(alc, trackResurrection: true);

    // リフレクションで関数コール
    var type = a.GetType("ClassLibrary1.Class1");
    var instance = Activator.CreateInstance(type);
    var helloMethod = type.GetMethod("Hello");
    helloMethod.Invoke(instance, new object[] { 1 });

    // アンロード実施
    alc.Unload();
}

作成する関数には念のためMethodImplOptions.NoInliningをつけておきます。

これはこのExecuteAndUnload関数がインライン化されないようにするためです。

AssemblyLoadContextを使ったアンロードはAssemblyLoadContext内の型やインスタンスが外部から参照されていると実施できません。

そのためインライン化しているとExecuteAndUnloadの呼び出し元(今回ではMain関数)に参照が残る恐れがあります。

(※ ただし今回の作成サンプルではMethodImplOptions.AggressiveInliningにしてもアンロードは完了できました)

[MethodImpl(MethodImplOptions.NoInlining)]
static void ExecuteAndUnload(string assemblyPath, out WeakReference alcWeakRef)

AssemblyLoadContextのインスタンスを作成し、その中にアセンブリをロードします。

assemblyPathにはdll or exeのファイルパスが入ります。

 // アセンブリをロードするAssemblyLoadContextを作成
var alc = new TestAssemblyLoadContext();

 // アセンブリをロード
Assembly a = alc.LoadFromAssemblyPath(assemblyPath);

AssemblyLoadContextの参照を弱参照として残します。

この参照はExecuteAndUnloadの呼び出し元でアンロードが完了しているかを判断するのに使います。

// 外からアンロードを検知するために弱参照を設定
alcWeakRef = new WeakReference(alc, trackResurrection: true);

今回は実験のためクラス名を指定してインスタンスを作成し、リフレクションで関数を呼びました。

ここはプラグインを作る際は別アセンブリに定義したインターフェイスを実装するクラスをClassLibrary1.dllに作り、インターフェイス経由で呼び出したほうが実用的ですね。

// リフレクションで関数コール
var type = a.GetType("ClassLibrary1.Class1");
var instance = Activator.CreateInstance(type);
var helloMethod = type.GetMethod("Hello");
helloMethod.Invoke(instance, new object[] { 1 });

Unloadメソッドをコールしてアンロードを実施します。

// アンロード実施
alc.Unload();

ExecuteAndUnloadを使ってアセンブリをアンロードしてみる

以下のようにMain関数を実装して実際に、アセンブリを動的ロードしたあとアンロードしてみました。

今回はアンロードが成功しているかの判断は、dllファイルを消せるかどうかで調べてみました。 アンロードが完了していないと、Deleteに失敗します。

static void Main(string[] args)
{
    var myDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);

    // 読み込むアセンブリ(dll)のパス
    var assemblyPath = Path.Combine(myDirectory, @"..\..\..\..\ClassLibrary1\bin\Debug\netstandard2.1\ClassLibrary1.dll");

    // アセンブリを読み込んで関数をコール
    ExecuteAndUnload(assemblyPath, out WeakReference alcWeakRef);

    try
    {
        File.Delete(assemblyPath);
    }
    catch(UnauthorizedAccessException)
    {
        Console.WriteLine("アンロード完了してないので消せない");
    }

    // アンロードされるまで待つ
    int counter = 0;
    for (counter = 0; alcWeakRef.IsAlive && (counter < 10); counter++)
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
    }

    if (counter < 10)
    {
        // この段階ではアンロード済みなので消せる
        File.Delete(assemblyPath);

        Console.WriteLine("アンロード成功");
    }
    else
    {
        Console.WriteLine("アンロード失敗");
    }

    Console.ReadKey();
}

ExecuteAndUnloadをコールして、アセンブリのロード→アセンブリ内関数実行→アンロードを行った後、WeakReferenceを使ってアンロードの完了をチェックしています。

アンロード対象のAssemblyLoadContextの参照を無くすため、GC.Collect()で強制的にガベージ コレクションを実行しています。

今回試した結果では、うまくアンロードできているときは、ループを抜けた時にcounterの値は2になっていました。

またExecuteAndUnloadを抜けた直後ではdllの削除に必ず失敗しますが、アンロード完了を待った後では削除成功していることからもアセンブリのアンロードがうまくいっていることが確認できました。

いじわるをしてみる

上記のサンプルはアンロードが成功するケースでしたが、ではどのような記述をすると失敗するのかを少し試してみました。

読み込んだアセンブリの中のスレッドが終わらないケース

ClassLibrary1.dllのHelloメソッドの中身を以下のように変更してみました。

一度Helloが呼ばれると、Task.Runで終わらないスレッドが生成されます。

public void Hello(int arg)
{
    Task.Run(() =>
    {
        while(true)
        {
            Thread.Sleep(1);
        }
    });
    Console.WriteLine(arg);
}

このアセンブリをロードしてアンロードを試みると失敗しました。

AssemblyLoadContextを保持してみる

アセンブリをロードする側のクラスに以下のようなstaticな変数を作って、

private static AssemblyLoadContext assemblyLoadContext;

ExecuteAndUnloadの中でTestAssemblyLoadContextの参照を保持してみました。

var alc = new TestAssemblyLoadContext();
assemblyLoadContext = alc;

このケースもAssemblyLoadContextへの参照が残っているため、アンロードには失敗しました。

ロードするアセンブリ内のTypeを保持してみる

アセンブリをロードする側のクラスに以下のようなstaticな変数を作って、

private static Type class1Type;

ExecuteAndUnloadの中でClassLibrary1.Class1のTypeを保持してみました。

var type = a.GetType("ClassLibrary1.Class1");
class1Type = type;

このケースもTypeへの参照が残っているため、アンロードには失敗しました。

まとめ

.NET Coreの環境でもAssemblyLoadContextを使ってアセンブリのロード&アンロードをすることができました。

ただしアンロードするアセンブリの後始末を忘れるとアンロードに失敗するので、ロードからアンロードまでの処理はなるべく閉じエリアで行うのがよさそうです。

実際問題アセンブリのアンロードが必要なケースはそこまで多くはないと思いますが、知っておくとどこかで役に立つかも。

Sample Project

今回作成したサンプルは以下のリポジトリにあります。

参考サイト

.NET Core でアセンブリのアンローダビリティを使用およびデバッグする方法

Collectible assemblies in .NET Core 3.0 | StrathWeb. A free flowing web tech monologue.


See also