.NET(Core)引入了加载和稍后卸载一组程序集的功能。 在 .NET Framework 中,自定义应用域用于此目的,但 .NET (Core) 仅支持单个默认应用域。
卸载能力支持通过 AssemblyLoadContext。 可以将一组程序集加载到可收集的 AssemblyLoadContext 中,在其中执行方法或仅使用反射检查,最后卸载 AssemblyLoadContext。 这会将加载到的程序集卸载到该程序集中 AssemblyLoadContext。
使用 AssemblyLoadContext 进行卸载和使用 AppDomains 进行卸载之间有一个值得注意的区别。 使用 AppDomains 时,将强制卸载。 卸载时,目标 AppDomain 中运行的所有线程都会中止,目标 AppDomain 中创建的托管 COM 对象将被销毁,依此类而行。 使用 AssemblyLoadContext时,卸载是“协作的”。
AssemblyLoadContext.Unload调用该方法只是启动卸载。 卸载完成后:
- 没有线程具有从程序集加载到
AssemblyLoadContext其调用堆栈中的方法。 - 从加载到
AssemblyLoadContext的程序集中的类型,这些类型的实例以及程序集本身均未被引用:- 除
AssemblyLoadContext引用之外的引用,弱引用(WeakReference或WeakReference<T>)除外。 - 强垃圾回收器(GC)处理(GCHandleType.Normal 或 GCHandleType.Pinned)从内部和外部
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 方法中使用 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返回后,可以通过调用自定义Unload的AssemblyLoadContext方法或删除对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 事件。 可以将一个执行必要清理的处理程序挂载到此事件。
排查可卸载性问题
由于卸载的协作性质,很容易忘记引用关系,这些引用关系可能会使这些东西保持可回收状态并阻止卸载。 以下是可以保存引用的一些实体的摘要(其中一些是不明显的):
- 存储在堆栈槽或处理器寄存器中的常规引用(包括方法局部变量、用户代码显式创建或实时(JIT)编译器隐式创建的静态变量
AssemblyLoadContext、强(固定)GC 句柄),并可传递指向:- 加载到可
AssemblyLoadContext收集对象的程序集。 - 此类程序集的类型。
- 这样的程序集中的类型实例。
- 加载到可
- 加载到
AssemblyLoadContext收集功能的程序集的代码由线程执行。 - 在可收集
AssemblyLoadContext对象内部创建的自定义非可回收AssemblyLoadContext类型的实例。 - 自定义
AssemblyLoadContext中设置了回调方法的RegisteredWaitHandle挂起实例。 - 自定义
AssemblyLoadContext子类中的字段,这些字段引用加载到可收集AssemblyLoadContext的程序集、类型或类型的实例。 正在卸载时,运行时会保留一个强大的 GC 句柄来AssemblyLoadContext协调卸载。 这意味着,即使删除了对AssemblyLoadContext的引用,GC也不会收集那些字段引用。 清除这些字段,以便卸载可以完成。
小窍门
对象引用可以存储在栈槽或处理器寄存器中,当这种情况可能阻止卸载 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
在“统计信息:”部分中,检查属于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。 这实际上是一个静态变量,但不幸的是,无法判断。 引用类型的静态存储在内部运行时结构中的托管对象数组中。
另一种可能阻止< c0 /> 卸载的情况是,当线程的堆栈中保留了来自已加载到< c1 /> 的程序集的方法帧时。 可以通过导出所有线程的托管的调用堆栈以确认这一点:
~*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;
}
}
}