Использование и отладка сборок с возможностью выгрузки в .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;
}
}
}