Show / Hide Table of Contents

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

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

.NET Core 3.0 и более поздние версии поддерживают выгрузку с помощью 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/master/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"}};
int result = (int) a.EntryPoint.Invoke(null, args);

После возврата метода Main можно инициировать выгрузку путем вызова метода Unload для пользовательского AssemblyLoadContext или удаления имеющейся ссылки на AssemblyLoadContext:

alc.Unload();

Этого достаточно для выгрузки тестовой сборки. Давайте разместим все это в отдельном невстраиваемом методе, чтобы гарантировать, что, TestAssemblyLoadContext, Assembly и MethodInfo (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 для выполнения очистки при инициации выгрузки. Например, может потребоваться отключить потоки или очистить некоторые строгие дескрипторы сборки мусора. В таких случаях можно использовать событие Unloading. Обработчик, выполняющий необходимую очистку, может быть подключен к этому событию.

Устранение проблем с выгрузкой

Из-за сотрудничающей природы выгрузки можно легко забыть о ссылках, которые поддерживают активность элементов в собираемом AssemblyLoadContext и предотвращают выгрузку. Ниже описываются сущности (некоторые из них не очевидны), которые могут содержать ссылки:

  • Обычные ссылки за пределами собираемого AssemblyLoadContext, хранящегося в слоте стека или в регистре процессора (локальные переменные метода, явно созданные пользовательским кодом или неявно JIT-компилятором), статическая переменная или строгий/закрепляющий обработчик сборки мусора, транзитивно указывающие на следующие элементы:
    • Сборка, загруженная в собираемый AssemblyLoadContext.
    • Тип из такой сборки.
    • Экземпляр типа из такой сборки.
  • Потоки, выполняющие код из сборки, загруженной в собираемый AssemblyLoadContext.
  • Экземпляры пользовательских несобираемых типов AssemblyLoadContext, созданных в собираемом AssemblyLoadContext.
  • Ожидающие обработки экземпляры RegisteredWaitHandle с обратными вызовами, настроенными на методы в пользовательском AssemblyLoadContext.
Tip

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

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

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

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

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

В WinDbg (кажется, что WinDbg делает это автоматически при прерывании приложения .NET Core):

.loadby sos coreclr

В LLDB:

plugin load /path/to/libsosplugin.so

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

Tip

При отладке с помощью 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

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

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

!gcroot -all 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;

            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

Следующий код представляет test.dll, переданный в метод ExecuteAndUnload в основной программе тестирования.

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;
        }
    }
}
Back to top Неофициальная документация по .NET на русском языке. Лицензия: CC-BY 4.0. Основано на документации по .NET с Microsoft Docs
Generated by DocFX