.NET(核心版)新增了載入並後續卸載一組程序集的功能。 在 .NET Framework 中,為此目的使用自訂應用程式域,但 .NET(核心版)僅支援單一預設應用程式域。
可卸載性透過AssemblyLoadContext支援。 你可以將一組可回收的組件載入 AssemblyLoadContext,在其中執行方法,或只是使用反射進行檢查,最後卸載 AssemblyLoadContext。 這會卸載載入到 AssemblyLoadContext 的元件。
使用 AssemblyLoadContext 卸載和使用 AppDomains 卸載之間有一個值得注意的差異。 使用 AppDomains 時,卸載是強制的。 卸載時,目標 AppDomain 中執行的所有執行緒會中止,目標 AppDomain 中建立的受管理 COM 物件會被銷毀,依此類推。 使用AssemblyLoadContext,卸載是「合作式」的。 呼叫此 AssemblyLoadContext.Unload 方法僅僅開始進行卸載。 卸貨作業結束後:
- 沒有任何執行緒會將組件中的方法載入
AssemblyLoadContext其呼叫堆疊中。 -
AssemblyLoadContext中載入的組件沒有任何類型、這些類型的實例,以及組件本身被以下方式引用:- 除了弱參考(WeakReference或WeakReference<T>)以外的
AssemblyLoadContext參考。 - 強大的垃圾回收器(GC)手柄(GCHandleType.Normal 或 GCHandleType.Pinned)可以處理
AssemblyLoadContext內外的。
- 除了弱參考(WeakReference或WeakReference<T>)以外的
使用可收集的 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常規引用,這些引用儲存在堆疊槽或處理器暫存器(方法的區域變數,可能是由使用者程式碼明確建立,或由及時(JIT)編譯器隱式創建)、靜態變數或強(固定)GC控制代碼,且可傳遞性地指向:- 一個裝入收藏品
AssemblyLoadContext的組件。 - 來自此類組件的一種類型。
- 來自此類組件的類型實例。
- 一個裝入收藏品
- 執行緒執行從載入至可收集的程序集
AssemblyLoadContext的程式碼。 - 在可收集的
AssemblyLoadContext中創建的自訂、不可收集的AssemblyLoadContext型別實例。 - 待處理的RegisteredWaitHandle實例,其中的回調設置為自訂
AssemblyLoadContext的方法。 - 你自訂的
AssemblyLoadContext子類別中,引用已載入到可收集AssemblyLoadContext的組件、類型或類型實例的欄位。 卸載進行時,執行期會保留一個強型 GC 控制代碼,以協調AssemblyLoadContext的卸載。 這表示即使你把自己的AssemblyLoadContext引用丟棄,垃圾收集器也不會回收那些欄位引用。 清空這些欄位,讓卸貨完成。
小提示
儲存在堆疊槽或處理器暫存器中的物件參考,可能會阻止AssemblyLoadContext的卸載,並可能發生在以下情況下:
- 當函式呼叫結果直接傳給另一個函式時,即使沒有使用者建立的本地變數。
- 當 JIT 編譯器保留對某個方法中某個時點可用物件的參考時。
偵錯卸載問題
進行除錯和卸載問題的處理可能會很繁瑣。 你可能會遇到不知道是什麼原因使 AssemblyLoadContext 無法被卸載的情況,但卸載操作失敗。 幫助這點最好的工具是 WinDbg(Unix 上的 LLDB)搭配 SOS 外掛。 你需要找出是什麼讓屬於特定 AssemblyLoadContext 的 LoaderAllocator 持續運行。 SOS 插件讓你可以看到 GC 堆積物件、它們的階層結構和根節點。
要將 SOS 外掛載入除錯器,請在除錯器指令列輸入以下其中一個指令。
在 WinDbg(如果還沒載入的話):
.loadby sos coreclr
在LLDB中:
plugin load /path/to/libsosplugin.so
接著你要除錯一個範例程式,該程式在卸載時有問題。 原始碼可在 範例原始碼 區段取得。 當你使用 WinDbg 執行它時,程式在嘗試檢查卸載是否成功後,會立即進入除錯器。 接著你就可以開始尋找罪魁禍首。
小提示
如果你在 Unix 上用 LLDB 除錯,以下範例中的 SOS 指令前面沒有!
!dumpheap -type LoaderAllocator
此指令會傾出所有類型名稱包含 LoaderAllocator 的 GC 堆積中的物件。 以下為範例:
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
在「統計:」部分,檢查屬於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 控制代碼時。
在前述範例中,第一個根是位於rbp-20位址、儲存在函數System.Reflection.RuntimeMethodInfo框架中的example.Program.Main(System.String[])類型的局部變數(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 中
以下程式碼代表test.dll傳入主要測試程式中的ExecuteAndUnload方法。
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;
}
}
}