Partilhar via


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 AssemblyLoadContexto , 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 AssemblyLoadContextinstâncias , desses tipos, e os próprios assemblies são referenciados por:

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 AssemblyLoadContextmais 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, Assemblye 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:

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.
  • 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ável AssemblyLoadContext.
  • 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;
        }
    }
}