如何在 .NET 中使用和调试程序集可卸载性

.NET Core 3.0 引入了加载和随后卸载一组程序集的功能。 在 .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"}};
int result = (int) a.EntryPoint.Invoke(null, args);

Main 方法返回后,可通过在自定义 AssemblyLoadContext 上调用 Unload 方法或删除对 AssemblyLoadContext 的引用来启动卸载:

alc.Unload();

这足以卸载测试程序集。 将所有上述内容放入单独的非可内联方法中,以确保 TestAssemblyLoadContextAssemblyMethodInfo (Assembly.EntryPoint) 无法通过堆栈槽引用(实际或 JIT 引入的本地变量)保持活动状态。 这可以使 TestAssemblyLoadContext 保持活动状态并阻止其卸载。

此外,返回对 AssemblyLoadContext 的弱引用,以便之后可以使用它来检测卸载是否已完成。

[MethodImpl(MethodImplOptions.NoInlining)]
static int 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"}};
    int result = (int) a.EntryPoint.Invoke(null, args);

    alc.Unload();

    return result;
}

现在,可运行此函数以加载、执行和卸载程序集。

WeakReference testAlcWeakRef;
int result = 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 外部、存储在堆栈槽或处理器寄存器(由用户代码显式创建或由实时 (JIT) 编译器隐式创建的方法本地变量)中的常规引用、静态变量或强(固定)GC 句柄以及以可传递的方式指向:
    • 加载到可回收的 AssemblyLoadContext 中的程序集。
    • 此类程序集中的类型。
    • 此类程序集中的类型的实例。
  • 从加载到可回收的 AssemblyLoadContext 程序集中运行代码的线程。
  • 在可回收的 AssemblyLoadContext 中创建的自定义非可回收 AssemblyLoadContext 类型的实例。
  • 将回调设置为自定义 AssemblyLoadContext 中的方法的挂起 RegisteredWaitHandle 实例。

提示

在以下情况下可能会出现存储在堆栈槽或处理器寄存器中、可阻止卸载 AssemblyLoadContext 的对象引用:

  • 当直接将函数调用结果传递给另一个函数时,即使没有用户创建的本地变量。
  • 当 JIT 编译器保留对可在方法中的某个点使用的对象的引用时。

调试卸载问题

调试卸载问题可能比较繁琐。 你可能会遇到这样的情况:你不知道哪些内容可以使 AssemblyLoadContext 保持活动状态,但卸载会失败。 帮助解决此问题的最佳武器是带有 SOS 插件的 WinDbg(Unix 上的 LLDB)。 需要查找哪些内容使属于特定 AssemblyLoadContextLoaderAllocator 保持活动状态。 SOS 插件可让你查看 GC 堆对象、其层次结构和根。

若要将插件加载到调试器中,请在调试器命令行中输入以下命令:

在 WinDbg(似乎 WinDbg 在进入 .NET Core 应用程序时会自动执行此操作)中:

.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 -all 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-20rbp 意为处理器寄存器 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;

            int result = (int)a.EntryPoint.Invoke(null, args);
            alc.Unload();

            return result;
        }

        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
    {
        string message = "Hello";
    }

    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;
        }
    }
}