Come usare e sottoporre a debug la scaricabilità di assembly in .NET

In .NET (Core) è stata introdotta la possibilità di caricare e in seguito scaricare un set di assembly. In .NET Framework per questo scopo vengono usati domini di app personalizzati, ma .NET (Core) supporta solo un singolo dominio di app predefinito.

La scaricabilità è supportata tramite AssemblyLoadContext. È possibile caricare un set di assembly in un oggetto AssemblyLoadContext ritirabile, eseguirvi metodi o semplicemente ispezionarli tramite reflection e infine scaricare l'AssemblyLoadContext. In questo modo, gli assembly caricati in AssemblyLoadContext vengono scaricati.

Esiste una differenza rilevante tra lo scaricamento tramite AssemblyLoadContext e con l'uso di AppDomain. Con gli AppDomain, lo scaricamento è forzato. In fase di scaricamento, tutti i thread in esecuzione nell'AppDomain di destinazione vengono interrotti, gli oggetti COM gestiti creati nell'AppDomain di destinazione vengono eliminati definitivamente e così via. Con AssemblyLoadContext, lo scaricamento è di tipo "cooperativo". La chiamata al metodo AssemblyLoadContext.Unload avvia semplicemente lo scaricamento. Lo scaricamento termina dopo che si sono verificate le condizioni seguenti:

  • Nessuno dei thread include metodi relativi agli assembly caricati nell'AssemblyLoadContext nel rispettivo stack di chiamate.
  • A nessuno dei tipi relativi agli assembly caricati in AssemblyLoadContext, a nessuna delle istanze di questi tipi e a nessuno degli assembly stessi viene fatto riferimento da:

Uso di un AssemblyLoadContext ritirabile

Questa sezione contiene un'esercitazione dettagliata che illustra come caricare in modo semplice un'applicazione .NET (Core) in un oggetto AssemblyLoadContext ritirabile, eseguirne il punto di ingresso e quindi scaricarla. L'esempio completo è disponibile all'indirizzo https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.

Creare un AssemblyLoadContext ritirabile

Derivare la classe da AssemblyLoadContext ed eseguire l'override del relativo metodo AssemblyLoadContext.Load. Questo metodo risolve i riferimenti a tutti gli assembly che sono dipendenze degli assembly caricati nell'AssemblyLoadContext.

Il codice seguente offre un esempio della versione più semplice dell'AssemblyLoadContext personalizzato:

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

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

Come si può notare, il metodo Load restituisce null. Ciò significa che tutti gli assembly di dipendenza vengono caricati nel contesto predefinito e il nuovo contesto contiene solo gli assembly che vi sono stati caricati in modo esplicito.

Se si vuole che nell'AssemblyLoadContext vengano caricate anche alcune o tutte le dipendenze, è possibile usare l'oggetto AssemblyDependencyResolver nel metodo Load. AssemblyDependencyResolver risolve i nomi degli assembly in percorsi assoluti di file di assembly. Il resolver usa il file deps.json e i file di assembly nella directory dell'assembly principale caricato nel contesto.

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;
        }
    }
}

Usare un AssemblyLoadContext ritirabile personalizzato

In questa sezione si presuppone che venga usata la versione più semplice dell'TestAssemblyLoadContext.

È possibile creare un'istanza dell'AssemblyLoadContext personalizzato e caricarvi un assembly, come indicato di seguito:

var alc = new TestAssemblyLoadContext();
Assembly a = alc.LoadFromAssemblyPath(assemblyPath);

Per ogni assembly a cui fa riferimento l'assembly caricato, viene chiamato il metodo TestAssemblyLoadContext.Load in modo che TestAssemblyLoadContext possa determinare il percorso da cui ottenere l'assembly. In questo caso, viene restituito null per indicare che deve essere caricato nel contesto predefinito dai percorsi usati dal runtime per caricare gli assembly per impostazione predefinita.

Ora che è stato caricato un assembly, è possibile eseguire un metodo da tale contesto. Eseguire il metodo Main:

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

Dopo che il metodo Main ha restituito un risultato, è possibile avviare lo scaricamento chiamando il metodo Unload sull'oggetto AssemblyLoadContext personalizzato o rimuovendo il riferimento a AssemblyLoadContext:

alc.Unload();

Questa operazione è sufficiente per scaricare l'assembly di test. Successivamente, includere tutto questo in un metodo separato non abilitato per l'inlining per assicurarsi che TestAssemblyLoadContext, Assembly e MethodInfo (ovvero Assembly.EntryPoint) non possano essere mantenuti attivi da riferimenti di slot dello stack (variabili locali reali o introdotte da JIT). Tali riferimenti potrebbero mantenere attivi TestAssemblyLoadContext e impedire lo scaricamento.

Restituire inoltre un riferimento debole all'AssemblyLoadContext in modo da poterlo usare in un secondo momento per determinare che lo scaricamento è stato completato.

[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();
}

A questo punto è possibile eseguire questa funzione per caricare, eseguire e scaricare l'assembly.

WeakReference testAlcWeakRef;
ExecuteAndUnload("absolute/path/to/your/assembly", out testAlcWeakRef);

Lo scaricamento non viene tuttavia completato immediatamente. Come indicato in precedenza, si basa sul processo di Garbage Collection per raccogliere tutti gli oggetti dall'assembly di test. In molti casi non è necessario attendere il completamento dello scaricamento. Vi sono tuttavia casi in cui è utile sapere che l'operazione è stata completata, Ad esempio, è possibile scegliere di eliminare il file di assembly caricato nell'oggetto AssemblyLoadContext personalizzato dal disco. In casi come questo è possibile usare il frammento di codice seguente. Attiva un processo di Garbage Collection e rimane in attesa dei finalizzatori in sospeso in un ciclo finché il riferimento debole all'oggetto AssemblyLoadContext personalizzato non è impostato su null, a indicare che l'oggetto di destinazione è stato raccolto. Nella maggior parte dei casi, è richiesto un unico passaggio attraverso il ciclo. Tuttavia, per i casi più complessi, in cui gli oggetti creati dal codice in esecuzione in AssemblyLoadContext hanno finalizzatori, può essere necessario un numero maggiore di passaggi.

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

Limiti

Gli assembly caricati in un AssemblyLoadContext ritirabile devono rispettare le restrizioni generali sugli assembly ritirabilI. Si applicano anche le limitazioni seguenti:

  • Gli assembly scritti in C++/CLI non sono supportati.
  • Il codice ReadyToRun generato verrà ignorato.

L'evento di scaricamento

Talvolta può essere necessario che il codice caricato in un oggetto AssemblyLoadContext personalizzato esegua alcune operazioni di pulizia in fase di avvio dello scaricamento. Può ad esempio essere necessario arrestare i thread, pulire alcuni handle avanzati di GC. In questi casi può essere usato l'evento Unloading. A questo evento può essere associato un gestore che esegue la pulizia necessaria.

Risolvere i problemi di scaricamento

Considerata la natura cooperativa dello scaricamento, può capitare di dimenticare i riferimenti che mantengono attivi gli elementi presenti in un oggetto AssemblyLoadContext ritirabile e impediscono lo scaricamento. Di seguito è riportato un riepilogo delle entità (alcune delle quali non ovvie) che possono mantenere attivi i riferimenti:

  • Riferimenti regolari all'esterno dell'oggetto AssemblyLoadContext ritirabile, archiviati in uno slot dello stack o in un registro del processore (variabili locali del metodo, create in modo esplicito dal codice utente o in modo implicito dal compilatore JIT), in una variabile statica o in un handle avanzato di GC (blocco), che puntano in modo transitivo a:
    • Un assembly caricato nell'AssemblyLoadContext ritirabile.
    • Un tipo di tale assembly.
    • Un'istanza di un tipo incluso in tale assembly.
  • Thread che eseguono codice da un assembly caricato nell'AssemblyLoadContext ritirabile.
  • Istanze di tipi AssemblyLoadContext personalizzati non ritirabili creati all'interno dell'oggetto AssemblyLoadContext ritirabile.
  • Istanze di RegisteredWaitHandle in sospeso con callback impostati sui metodi nell'oggetto AssemblyLoadContext personalizzato.

Suggerimento

I riferimenti a oggetti archiviati in slot dello stack o in registri del processore e che potrebbero impedire lo scaricamento di un oggetto AssemblyLoadContext possono verificarsi nelle situazioni seguenti:

  • Quando i risultati della chiamata di funzione vengono passati direttamente a un'altra funzione, anche in assenza di variabili locali create dall'utente.
  • Quando il compilatore JIT mantiene un riferimento a un oggetto disponibile in un determinato punto in un metodo.

Eseguire il debug dei problemi di scaricamento

Il debug dei problemi relativi allo scaricamento può essere tedioso. Possono verificarsi situazioni in cui non si riesce a capire ciò che mantiene attivo un AssemblyLoadContext, ma lo scaricamento ha esito negativo. Lo strumento migliore per risolvere problemi di questo tipo è WinDbg (o LLDB in Unix) con il plug-in SOS. È necessario individuare cosa mantiene attiva un'istanza di LoaderAllocator appartenente allo specifico oggetto AssemblyLoadContext. Il plug-in SOS consente di esaminare gli oggetti heap di GC e le relative gerarchie e radici.

Per caricare il plug-in SOS nel debugger, immettere uno dei comandi seguenti nella riga di comando del debugger.

In WinDbg (se non è già caricato):

.loadby sos coreclr

In LLDB:

plugin load /path/to/libsosplugin.so

Ora si eseguirà il debug di un programma di esempio che presenta problemi di scaricamento. Il codice sorgente è disponibile nella sezione Codice sorgente di esempio. Quando viene eseguito in WinDbg, il programma si interrompe nel debugger subito dopo il tentativo di verifica dello scaricamento. A questo punto è possibile iniziare a cercare i colpevoli.

Suggerimento

Se si esegue il debug con LLDB in Unix, i comandi SOS negli esempi seguenti non devono essere preceduti da !.

!dumpheap -type LoaderAllocator

Questo comando esegue il dump di tutti gli oggetti con un nome di tipo contenente LoaderAllocator che si trovano nell'heap GC. Ecco un esempio:

         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

Nella sezione "Statistics" controllare l'oggetto MT (MethodTable) appartenente a System.Reflection.LoaderAllocator, che è l'oggetto a cui si è interessati. Quindi, nell'elenco all'inizio, trovare la voce con il valore di MT corrispondente e ottenere l'indirizzo dell'oggetto stesso. In questo caso, l'indirizzo è "000002b78000ce40".

Ora che si conosce l'indirizzo dell'oggetto LoaderAllocator, è possibile usare un altro comando per trovare le relative radici di GC:

!gcroot 0x000002b78000ce40

Questo comando esegue il dump della catena di riferimenti agli oggetti che portano all'istanza di LoaderAllocator. L'elenco inizia con la radice, ovvero l'entità che mantiene attiva l'istanza di LoaderAllocator e pertanto è il nucleo del problema. La radice può essere uno slot dello stack, un registro del processore, un handle GC o una variabile statica.

Di seguito è riportato un esempio dell'output del 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.

Il passaggio successivo consiste nell'identificare la posizione della radice per poterla correggere. Il caso più semplice è quello in cui la radice è uno slot dello stack o un registro del processore. In tal caso, l'oggetto gcroot mostra il nome della funzione il cui frame contiene la radice e il thread che esegue tale funzione. Il caso più complicato è quello in cui la radice è una variabile statica o un handle GC.

Nell'esempio precedente, la prima radice è una variabile locale di tipo System.Reflection.RuntimeMethodInfo memorizzata nel frame della funzione example.Program.Main(System.String[]) all'indirizzo rbp-20 (rbp è il registro del processore rbp e -20 è un offset esadecimale rispetto a tale registro).

La seconda radice è un oggetto GCHandle normale (sicuro) che contiene un riferimento a un'istanza della classe test.Test.

La terza radice è un oggetto GCHandle bloccato. Si tratta in realtà di una variabile statica, ma purtroppo non è possibile verificarlo. Le entità statiche per i tipi di riferimento vengono memorizzate in una matrice di oggetti gestiti in strutture di runtime interne.

Un altro caso che può impedire lo scaricamento di un AssemblyLoadContext è quello in cui un thread presenta un frame di un metodo di un assembly caricato nell'AssemblyLoadContext nel relativo stack. È possibile verificarlo eseguendo il dump degli stack di chiamate gestite di tutti i thread:

~*e !clrstack

Questo comando indica di applicare il comando !clrstack a tutti i thread. Di seguito è riportato l'output del comando per l'esempio. Purtroppo, LLDB in Unix non prevede un modo per applicare un comando a tutti i thread ed è quindi necessario passare da un thread all'altro e ripetere il comando clrstack manualmente. Ignorare tutti i thread in cui il debugger riporta il messaggio "Unable to walk the managed stack" per segnalare che non è possibile esaminare lo stack gestito.

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]

Come si può notare, nell'ultimo thread è presente test.Program.ThreadProc(). Si tratta di una funzione dell'assembly caricato nell'AssemblyLoadContext che mantiene quindi attivo l'AssemblyLoadContext.

Codice sorgente di esempio

Il codice seguente che contiene i problemi di scaricabilità viene usato nell'esempio di debug precedente.

Programma di test principale

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}");
        }
    }
}

Programma caricato in TestAssemblyLoadContext

Il codice seguente rappresenta il file test.dll passato al metodo ExecuteAndUnload nel programma di test principale.

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;
        }
    }
}