Como usar e depurar a capacidade de descarregamento de assembly no .NET

O .NET (Core) introduziu a capacidade de carregar e, posteriormente, descarregar um conjunto de assemblies. No .NET Framework, os domínios de aplicativo personalizados eram usados para essa finalidade, mas o .NET (Core) só dá suporte a um domínio de aplicativo padrão individual.

Há suporte para descarregamento por meio de AssemblyLoadContext. Você pode carregar um conjunto de assemblies em um AssemblyLoadContext de coleção, executar métodos neles ou apenas inspecioná-los usando a reflexão e, por fim, descarregar o AssemblyLoadContext. Isso descarrega os assemblies carregados no AssemblyLoadContext.

Há uma diferença notável entre o descarregamento com AssemblyLoadContext e com o AppDomain. Com o AppDomain, o descarregamento é forçado. No momento do descarregamento, todos os threads em execução no AppDomain de destino são anulados, os objetos COM gerenciados criados no AppDomain de destino são destruídos, e assim por diante. Com AssemblyLoadContext, o descarregamento é "cooperativo". A chamada do método AssemblyLoadContext.Unload apenas inicia o descarregamento. O descarregamento é concluído depois que:

  • Nenhum thread tem métodos dos assemblies carregados em AssemblyLoadContext nas pilhas de chamadas.
  • Nenhum dos tipos dos assemblies carregados nas instâncias AssemblyLoadContext desses tipos, nem os próprios assemblies são referenciados por:

Usar o AssemblyLoadContext de coleção

Esta seção contém um tutorial passo a passo detalhado que mostra uma forma simples de carregar um aplicativo .NET (Core) em um AssemblyLoadContext de coleção, executar o ponto de entrada dele e, em seguida, descarregá-lo. Encontre uma amostra completa em https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.

Criar um AssemblyLoadContext de coleção

Derive sua classe do AssemblyLoadContext e substitua o método AssemblyLoadContext.Load. Esse método resolve as referências para todos os assemblies que são dependências de assemblies carregados nesse AssemblyLoadContext.

O seguinte código é um exemplo do AssemblyLoadContext personalizado mais simples:

class TestAssemblyLoadContext : AssemblyLoadContext
{
    public TestAssemblyLoadContext() : base(isCollectible: true)
    {
    }

    protected override Assembly? Load(AssemblyName name)
    {
        return null;
    }
}

Como você pode ver, o método Load 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 carregados explicitamente nele.

Caso deseje carregar algumas ou todas as dependências no AssemblyLoadContext também, use o AssemblyDependencyResolver no método Load. O AssemblyDependencyResolver resolve os nomes do 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 de coleção personalizado

Esta seção pressupõe que a versão mais simples do TestAssemblyLoadContext esteja sendo usada.

Crie uma instância do AssemblyLoadContext personalizado e carregue 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 TestAssemblyLoadContext.Load é chamado, de modo que o TestAssemblyLoadContext possa decidir o local em que obterá o assembly. Nesse caso, ele retorna null para indicar que deve ser carregado no contexto padrão das localizações usadas pelo runtime para carregar assemblies por padrão.

Agora que um assembly foi carregado, você pode executar um método nele. Execute o método Main:

var args = new object[1] {new string[] {"Hello"}};
_ = a.EntryPoint?.Invoke(null, args);

Depois que o método Main for retornado, inicie o descarregamento chamando o método Unload no AssemblyLoadContext personalizado ou removendo a referência existente ao AssemblyLoadContext:

alc.Unload();

Isso é suficiente para descarregar o assembly de teste. Em seguida, você colocará tudo isso em um método separado que não pode ser embutido para garantir que TestAssemblyLoadContext, Assembly e MethodInfo (o Assembly.EntryPoint) não possam ser mantidos ativos por referências de slot de pilha (locais introduzidos reais ou em JIT). Isso pode manter o TestAssemblyLoadContext ativo e impedir o descarregamento.

Além disso, retorne uma referência fraca ao AssemblyLoadContext, de modo que você possa usá-la posteriormente para detectar a conclusão do descarregamento.

[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, o descarregamento não é concluído imediatamente. Como mencionado anteriormente, ele depende do coletor de lixo para coletar todos os objetos do assembly de teste. Em muitos casos, não é necessário aguardar a conclusão do descarregamento. No entanto, há casos em que é útil saber que o descarregamento foi concluído. Por exemplo, o ideal é excluir o arquivo do assembly que foi carregado no AssemblyLoadContext personalizado do disco. Nesse caso, o snippet de código a seguir pode ser usado. Ele dispara uma coleta de lixo e aguarda os finalizadores pendentes em um loop até a referência fraca ao AssemblyLoadContext personalizado ser 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 passagens podem ser necessárias.

for (int i = 0; testAlcWeakRef.IsAlive && (i < 10); i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
}

O evento de descarregamento

Em alguns casos, poderá ser necessário que o código carregado em um AssemblyLoadContext personalizado execute uma limpeza quando o descarregamento for iniciado. Por exemplo, talvez ele precise interromper threads ou limpar identificadores de GC fortes. O evento Unloading pode ser usado nesses casos. Você pode vincular um manipulador que executa a limpeza necessária a esse evento.

Solução de problemas de capacidade de descarregamento

Devido à natureza cooperativa do descarregamento, é fácil esquecer referências que possam manter os itens em um AssemblyLoadContext de coleção ativo e impedir o descarregamento. Este é um resumo das entidades (algumas não óbvias) que podem conter as referências:

  • Referências regulares mantidas fora do AssemblyLoadContext de coleção, que são armazenadas em um slot de pilha ou em um registro de processador (locais de método, criados explicitamente pelo código do usuário ou implicitamente pelo compilador JIT (just-in-time)), uma variável estática ou um identificador GC forte (de fixação) e apontando transitivamente para:
    • Um assembly carregado no AssemblyLoadContext de coleção.
    • Um tipo desse assembly.
    • Uma instância de um tipo desse assembly.
  • Threads executando um código de um assembly carregado no AssemblyLoadContext de coleção.
  • Instâncias de tipos AssemblyLoadContext personalizados que não são de coleção criados dentro do AssemblyLoadContext de coleção.
  • Instâncias RegisteredWaitHandle pendentes com retornos de chamada definidos como métodos no AssemblyLoadContext personalizado.

Dica

Referências de objeto armazenadas em slots de pilha ou registros 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, embora 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 momento em um método.

Depurar problemas de descarregamento

Problemas de depuração com o descarregamento podem ser entediantes. Você pode enfrentar situações em que não sabe o que pode estar mantendo AssemblyLoadContext um ativo, mas o descarregamento falha. A melhor ferramenta para ajudar a fazer isso é o WinDbg (ou LLDB no UNIX) com o plug-in SOS. Você precisa encontrar o que está mantendo ativo um LoaderAllocator pertencente ao AssemblyLoadContext específico. Esse plug-in SOS permite que você examine os objetos de heap do GC, as respectivas hierarquias e as raízes.

Para carregar o plug-in SOS no depurador, insira um dos comandos a seguir na linha de comando do depurador.

No WinDbg (caso ele ainda não esteja carregado):

.loadby sos coreclr

No 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ê o executa no WinDbg, o programa interrompe o depurador logo após a tentativa de verificar o êxito do descarregamento. Em seguida, você poderá começar a procurar os culpados.

Dica

Se você fizer a depuração usando o LLDB no UNIX, os comandos do SOS nos exemplos a seguir não serão precedidos pelo !.

!dumpheap -type LoaderAllocator

Esse comando despeja todos os objetos com um nome de tipo que contém LoaderAllocator que estão no heap do GC. Veja 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) pertencente ao System.Reflection.LoaderAllocator, que é o objeto de interesse. Em seguida, na lista do início, encontre a entrada com MT correspondente a aquela e obtenha o endereço do próprio objeto. Nesse caso, ela é “000002b78000ce40”.

Agora que você sabe o endereço do objeto LoaderAllocator, use outro comando para encontrar as raízes do GC:

!gcroot 0x000002b78000ce40

Esse comando despeja a cadeia de referências de objeto que leva à instância LoaderAllocator. A lista começa com a raiz, que é a entidade que mantém o LoaderAllocator ativo e, portanto, é o cerne do problema. A raiz pode ser um slot de pilha, um registro de processador, um identificador GC ou uma variável estática.

Veja um exemplo da saída do comando 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.

A próxima etapa é descobrir o local em que a raiz se encontra para 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 um identificador 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 de processador rbp e -20 é um deslocamento hexadecimal desse registro).

A segunda raiz é um GCHandle normal (forte) que contém uma referência a uma instância da classe test.Test.

A terceira raiz é um GCHandle fixo. Esta é, na verdade, uma variável estática, mas, infelizmente, não há como saber. Os estáticos para tipos de referência são armazenados em uma matriz de objeto gerenciado em estruturas de runtime internas.

Outro caso que pode impedir o descarregamento de um AssemblyLoadContext é quando um thread tem um quadro de um método de um assembly carregado no AssemblyLoadContext em 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 comando !clrstack". Veja a seguir a saída desse comando para o exemplo. A má notícia é que o LLDB no UNIX não tem nenhuma maneira de aplicar um comando a todos os threads; portanto, você precisa alternar manualmente os threads e repetir o comando clrstack. Ignore todos os threads em que o depurador indica "Não é possível percorrer a 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 thread tem test.Program.ThreadProc(). Essa é uma função do assembly carregado no AssemblyLoadContext e, portanto, mantém o AssemblyLoadContext ativo.

Exemplo de código-fonte

O código a seguir que contém problemas de capacidade de descarregamento é usado no exemplo de depuração anterior.

Programa de teste 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 a test.dll passada para o método ExecuteAndUnload 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;
        }
    }
}