Como usar e depurar a capacidade de descarga de assembly no .NET
O .NET (Core) introduziu a capacidade de carregar e descarregar posteriormente um conjunto de assemblies. No .NET Framework, domínios de aplicativo personalizados foram usados para essa finalidade, mas o .NET (Core) suporta apenas um único domínio de aplicativo padrão.
A capacidade de descarga é suportada através do AssemblyLoadContext. Você pode carregar um conjunto de montagens em um colecionável AssemblyLoadContext
, executar métodos neles ou apenas inspecioná-los usando reflexão e, finalmente, descarregar o AssemblyLoadContext
. Isso descarrega os assemblies carregados no AssemblyLoadContext
.
Há uma diferença notável entre o descarregamento usando AssemblyLoadContext
e usando AppDomains. Com AppDomains, o descarregamento é forçado. No momento da descarga, todos os threads em execução no AppDomain de destino são abortados, os objetos COM gerenciados criados no AppDomain de destino são destruídos e assim por diante. Com AssemblyLoadContext
o , a descarga é "cooperativa". Chamar o AssemblyLoadContext.Unload método apenas inicia o descarregamento. A descarga termina após:
- Nenhum thread tem métodos dos assemblies carregados em
AssemblyLoadContext
suas pilhas de chamadas. - Nenhum dos tipos dos assemblies carregados nas
AssemblyLoadContext
instâncias , desses tipos, e os próprios assemblies são referenciados por:- Referências fora do
AssemblyLoadContext
, exceto referências fracas (WeakReference ou WeakReference<T>). - O coletor de lixo (GC) forte manipula (GCHandleType.Normal ou GCHandleType.Pinned) de dentro e de fora do
AssemblyLoadContext
.
- Referências fora do
Usar AssemblyLoadContext colecionável
Esta seção contém um tutorial passo a passo detalhado que mostra uma maneira simples de carregar um aplicativo .NET (Core) em um colecionável AssemblyLoadContext
, executar seu ponto de entrada e, em seguida, descarregá-lo. Você pode encontrar uma amostra completa em https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.
Criar um AssemblyLoadContext colecionável
Derive sua classe do e substitua AssemblyLoadContext seu AssemblyLoadContext.Load método. Esse método resolve referências a todos os assemblies que são dependências de assemblies carregados nesse AssemblyLoadContext
.
O código a seguir é um exemplo do costume AssemblyLoadContext
mais simples:
class TestAssemblyLoadContext : AssemblyLoadContext
{
public TestAssemblyLoadContext() : base(isCollectible: true)
{
}
protected override Assembly? Load(AssemblyName name)
{
return null;
}
}
Como você pode ver, o Load
método retorna null
. Isso significa que todos os assemblies de dependência são carregados no contexto padrão e o novo contexto contém apenas os assemblies explicitamente carregados nele.
Se você quiser carregar algumas ou todas as dependências no AssemblyLoadContext
também, você pode usar o AssemblyDependencyResolver
no Load
método in. O AssemblyDependencyResolver
resolve os nomes de assembly para caminhos de arquivo de assembly absolutos. O resolvedor usa o arquivo .deps.json e os arquivos de assembly no diretório do assembly principal carregado no contexto.
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;
}
}
}
Usar um AssemblyLoadContext colecionável personalizado
Esta seção assume que a versão mais simples do TestAssemblyLoadContext
está sendo usada.
Você pode criar uma instância do personalizado AssemblyLoadContext
e carregar um assembly nele da seguinte maneira:
var alc = new TestAssemblyLoadContext();
Assembly a = alc.LoadFromAssemblyPath(assemblyPath);
Para cada um dos assemblies referenciados pelo assembly carregado, o método é chamado para TestAssemblyLoadContext.Load
que o TestAssemblyLoadContext
possa decidir de onde obter o assembly. Nesse caso, ele retorna null
para indicar que ele deve ser carregado no contexto padrão de locais que o tempo de execução usa para carregar assemblies por padrão.
Agora que um assembly foi carregado, você pode executar um método a partir dele. Execute o Main
método:
var args = new object[1] {new string[] {"Hello"}};
_ = a.EntryPoint?.Invoke(null, args);
Depois que o Main
método retorna, você pode iniciar o descarregamento chamando o Unload
método no personalizado AssemblyLoadContext
ou removendo a referência que você tem para o AssemblyLoadContext
:
alc.Unload();
Isso é suficiente para descarregar o conjunto de teste. Em seguida, você colocará tudo isso em um método não inlineável separado para garantir que o TestAssemblyLoadContext
, Assembly
e MethodInfo
(o Assembly.EntryPoint
) não possam ser mantidos vivos por referências de slot de pilha (locais reais ou introduzidos por JIT). Isso poderia manter os TestAssemblyLoadContext
vivos e evitar a descarga.
Além disso, retorne uma referência fraca ao para que você possa usá-lo mais tarde para detetar a AssemblyLoadContext
conclusão da descarga.
[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();
}
Agora você pode executar essa função para carregar, executar e descarregar o assembly.
WeakReference testAlcWeakRef;
ExecuteAndUnload("absolute/path/to/your/assembly", out testAlcWeakRef);
No entanto, a descarga não é concluída imediatamente. Como mencionado anteriormente, ele depende do coletor de lixo para coletar todos os objetos da montagem de teste. Em muitos casos, não é necessário esperar pela conclusão da descarga. No entanto, há casos em que é útil saber que a descarga terminou. Por exemplo, talvez você queira excluir o arquivo assembly que foi carregado no disco personalizado AssemblyLoadContext
do disco. Nesse caso, o trecho de código a seguir pode ser usado. Ele aciona a coleta de lixo e aguarda finalizadores pendentes em um loop até que a referência fraca ao costume AssemblyLoadContext
seja definida como null
, indicando que o objeto de destino foi coletado. Na maioria dos casos, apenas uma passagem pelo loop é necessária. No entanto, para casos mais complexos em que os objetos criados pelo código em execução no AssemblyLoadContext
têm finalizadores, mais passes podem ser necessários.
for (int i = 0; testAlcWeakRef.IsAlive && (i < 10); i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Limitações
As montagens carregadas em um colecionável AssemblyLoadContext
devem respeitar as restrições gerais sobre montagens colecionáveis. Aplicam-se adicionalmente as seguintes limitações:
- Não há suporte para assemblies escritos em C++/CLI.
- O código gerado pelo ReadyToRun será ignorado.
O evento Unloading
Em alguns casos, pode ser necessário que o código carregado em um costume AssemblyLoadContext
execute alguma limpeza quando a descarga for iniciada. Por exemplo, pode ser necessário parar threads ou limpar alças GC fortes. O Unloading
evento pode ser usado nesses casos. Você pode conectar um manipulador que executa a limpeza necessária para esse evento.
Solucionar problemas de descarga
Devido à natureza cooperativa da descarga, é fácil esquecer referências que podem estar mantendo o material em um colecionável AssemblyLoadContext
vivo e impedindo a descarga. Aqui está um resumo das entidades (algumas delas não óbvias) que podem conter as referências:
- Referências regulares mantidas de fora do colecionável
AssemblyLoadContext
que são armazenadas em um slot de pilha ou um registro de processador (locais de método, criados explicitamente pelo código do usuário ou implicitamente pelo compilador just-in-time (JIT)), uma variável estática ou um identificador GC forte (fixação) e apontando transitivamente para:- Um conjunto carregado no colecionável
AssemblyLoadContext
. - Um tipo de tal montagem.
- Uma instância de um tipo de tal assembly.
- Um conjunto carregado no colecionável
- Threads executando código de um assembly carregado no colecionável
AssemblyLoadContext
. - Instâncias de tipos personalizados e não colecionáveis
AssemblyLoadContext
criados dentro do colecionávelAssemblyLoadContext
. - Instâncias pendentes RegisteredWaitHandle com retornos de chamada definidos como métodos no .
AssemblyLoadContext
Gorjeta
As referências de objeto armazenadas em slots de pilha ou registradores de processador e que podem impedir o descarregamento de um AssemblyLoadContext
podem ocorrer nas seguintes situações:
- Quando os resultados da chamada de função são passados diretamente para outra função, mesmo que não haja nenhuma variável local criada pelo usuário.
- Quando o compilador JIT mantém uma referência a um objeto que estava disponível em algum ponto de um método.
Problemas de descarregamento de depuração
Problemas de depuração com a descarga podem ser tediosos. Você pode entrar em situações em que você não sabe o que pode estar segurando um AssemblyLoadContext
vivo, mas a descarga falha. A melhor ferramenta para ajudar com isso é WinDbg (ou LLDB no Unix) com o plugin SOS. Você precisa encontrar o que está mantendo um LoaderAllocator
que pertence ao específico AssemblyLoadContext
vivo. O plugin SOS permite que você veja objetos de pilha GC, suas hierarquias e raízes.
Para carregar o plug-in SOS no depurador, digite um dos seguintes comandos na linha de comando do depurador.
No WinDbg (se ainda não estiver carregado):
.loadby sos coreclr
Em LLDB:
plugin load /path/to/libsosplugin.so
Agora você vai depurar um programa de exemplo que tem problemas com o descarregamento. O código-fonte está disponível na seção Exemplo de código-fonte . Quando você executá-lo em WinDbg, o programa quebra no depurador logo após tentar verificar o sucesso de descarregamento. Você pode então começar a procurar os culpados.
Gorjeta
Se você depurar usando LLDB no Unix, os comandos SOS nos exemplos a seguir não têm o !
na frente deles.
!dumpheap -type LoaderAllocator
Este comando despeja todos os objetos com um nome de tipo contendo LoaderAllocator
que estão no heap GC. Eis um exemplo:
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
Na parte "Estatísticas:", verifique o MT
(MethodTable
) que pertence ao System.Reflection.LoaderAllocator
, que é o objeto que lhe interessa. Em seguida, na lista no início, encontre a entrada com MT
que corresponde a essa e obtenha o endereço do próprio objeto. Neste caso, é "000002b78000ce40".
Agora que você sabe o endereço do LoaderAllocator
objeto, você pode usar outro comando para encontrar suas raízes GC:
!gcroot 0x000002b78000ce40
Este comando despeja a cadeia de referências de objeto que levam à LoaderAllocator
instância. A lista começa com a raiz, que é a entidade que mantém o LoaderAllocator
vivo e, portanto, é o núcleo do problema. A raiz pode ser um slot de pilha, um registro de processador, um identificador GC ou uma variável estática.
Aqui está um exemplo da saída do gcroot
comando:
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.
O próximo passo é descobrir onde a raiz está localizada para que você possa corrigi-la. O caso mais fácil é quando a raiz é um slot de pilha ou um registro de processador. Nesse caso, o gcroot
mostra o nome da função cujo quadro contém a raiz e o thread que executa essa função. O caso difícil é quando a raiz é uma variável estática ou uma alça GC.
No exemplo anterior, a primeira raiz é um local do tipo System.Reflection.RuntimeMethodInfo
armazenado no quadro da função example.Program.Main(System.String[])
no endereço rbp-20
(rbp
é o registro rbp
do processador e -20 é um deslocamento hexadecimal desse registro).
A segunda raiz é uma raiz normal (forte) GCHandle
que contém uma referência a uma instância da test.Test
classe.
A terceira raiz é um pinned GCHandle
. Esta é, na verdade, uma variável estática, mas, infelizmente, não há como dizer. A estática para tipos de referência é armazenada em uma matriz de objetos gerenciados em estruturas internas de tempo de execução.
Outro caso que pode impedir a AssemblyLoadContext
descarga de um AssemblyLoadContext
é quando um thread tem um quadro de um método de um assembly carregado na sua pilha. Você pode verificar isso despejando pilhas de chamadas gerenciadas de todos os threads:
~*e !clrstack
O comando significa "aplicar a todos os threads o !clrstack
comando". A seguir está a saída desse comando para o exemplo. Infelizmente, LLDB no Unix não tem nenhuma maneira de aplicar um comando a todos os threads, então você deve alternar manualmente threads e repetir o clrstack
comando. Ignore todos os threads em que o depurador diz "Não é possível andar na pilha gerenciada".
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]
Como você pode ver, o último tópico tem test.Program.ThreadProc()
. Esta é uma função do conjunto carregado no AssemblyLoadContext
, e assim mantém o AssemblyLoadContext
vivo.
Exemplo de código-fonte
O código a seguir que contém problemas de descarregabilidade é usado no exemplo de depuração anterior.
Programa de testes principal
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}");
}
}
}
Programa carregado no TestAssemblyLoadContext
O código a seguir representa o test.dll passado para o ExecuteAndUnload
método no programa de teste principal.
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;
}
}
}