如何在 .NET 中使用組件卸載功能及為其偵錯

.NET (Core) 導入可載入及稍後卸載一組組件的功能。 在 .NET Framework 中,自訂應用程式網域已用於此用途,但 .NET (Core) 僅支援單一預設應用程式網域。

卸載功能透過 AssemblyLoadContext 支援。 您可以將一組組件載入可回收 AssemblyLoadContext,在其中執行方法,或只使用反映進行檢查,最後卸載 AssemblyLoadContext。 此功能會卸載已載入至 AssemblyLoadContext 的組件。

使用 AssemblyLoadContext 與使用 AppDomain 卸載之間有一個值得注意的差異。 使用 AppDomain 時,會強制卸載。 卸載時,在目標 AppDomain 中執行的所有執行緒都會中止,在目標 AppDomain 中建立的受控 COM 物件會被終結等等。 使用 AssemblyLoadContext 時,卸載為「合作」。 呼叫 AssemblyLoadContext.Unload 方法只會起始卸載。 卸載會在下列情況之後完成:

  • 沒有任何執行緒會將組件中方法載入到其呼叫堆疊上的 AssemblyLoadContext
  • 載入至 AssemblyLoadContext 的任何組件型別、這些型別的執行個體,以及組件本身,都不能為下列項目所參考:

使用可回收 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,則可以在 Load 方法中使用 AssemblyDependencyResolverAssemblyDependencyResolver 會將組件名稱解析為絕對組件檔路徑。 解析程式會使用 .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 方法傳回之後,您可以藉由呼叫 AssemblyLoadContext 上的 Unload 方法或移除 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 (表示已收集目標物件) 為止。 在大多數情況下,只需要透過迴圈傳遞一次。 不過,針對在 AssemblyLoadContext 中執行之程式碼所建立物件具有完成項的更複雜案例,可能需要更多的傳遞。

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

卸載事件

在某些情況下,載入到自訂 AssemblyLoadContext 中的程式碼可能需要在起始卸載時執行一些清除。 例如,可能需要停止執行緒、清除強式 GC 控制代碼。 Unloading 事件可用於這類情況。 您可以將執行必要清除的處理常式連結到這個事件。

針對卸載功能問題進行疑難排解

由於卸載的合作本質,您很容易忘記參考可能會使可回收 AssemblyLoadContext 的內容保持運作並防止其卸載。 以下摘要說明可以保留參考的實體 (其中一些不明顯):

  • 從可回收 AssemblyLoadContext 外部保留的一般參考,儲存於堆疊位置或處理器暫存器 (方法區域變數,由使用者程式碼明確建立或由 Just-In-Time (JIT) 隱含建立)、靜態變數或強式 (固定) GC 控制代碼,並以可傳遞方式指向:
    • 載入到可回收 AssemblyLoadContext 中的組件。
    • 來自這類組件的類型。
    • 來自這類組件類型的執行個體。
  • 從載入到可回收 AssemblyLoadContext 之組件執行程式碼的執行緒。
  • 自訂、非可回收 AssemblyLoadContext 型別的執行個體,建立於可回收 AssemblyLoadContext 內部。
  • 擱置的 RegisteredWaitHandle 執行個體,搭配將回呼設定為自訂 AssemblyLoadContext 中的方法。

提示

以下情況可能會發生儲存在堆疊位置或處理器暫存器中的物件參考,以及可能會防止卸載 AssemblyLoadContext 的物件參考:

  • 直接將函式呼叫結果傳遞至另一個函式時,即使沒有任何使用者建立的區域變數也一樣。
  • 當 JIT 編譯器保留方法中某個時間點可用的物件參考時。

對卸載問題進行偵錯

對卸載問題進行偵錯可能很繁瑣。 您可能會遇到以下狀況:您不知道哪些項目可以使 AssemblyLoadContext 保持運作,但是卸載會失敗。 協助此問題之最佳工具是含有 SOS 外掛程式的 WinDbg (或 UNIX 上的 LLDB)。 您必須找出讓 LoaderAllocator (其屬於特定 AssemblyLoadContext) 保持運作的項目。 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:" 部分中,檢查屬於 System.Reflection.LoaderAllocatorMT (MethodTable),這是您關注的物件。 然後在開頭的清單中,尋找 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 是該暫存器中的十六進位位移)。

第二個根是標準 (強式) GCHandle,它會保存 test.Test 類別執行個體的參考。

第三個根是固定的 GCHandle。 這個實際上是靜態變數,但可惜的是,無法分辨。 參考型別靜態變數會儲存在內部執行階段結構的受控物件陣列中。

另一個可以防止卸載 AssemblyLoadContext 的情況,就是當執行緒具有組件的方法框架,而該組件已載入到其堆疊的 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;
        }
    }
}