次の方法で共有


.NET でアセンブリのアンロード機能を使用およびデバッグする方法

.NET (Core) では、一連のアセンブリを読み込んで後でアンロードする機能が導入されました。 .NET Framework では、カスタム アプリ ドメインがこの目的で使用されていましたが、.NET (Core) でサポートされている既定のアプリ ドメインは 1 つだけです。

アンロードは、 AssemblyLoadContextを通じてサポートされます。 一連のアセンブリを収集可能な AssemblyLoadContextに読み込んだり、その中でメソッドを実行したり、リフレクションを使用して検査したりして、最後に AssemblyLoadContextをアンロードすることができます。 これは、 AssemblyLoadContextに読み込まれたアセンブリをアンロードします。

AssemblyLoadContextを使用したアンロードと AppDomain の使用には、注目すべき違いがあります。 AppDomains では、アンロードが強制されます。 アンロード時に、ターゲット AppDomain で実行されているすべてのスレッドが中止され、ターゲット AppDomain で作成されたマネージド COM オブジェクトが破棄されます。 AssemblyLoadContextでは、アンロードは "協調" です。 AssemblyLoadContext.Unload メソッドを呼び出すと、アンロードが開始されます。 アンロードは以下の後に完了します。

  • AssemblyLoadContext に読み込まれたアセンブリのメソッドを呼び出し履歴に持つスレッドはありません。
  • AssemblyLoadContextに読み込まれたアセンブリの型、それらの型のインスタンス、およびアセンブリ自体は、次の方法で参照されません。
    • 弱参照 (WeakReference または WeakReference<T>) を除くAssemblyLoadContextの外部の参照。
    • の内側と外側の両方から、強力なガベージ コレクター (GC) ハンドル (GCHandleType.Normal または AssemblyLoadContext)。

Collectible AssemblyLoadContext を使用する

このセクションには、.NET (Core) アプリケーションを収集可能な AssemblyLoadContextに読み込み、そのエントリ ポイントを実行してアンロードする簡単な方法を示す詳細なステップ バイ ステップ チュートリアルが含まれています。 完全なサンプルは、 https://github.com/dotnet/samples/tree/main/core/tutorials/Unloadingにあります。

収集可能な AssemblyLoadContext を作成する

AssemblyLoadContextからクラスを派生させ、そのAssemblyLoadContext.Load メソッドをオーバーライドします。 そのメソッドは、その AssemblyLoadContextに読み込まれたアセンブリの依存関係であるすべてのアセンブリへの参照を解決します。

次のコードは、最も単純なカスタム AssemblyLoadContextの例です。

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

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

ご覧のように、 Load メソッドは nullを返します。 つまり、すべての依存関係アセンブリが既定のコンテキストに読み込まれ、新しいコンテキストに明示的に読み込まれたアセンブリのみが含まれます。

依存関係の一部またはすべてをAssemblyLoadContextに読み込む場合は、AssemblyDependencyResolver メソッドでLoadを使用できます。 AssemblyDependencyResolverは、アセンブリ名を絶対アセンブリ ファイル パスに解決します。 リゾルバーは、コンテキストに読み込まれたメイン アセンブリのディレクトリにある .deps.json ファイルとアセンブリ ファイルを使用します。

using System.Reflection;
using System.Runtime.Loader;

namespace complex
{
    class TestAssemblyLoadContext : AssemblyLoadContext
    {
        private AssemblyDependencyResolver _resolver;

        public TestAssemblyLoadContext(string mainAssemblyToLoadPath) : base(isCollectible: true)
        {
            _resolver = new AssemblyDependencyResolver(mainAssemblyToLoadPath);
        }

        protected override Assembly? Load(AssemblyName name)
        {
            string? assemblyPath = _resolver.ResolveAssemblyToPath(name);
            if (assemblyPath != null)
            {
                return LoadFromAssemblyPath(assemblyPath);
            }

            return null;
        }
    }
}

カスタム収集可能な AssemblyLoadContext を使用する

このセクションでは、 TestAssemblyLoadContext の単純なバージョンが使用されていることを前提としています。

カスタム AssemblyLoadContext のインスタンスを作成し、次のようにアセンブリを読み込むことができます。

var alc = new TestAssemblyLoadContext();
Assembly a = alc.LoadFromAssemblyPath(assemblyPath);

読み込まれたアセンブリによって参照されるアセンブリごとに、 TestAssemblyLoadContext.Load メソッドが呼び出されるため、 TestAssemblyLoadContext はアセンブリの取得元を決定できます。 この場合、ランタイムが既定でアセンブリの読み込みに使用する場所から既定のコンテキストに読み込む必要があることを示す null が返されます。

アセンブリが読み込まれたので、そこからメソッドを実行できます。 Main メソッドを実行します。

var args = new object[1] {new string[] {"Hello"}};
_ = a.EntryPoint?.Invoke(null, args);

Main メソッドから制御が戻ったら、カスタム UnloadAssemblyLoadContext メソッドを呼び出すか、AssemblyLoadContextに対して必要な参照を削除してアンロードを開始できます。

alc.Unload();

これで、テスト アセンブリをアンロードできます。 次に、このすべてを個別の非インライン化可能なメソッドに配置して、 TestAssemblyLoadContextAssemblyMethodInfo ( Assembly.EntryPoint) をスタック スロット参照 (実際のローカルまたは JIT で導入されたローカル) で維持できないようにします。 これにより、 TestAssemblyLoadContext が維持され、アンロードが防止される可能性があります。

また、後でアンロードの完了を検出するために使用できるように、 AssemblyLoadContext への弱い参照を返します。

[MethodImpl(MethodImplOptions.NoInlining)]
static void ExecuteAndUnload(string assemblyPath, out WeakReference alcWeakRef)
{
    var alc = new TestAssemblyLoadContext();
    Assembly a = alc.LoadFromAssemblyPath(assemblyPath);

    alcWeakRef = new WeakReference(alc, trackResurrection: true);

    var args = new object[1] {new string[] {"Hello"}};
    _ = a.EntryPoint?.Invoke(null, args);

    alc.Unload();
}

これで、この関数を実行して、アセンブリの読み込み、実行、アンロードを行うことができます。

WeakReference testAlcWeakRef;
ExecuteAndUnload("absolute/path/to/your/assembly", out testAlcWeakRef);

ただし、アンロードはすぐには完了しません。 前述のように、ガベージ コレクターを使用して、テスト アセンブリからすべてのオブジェクトを収集します。 多くの場合、アンロードの完了を待つ必要はありません。 ただし、アンロードが完了したことを知ると便利な場合があります。 たとえば、カスタム AssemblyLoadContext に読み込まれたアセンブリ ファイルをディスクから削除できます。 このような場合は、次のコード スニペットを使用できます。 ガベージコレクションをトリガーし、ターゲットオブジェクトが収集されたことを示すカスタムAssemblyLoadContextへの弱参照がnullに設定されるまで、ループ内で保留中のファイナライザーの完了を待機します。 ほとんどの場合、ループを通過するパスは 1 つだけ必要です。 ただし、 AssemblyLoadContext で実行されているコードによって作成されたオブジェクトにファイナライザーがある場合は、より多くのパスが必要になる場合があります。

for (int i = 0; testAlcWeakRef.IsAlive && (i < 10); i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
}

制限事項

収集可能な AssemblyLoadContext に読み込まれたアセンブリは、 収集可能なアセンブリに関する一般的な制限に従う必要があります。 次の制限も適用されます。

  • C++/CLI で記述されたアセンブリはサポートされていません。
  • ReadyToRun で生成されたコードは無視されます。

アンロードイベント

場合によっては、コードをカスタム AssemblyLoadContext に読み込んで、アンロードが開始されたときにクリーンアップを実行することが必要になる場合があります。 たとえば、スレッドを停止したり、強力な GC ハンドルをクリーンアップしたりする必要がある場合があります。 このような場合は、 Unloading イベントを使用できます。 必要なクリーンアップを実行するハンドラーをこのイベントにフックできます。

アンロード可能性の問題のトラブルシューティング

アンロードの協調的な性質のため、収集可能な AssemblyLoadContext に物を保持し、アンロードを妨げている可能性のある参照を忘れやすくなります。 参照を保持できるエンティティには、一部は明確でないものも含まれています。その概要を以下に示します。

  • スタック スロットまたはプロセッサ レジスタに格納されている収集可能な AssemblyLoadContext の外部から保持される通常の参照(メソッドのローカル変数、ユーザー コードによって明示的に作成されるものか、Just-In-Time (JIT) コンパイラによって暗黙的に作成されるもの、静的変数、または強力な(ピン留め)GC ハンドルによって保持され、間接的に指しています。
    • 収集可能な AssemblyLoadContextに読み込まれたアセンブリ。
    • このようなアセンブリからの型。
    • そのようなアセンブリに属する型のインスタンス。
  • 収集可能な AssemblyLoadContextに読み込まれたアセンブリからコードを実行しているスレッド。
  • 収集可能なAssemblyLoadContext内に作成された、非コレクション型のカスタムAssemblyLoadContext型のインスタンス。
  • カスタム RegisteredWaitHandleのメソッドにコールバックが設定された保留中のAssemblyLoadContext インスタンス。
  • カスタム AssemblyLoadContext サブクラスのフィールドは、収集可能な AssemblyLoadContextに読み込まれた型のアセンブリ、型、またはインスタンスを参照します。 アンロードの進行中、ランタイムは AssemblyLoadContext に対する強力な GC ハンドルを保持してアンロードを調整します。 これは、 AssemblyLoadContextへの独自の参照を削除した後でも、GC がこれらのフィールド参照を収集しないことを意味します。 アンロードが完了できるように、これらのフィールドをクリアします。

ヒント

スタック スロットまたはプロセッサ レジスタに格納され、 AssemblyLoadContext のアンロードを妨げる可能性があるオブジェクト参照は、次の状況で発生する可能性があります。

  • ユーザーが作成したローカル変数がなくても、関数呼び出しの結果が別の関数に直接渡される場合。
  • JIT コンパイラがメソッド内のある時点で使用可能だったオブジェクトへの参照を保持する場合。

アンロードに関する問題をデバッグする

アンロードに関する問題のデバッグは面倒な場合があります。 何がAssemblyLoadContextをメモリに保持しているのかわからない状況に陥る可能性があり、アンロードが失敗することがあります。 これを支援する最適なツールは、SOS プラグインを使用した WinDbg (または Unix 上の LLDB) です。 特定のAssemblyLoadContextに属するLoaderAllocatorを生かし続けているものを見つける必要があります。 SOS プラグインを使用すると、GC ヒープ オブジェクト、その階層、ルートを確認できます。

SOS プラグインをデバッガーに読み込むには、デバッガーのコマンド ラインに次のいずれかのコマンドを入力します。

WinDbg で (まだ読み込まれていない場合):

.loadby sos coreclr

LLDB の場合:

plugin load /path/to/libsosplugin.so

次に、アンロードに問題があるサンプル プログラムをデバッグします。 ソース コードは、「 ソース コードの例 」セクションで入手できます。 WinDbg で実行すると、アンロードの成功を確認しようとした直後に、プログラムがデバッガーに中断されます。 その後、犯人の検索を開始できます。

ヒント

Unix で LLDB を使用してデバッグする場合、次の例の SOS コマンドの前に ! はありません。

!dumpheap -type LoaderAllocator

このコマンドは、GC ヒープ内の LoaderAllocator を含む型名を持つすべてのオブジェクトをダンプします。 次に例を示します。

         Address               MT     Size
000002b78000ce40 00007ffadc93a288       48
000002b78000ceb0 00007ffadc93a218       24

Statistics:
              MT    Count    TotalSize Class Name
00007ffadc93a218        1           24 System.Reflection.LoaderAllocatorScout
00007ffadc93a288        1           48 System.Reflection.LoaderAllocator
Total 2 objects

"Statistics:" 部分で、MTに属しているMethodTable (System.Reflection.LoaderAllocator) を確認します。これは、関心のあるオブジェクトです。 次に、最初のリストで、そのエントリに一致する MT を含むエントリを見つけ、オブジェクト自体のアドレスを取得します。 この場合、"000002b78000ce40" になります。

LoaderAllocator オブジェクトのアドレスがわかったら、別のコマンドを使用してその GC ルートを見つけることができます。

!gcroot 0x000002b78000ce40

このコマンドは、 LoaderAllocator インスタンスにつながるオブジェクト参照のチェーンをダンプします。 この一覧はルートから始まります。これは、 LoaderAllocator を維持するエンティティであり、問題の中核となります。 ルートには、スタック スロット、プロセッサ レジスタ、GC ハンドル、または静的変数を指定できます。

gcroot コマンドの出力の例を次に示します。

Thread 4ac:
    000000cf9499dd20 00007ffa7d0236bc example.Program.Main(System.String[]) [E:\unloadability\example\Program.cs @ 70]
        rbp-20: 000000cf9499dd90
            ->  000002b78000d328 System.Reflection.RuntimeMethodInfo
            ->  000002b78000d1f8 System.RuntimeType+RuntimeTypeCache
            ->  000002b78000d1d0 System.RuntimeType
            ->  000002b78000ce40 System.Reflection.LoaderAllocator

HandleTable:
    000002b7f8a81198 (strong handle)
    -> 000002b78000d948 test.Test
    -> 000002b78000ce40 System.Reflection.LoaderAllocator

    000002b7f8a815f8 (pinned handle)
    -> 000002b790001038 System.Object[]
    -> 000002b78000d390 example.TestInfo
    -> 000002b78000d328 System.Reflection.RuntimeMethodInfo
    -> 000002b78000d1f8 System.RuntimeType+RuntimeTypeCache
    -> 000002b78000d1d0 System.RuntimeType
    -> 000002b78000ce40 System.Reflection.LoaderAllocator

Found 3 roots.

次の手順では、ルートが配置されている場所を調べ、修正できるようにします。 最も簡単なケースは、ルートがスタック スロットまたはプロセッサ レジスタである場合です。 その場合、 gcroot には、フレームにルートが含まれている関数の名前と、その関数を実行しているスレッドが表示されます。 難しいケースは、ルートが静的変数または GC ハンドルである場合です。

前の例では、最初のルートはSystem.Reflection.RuntimeMethodInfo型のローカルで、example.Program.Main(System.String[])関数のフレームのアドレスrbp-20に格納されています (rbpはプロセッサ・レジスタrbpで、-20 はそのレジスタからの16進数のオフセットです)。

2 番目のルートは、GCHandle クラスのインスタンスへの参照を保持する通常の (強力な) test.Testです。

3 番目のルートはピン留めされた GCHandleです。 これは実際には静的変数ですが、残念ながら、指示する方法はありません。 参照型の静的変数は、内部ランタイム構造のマネージド オブジェクト配列に格納されます。

AssemblyLoadContextのアンロードを防ぐもう 1 つのケースは、スレッドがスタック上のAssemblyLoadContextに読み込まれたアセンブリからメソッドのフレームを持っている場合です。 これは、すべてのスレッドのマネージド呼び出し履歴をダンプすることで確認できます。

~*e !clrstack

このコマンドは、" !clrstack コマンドをすべてのスレッドに適用する" を意味します。 この例のコマンドの出力を次に示します。 残念ながら、Unix 上の LLDB には、すべてのスレッドにコマンドを適用する方法がないため、手動でスレッドを切り替えて、 clrstack コマンドを繰り返す必要があります。 デバッガーが "マネージド スタックをウォークできません" と表示されているすべてのスレッドを無視します。

OS Thread Id: 0x6ba8 (0)
        Child SP               IP Call Site
0000001fc697d5c8 00007ffb50d9de12 [HelperMethodFrame: 0000001fc697d5c8] System.Diagnostics.Debugger.BreakInternal()
0000001fc697d6d0 00007ffa864765fa System.Diagnostics.Debugger.Break()
0000001fc697d700 00007ffa864736bc example.Program.Main(System.String[]) [E:\unloadability\example\Program.cs @ 70]
0000001fc697d998 00007ffae5fdc1e3 [GCFrame: 0000001fc697d998]
0000001fc697df28 00007ffae5fdc1e3 [GCFrame: 0000001fc697df28]
OS Thread Id: 0x2ae4 (1)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process
Failed to start stack walk: 80070057
OS Thread Id: 0x61a4 (2)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process
Failed to start stack walk: 80070057
OS Thread Id: 0x7fdc (3)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process
Failed to start stack walk: 80070057
OS Thread Id: 0x5390 (4)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process
Failed to start stack walk: 80070057
OS Thread Id: 0x5ec8 (5)
        Child SP               IP Call Site
0000001fc70ff6e0 00007ffb5437f6e4 [DebuggerU2MCatchHandlerFrame: 0000001fc70ff6e0]
OS Thread Id: 0x4624 (6)
        Child SP               IP Call Site
GetFrameContext failed: 1
0000000000000000 0000000000000000
OS Thread Id: 0x60bc (7)
        Child SP               IP Call Site
0000001fc727f158 00007ffb5437fce4 [HelperMethodFrame: 0000001fc727f158] System.Threading.Thread.SleepInternal(Int32)
0000001fc727f260 00007ffb37ea7c2b System.Threading.Thread.Sleep(Int32)
0000001fc727f290 00007ffa865005b3 test.Program.ThreadProc() [E:\unloadability\test\Program.cs @ 17]
0000001fc727f2c0 00007ffb37ea6a5b System.Threading.Thread.ThreadMain_ThreadStart()
0000001fc727f2f0 00007ffadbc4cbe3 System.Threading.ExecutionContext.RunInternal(System.Threading.ExecutionContext, System.Threading.ContextCallback, System.Object)
0000001fc727f568 00007ffae5fdc1e3 [GCFrame: 0000001fc727f568]
0000001fc727f7f0 00007ffae5fdc1e3 [DebuggerU2MCatchHandlerFrame: 0000001fc727f7f0]

ご覧のように、最後のスレッドは test.Program.ThreadProc()。 これは、 AssemblyLoadContextに読み込まれたアセンブリの関数であるため、 AssemblyLoadContext を維持します。

ソース コードの例

アンロードの問題を含む次のコードは、前のデバッグ例で使用されています。

メイン テスト プログラム

using System;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;

namespace example
{
    class TestAssemblyLoadContext : AssemblyLoadContext
    {
        public TestAssemblyLoadContext() : base(true)
        {
        }
        protected override Assembly? Load(AssemblyName name)
        {
            return null;
        }
    }

    class TestInfo
    {
        public TestInfo(MethodInfo? mi)
        {
            _entryPoint = mi;
        }

        MethodInfo? _entryPoint;
    }

    class Program
    {
        static TestInfo? entryPoint;

        [MethodImpl(MethodImplOptions.NoInlining)]
        static int ExecuteAndUnload(string assemblyPath, out WeakReference testAlcWeakRef, out MethodInfo? testEntryPoint)
        {
            var alc = new TestAssemblyLoadContext();
            testAlcWeakRef = new WeakReference(alc);

            Assembly a = alc.LoadFromAssemblyPath(assemblyPath);
            if (a == null)
            {
                testEntryPoint = null;
                Console.WriteLine("Loading the test assembly failed");
                return -1;
            }

            var args = new object[1] {new string[] {"Hello"}};

            // Issue preventing unloading #1 - we keep MethodInfo of a method
            // for an assembly loaded into the TestAssemblyLoadContext in a static variable.
            entryPoint = new TestInfo(a.EntryPoint);
            testEntryPoint = a.EntryPoint;

            var oResult = a.EntryPoint?.Invoke(null, args);
            alc.Unload();
            return (oResult is int result) ? result : -1;
        }

        static void Main(string[] args)
        {
            WeakReference testAlcWeakRef;
            // Issue preventing unloading #2 - we keep MethodInfo of a method for an assembly loaded into the TestAssemblyLoadContext in a local variable
            MethodInfo? testEntryPoint;
            int result = ExecuteAndUnload(@"absolute/path/to/test.dll", out testAlcWeakRef, out testEntryPoint);

            for (int i = 0; testAlcWeakRef.IsAlive && (i < 10); i++)
            {
                GC.Collect();
                GC.WaitForPendingFinalizers();
            }

            System.Diagnostics.Debugger.Break();

            Console.WriteLine($"Test completed, result={result}, entryPoint: {testEntryPoint} unload success: {!testAlcWeakRef.IsAlive}");
        }
    }
}

TestAssemblyLoadContext に読み込まれたプログラム

次のコードは、 メイン テスト プログラム ExecuteAndUnload メソッドに渡されるtest.dllを表します。

using System;
using System.Runtime.InteropServices;
using System.Threading;

namespace test
{
    class Test
    {
    }

    class Program
    {
        public static void ThreadProc()
        {
            // Issue preventing unloading #4 - a thread running method inside of the TestAssemblyLoadContext at the unload time
            Thread.Sleep(Timeout.Infinite);
        }

        static GCHandle handle;
        static int Main(string[] args)
        {
            // Issue preventing unloading #3 - normal GC handle
            handle = GCHandle.Alloc(new Test());
            Thread t = new Thread(new ThreadStart(ThreadProc));
            t.IsBackground = true;
            t.Start();
            Console.WriteLine($"Hello from the test: args[0] = {args[0]}");

            return 1;
        }
    }
}