Condividi tramite


Come utilizzare e effettuare il debug della scaricabilità dell'assembly in .NET

.NET (Core) ha introdotto la possibilità di caricare e successivamente scaricare un set di assembly. In .NET Framework i domini app personalizzati sono stati usati a questo scopo, ma .NET (Core) supporta solo un singolo dominio app predefinito.

La scaricabilità è supportata tramite AssemblyLoadContext. È possibile caricare un set di assembly in un oggetto collezionabile AssemblyLoadContext, eseguire i metodi o esaminarli semplicemente usando la reflection e infine scaricare AssemblyLoadContext. Che scarica gli assembly caricati nell'oggetto AssemblyLoadContext.

C'è una differenza notevole tra lo scaricamento con AssemblyLoadContext e l'uso di AppDomains. Con AppDomains, lo scaricamento viene 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 è "cooperativo". La chiamata al AssemblyLoadContext.Unload metodo avvia solo lo scaricamento. Lo scaricamento termina dopo:

  • Nessun thread presenta metodi dagli assembly caricati nelle loro stack di chiamate AssemblyLoadContext.
  • Nessun tipo degli assembly caricati nel AssemblyLoadContext, istanze di tali tipi e gli assembly stessi sono referenziate da:

Usare AssemblyLoadContext collezionabile

Questa sezione contiene un'esercitazione dettagliata che illustra un modo semplice per caricare un'applicazione .NET (Core) in un oggetto collectible AssemblyLoadContext, eseguirne il punto di ingresso e quindi scaricarlo. È possibile trovare un esempio completo all'indirizzo https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.

Creare un AssemblyLoadContext raccoglibile.

Deriva la tua classe da AssemblyLoadContext ed esegui l'override del metodo AssemblyLoadContext.Load. Questo metodo risolve i riferimenti a tutti gli assembly che sono dipendenze degli assembly caricati in tale AssemblyLoadContext.

Il codice seguente è un esempio di AssemblyLoadContext personalizzato più semplice.

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

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

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

Se si desidera caricare anche alcune o tutte le dipendenze nel AssemblyLoadContext, è possibile usare AssemblyDependencyResolver nel metodo Load. AssemblyDependencyResolver risolve i nomi degli assembly in percorsi dei file di assembly assoluti. Il sistema di risoluzione usa il file .deps.json e i file di assembly nella directory dell'assembly principale caricato nel contesto di esecuzione.

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 personalizzabile del tipo collectible

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

È possibile creare un'istanza del AssemblyLoadContext personalizzato e caricarvi un assembly come segue:

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

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

Ora che è stato caricato un assembly, è possibile eseguire un metodo da esso. Esegui il metodo Main:

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

Dopo che il metodo Main restituisce, puoi avviare lo scaricamento chiamando il metodo Unload sull'oggetto personalizzato AssemblyLoadContext o rimuovendo il riferimento a AssemblyLoadContext.

alc.Unload();

Questo è sufficiente per scaricare l'assemblaggio di test. Successivamente, si inserisce tutto questo in un metodo non inlineabile separato per assicurarsi che TestAssemblyLoadContext, Assembly, e MethodInfo (il Assembly.EntryPoint) non possano essere mantenuti attivi dai riferimenti allo slot dello stack (variabili locali realmente o introdotte dal JIT). Questo potrebbe mantenere vivo TestAssemblyLoadContext e impedire lo scaricamento.

Restituisci anche un riferimento debole a AssemblyLoadContext in modo che possa essere usato in un secondo momento per rilevare il completamento del caricamento.

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

È ora possibile eseguire questa funzione per caricare, eseguire e scaricare l'assembly.

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

Tuttavia, lo scaricamento non viene completato immediatamente. Come accennato in precedenza, si basa sul Garbage Collector per raccogliere tutti gli oggetti dall'assembly di test. In molti casi, non è necessario attendere il completamento dello scaricamento. Tuttavia, esistono casi in cui è utile sapere che lo scaricamento è terminato. Ad esempio, è possibile eliminare il file di assembly caricato nel disco personalizzato AssemblyLoadContext . In questo caso, è possibile usare il frammento di codice seguente. Attiva l'operazione di Garbage Collection e attende i finalizzatori in sospeso in un ciclo fino a quando il riferimento debole all'oggetto personalizzato AssemblyLoadContext non viene impostato su null, a indicare che l'oggetto di destinazione è stato raccolto. Nella maggior parte dei casi, è necessario un solo passaggio attraverso il ciclo. Tuttavia, per casi più complessi in cui oggetti creati dal codice in esecuzione nei AssemblyLoadContext hanno finalizzatori, potrebbero essere necessari più interventi.

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

Limitazioni

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

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

Evento di scaricamento

In alcuni casi, potrebbe essere necessario che il codice caricato in un oggetto personalizzato AssemblyLoadContext esegua alcune operazioni di pulizia all'avvio dello scaricamento. Ad esempio, potrebbe essere necessario arrestare i thread o pulire gli handle GC forti. L'evento Unloading può essere usato in questi casi. È possibile associare un gestore che esegue la pulizia necessaria a questo evento.

Risolvere i problemi di mancato caricamento

A causa della natura cooperativa dello scaricamento, è facile dimenticare i riferimenti che potrebbero mantenere la roba in un raccoglibile AssemblyLoadContext vivo e prevenire lo scaricamento. Di seguito è riportato un riepilogo delle entità (alcune non ovvie) che possono contenere i riferimenti:

  • Riferimenti regolari mantenuti dall'esterno della raccolta AssemblyLoadContext che vengono 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), una variabile statica o un handle GC forte (pinning) e in modo transitivo che punta a:
    • L'assembly è stato caricato nell'oggetto collectible AssemblyLoadContext.
    • Tipo di un assembly di questo tipo.
    • Istanza di un tipo proveniente da tale assembly.
  • Thread che eseguono codice da un assembly caricato nell'oggetto collectible AssemblyLoadContext.
  • Istanze di tipi personalizzati e non raccolta AssemblyLoadContext creati all'interno del tipo raccolta AssemblyLoadContext.
  • Istanze in sospeso RegisteredWaitHandle con callback impostati su metodi nella classe personalizzata AssemblyLoadContext.
  • Campi nella sottoclasse personalizzata AssemblyLoadContext che fanno riferimento ad assembly, tipi o istanze di tipi caricati nell'oggetto collectible AssemblyLoadContext. Durante lo scaricamento, il runtime mantiene un handle GC sicuro su AssemblyLoadContext per coordinare lo scaricamento. Questo significa che i riferimenti di campo non verranno raccolti dal GC, anche dopo aver eliminato il proprio riferimento a AssemblyLoadContext. Cancellare questi campi in modo che lo scaricamento possa essere completato.

Suggerimento

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

  • Quando i risultati delle chiamate di funzione vengono passati direttamente a un'altra funzione, anche se non è presente alcuna variabile locale creata 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

I problemi di debug durante lo scaricamento possono essere tediosi. È possibile trovarsi in situazioni in cui non si sa cosa possa mantenere un AssemblyLoadContext attivo, ma lo scaricamento ha esito negativo. Lo strumento migliore da usare è WinDbg (o LLDB in Unix) con il plug-in SOS. È necessario trovare cosa mantiene in vita un LoaderAllocator che appartiene allo specifico AssemblyLoadContext. Il plug-in SOS consente di esaminare gli oggetti dell'heap del GC, le loro 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

A questo punto si eseguirà il debug di un programma di esempio con problemi di scaricamento. Il codice sorgente è disponibile nella sezione Codice sorgente di esempio . Quando lo si esegue in WinDbg, il programma si interrompe nel debugger subito dopo aver tentato di verificare l'esito positivo del caricamento. È quindi possibile iniziare a cercare i colpevoli.

Suggerimento

Se si esegue il debug usando LLDB su Unix, i comandi SOS negli esempi seguenti non hanno ! davanti.

!dumpheap -type LoaderAllocator

Questo comando esegue il dump di tutti gli oggetti nell'heap GC con un nome di tipo contenente LoaderAllocator. 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 parte "Statistiche:" controllare l'oggetto MT (MethodTable) che appartiene a System.Reflection.LoaderAllocator, ovvero l'oggetto a cui si è interessati. Quindi, nell'elenco all'inizio trovare la voce con MT corrispondente a quella e ottenere l'indirizzo dell'oggetto stesso. In questo caso, è "000002b78000ce40".

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

!gcroot 0x000002b78000ce40

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

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

Il passaggio successivo consiste nel capire dove si trova la radice in modo da poterla correggere. Il caso più semplice è quando la radice è uno slot stack o un registro del processore. In tal caso, gcroot mostra il nome della funzione il cui frame contiene la radice e il thread che esegue tale funzione. Il caso difficile è quando la radice è una variabile statica o un handle GC.

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

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

La terza radice è fissata GCHandle. Questo è in realtà una variabile statica, ma purtroppo non c'è modo di dire. Gli elementi statici per i tipi di riferimento vengono memorizzati in una matrice di oggetti gestiti nelle strutture interne del runtime.

Un altro caso che può impedire lo scaricamento di un oggetto AssemblyLoadContext è quando un thread ha un frame di un metodo da un assembly caricato nello AssemblyLoadContext stack. È possibile verificarlo facendo il dump degli stack delle chiamate gestite di tutti i thread.

~*e !clrstack

Il comando significa "applica il comando !clrstack a tutti i thread". Di seguito è riportato l'output del comando per l'esempio. Sfortunatamente, LLDB in Unix non ha modo di applicare un comando a tutti i thread, quindi è necessario cambiare manualmente i thread e ripetere il clrstack comando. Ignorare tutti i thread in cui il debugger indica "Impossibile eseguire il walkthrough dello 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, l'ultimo thread ha test.Program.ThreadProc(). Si tratta di una funzione dell'assembly caricato in AssemblyLoadContext, e quindi mantiene attivo il AssemblyLoadContext.

Codice sorgente di esempio

Nell'esempio di debug precedente viene usato il codice seguente che contiene problemi di scaricabilità.

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

Il programma è stato caricato nel TestAssemblyLoadContext

Il codice seguente rappresenta il test.dll passato al ExecuteAndUnload metodo 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;
        }
    }
}