如何在 .NET 中使用組件卸載功能及為其偵錯
.NET (Core) 導入可載入及稍後卸載一組組件的功能。 在 .NET Framework 中,自訂應用程式網域已用於此用途,但 .NET (Core) 僅支援單一預設應用程式網域。
卸載功能透過 AssemblyLoadContext 支援。 您可以將一組組件載入可回收 AssemblyLoadContext
,在其中執行方法,或只使用反映進行檢查,最後卸載 AssemblyLoadContext
。 此功能會卸載已載入至 AssemblyLoadContext
的組件。
使用 AssemblyLoadContext
與使用 AppDomain 卸載之間有一個值得注意的差異。 使用 AppDomain 時,會強制卸載。 卸載時,在目標 AppDomain 中執行的所有執行緒都會中止,在目標 AppDomain 中建立的受控 COM 物件會被終結等等。 使用 AssemblyLoadContext
時,卸載為「合作」。 呼叫 AssemblyLoadContext.Unload 方法只會起始卸載。 卸載會在下列情況之後完成:
- 沒有任何執行緒會將組件中方法載入到其呼叫堆疊上的
AssemblyLoadContext
。 - 載入至
AssemblyLoadContext
的任何組件型別、這些型別的執行個體,以及組件本身,都不能為下列項目所參考:AssemblyLoadContext
外部的參考,但弱式參考 (WeakReference 或 WeakReference<T>) 除外。AssemblyLoadContext
內部和外部的強式記憶體回收行程 (GC) 控制代碼 (GCHandleType.Normal 或 GCHandleType.Pinned)。
使用可回收 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
方法中使用 AssemblyDependencyResolver
。 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
方法傳回之後,您可以藉由呼叫 AssemblyLoadContext
上的 Unload
方法或移除 AssemblyLoadContext
的參考來起始卸載:
alc.Unload();
這便足以卸載測試組件。 接下來,您會將這些全都放入個別的非內嵌方法,以確保 TestAssemblyLoadContext
、Assembly
和 MethodInfo
(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
的組件必須遵守可收集組件的一般限制。 此外,也適用 下列限制:
- 不支援以 C++/CLI 撰寫的組件。
- ReadyToRun 產生的程式碼將會被忽略。
卸載事件
在某些情況下,載入到自訂 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.LoaderAllocator
的 MT
(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;
}
}
}