Использование и отладка сборок с возможностью выгрузки в .NET

.NET (Core) представила возможность загрузки и последующей выгрузки набора сборок. В платформа .NET Framework домены пользовательских приложений использовались для этой цели, но .NET (Core) поддерживает только один домен приложения по умолчанию.

Выгрузка поддерживается через AssemblyLoadContext. Вы можете загрузить набор сборок в собираемые AssemblyLoadContext, выполнять в них методы или просто проверять их с помощью отражения и, наконец, выгрузить AssemblyLoadContext. Эта операция выгружает сборки, загруженные в AssemblyLoadContext.

Между выгрузкой с помощью AssemblyLoadContext и доменов приложений есть одно важное различие. При использовании доменов приложений выгрузка выполняется принудительно. В момент выгрузки все потоки, работающие в целевом домене приложения, прерываются, управляемые COM-объекты, созданные в целевом домене приложения, уничтожаются и т. д. При использовании AssemblyLoadContext выгрузка выполняется в режиме "сотрудничества". Вызов метода AssemblyLoadContext.Unload просто инициирует выгрузку. Выгрузка завершается после того, как:

  • Ни один из потоков не имеет методов из сборок, загруженных в AssemblyLoadContext в стеках вызовов.
  • Ни один из типов из сборок, загруженных в AssemblyLoadContextэкземпляры этих типов, и сами сборки ссылаются на:
    • Ссылки за пределами , AssemblyLoadContextза исключением слабых ссылок (WeakReference или WeakReference<T>).
    • Строгие дескрипторы сборщика мусора (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 тоже, можно использовать AssemblyDependencyResolver в методе Load. 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();

Этого достаточно для выгрузки тестовой сборки. Затем вы поместите все это в отдельный неустранимый метод, чтобы гарантировать, что TestAssemblyLoadContextAssemblyMethodInfoAssembly.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 за пределами коллекций, которые хранятся в слоте стека или регистр процессора (локальные методы, явно созданные пользовательским кодом или неявно компилятором JIT), статической переменной или строгой (закреплением) дескриптором GC и транзитивно указывая на:
    • Сборка, загруженная в собираемый AssemblyLoadContext.
    • Тип из такой сборки.
    • Экземпляр типа из такой сборки.
  • Потоки, выполняющие код из сборки, загруженной в собираемый AssemblyLoadContext.
  • Экземпляры пользовательских, необорчивых AssemblyLoadContext типов, созданных внутри коллекционируемых AssemblyLoadContextтипов.
  • Ожидающие обработки экземпляры RegisteredWaitHandle с обратными вызовами, настроенными на методы в пользовательском AssemblyLoadContext.

Совет

Ссылки на объекты, которые хранятся в слотах стека или регистрах процессора и которые могут предотвратить выгрузку AssemblyLoadContext, могут возникать в следующих ситуациях:

  • Когда результаты вызова функции передаются непосредственно в другую функцию, несмотря на отсутствие созданной пользователем локальной переменной.
  • Когда JIT-компилятор сохраняет ссылку на объект, который был доступен в определенный момент в методе.

Отладка проблем с выгрузкой

Отладка проблем с выгрузкой может быть утомительной. Иногда вы не знаете, что поддерживает активность AssemblyLoadContext, но выгрузка завершается ошибкой. Лучший инструмент, помогающий с этим — WinDbg (или LLDB в Unix) с подключаемым модулем SOS. Вам нужно найти то, что хранится LoaderAllocator в том, что принадлежит конкретному AssemblyLoadContext живому. Подключаемый модуль SOS позволяет взглянуть на объекты кучи GC, их иерархии и корни.

Чтобы загрузить подключаемый модуль SOS в отладчик, введите одну из следующих команд в командной строке отладчика.

В WinDbg (если он еще не загружен):

.loadby sos coreclr

В LLDB:

plugin load /path/to/libsosplugin.so

Теперь вы отладите пример программы, которая имеет проблемы с выгрузкой. Исходный код доступен в разделе "Пример исходного кода ". При запуске с помощью WinDbg программа переключается в отладчике сразу после попытки проверить успешность выгрузки. После этого вы можете начать поиск причины проблемы.

Совет

При отладке с помощью LLDB в Unix команды SOS в следующих примерах не содержат перед собой !.

!dumpheap -type LoaderAllocator

Эта команда выполняет дамп всех объектов с именем типа, содержащим 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

В части "Статистика:" проверка MTMethodTable(), принадлежащую System.Reflection.LoaderAllocatorобъекту, о котором вы заботитесь. Затем в списке в начале найдите запись с MT этим совпадением и получите адрес самого объекта. В этом случае это "000002b78000ce40".

Теперь, когда вы знаете адрес LoaderAllocator объекта, можно использовать другую команду, чтобы найти его корни GC:

!gcroot 0x000002b78000ce40

Эта команда выполняет дампы цепочки ссылок на объекты, ведущих к экземпляру LoaderAllocator. Список начинается с корневого элемента, который является сущностью, которая сохраняет LoaderAllocator жизнь и, следовательно, является ядром проблемы. Корнем может быть слот стека, регистр процессора, обработчик сборки мусора или статическая переменная.

Ниже приведен пример выходных данных 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 отображается имя функции, фрейм которой содержит корень, и поток, в котором выполняется эта функция. Сложный случай: корень является статической переменной или обработчиком сборки мусора.

В предыдущем примере первый корень является локальной переменной типа System.Reflection.RuntimeMethodInfo, хранимого во фрейме функции example.Program.Main(System.String[]) по адресу rbp-20 (rbp — это регистр процессора rbp, а –20 — шестнадцатеричное смещение от этого регистра).

Второй корень является нормальным (строгим) GCHandle, который содержит ссылку на экземпляр класса test.Test.

Третий корень является закрепленным GCHandle. Это на самом деле статическая переменная, но, к сожалению, нет способа сказать. Статические переменные для ссылочных типов хранятся в управляемом массиве объектов во внутренних структурах среды выполнения.

Другой вариант, который может помешать выгрузке AssemblyLoadContext, — когда поток содержит фрейм метода из сборки, загруженной в стек AssemblyLoadContext. Это можно проверить, выполнив дамп управляемых стеков вызовов всех потоков:

~*e !clrstack

Команда означает "Применить ко всем потокам команду !clrstack". Ниже приведен результат выполнения команды для примера. К сожалению, в LLDB в Unix нет способа применить команду ко всем потокам, поэтому необходимо будет вручную переключать потоки и повторять команду 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;
        }
    }
}