Partager via


Comment utiliser et déboguer le déchargement d’assembly dans .NET

.NET (Core) a introduit la possibilité de charger et de décharger ultérieurement un ensemble d’assemblys. Dans .NET Framework, les domaines d’application personnalisés ont été utilisés à cet effet, mais .NET (Core) ne prend en charge qu’un seul domaine d’application par défaut.

Le déchargement est pris en charge par le biais de AssemblyLoadContext. Vous pouvez charger des assemblies collectables AssemblyLoadContext, exécuter des méthodes en eux ou simplement les inspecter à l'aide de la réflection, et enfin décharger le AssemblyLoadContext. Décharge les assemblies chargées dans le AssemblyLoadContext.

Il existe une différence notable entre le déchargement à l'aide de AssemblyLoadContext et l'utilisation des AppDomains. Avec AppDomains, le déchargement est forcé. Au moment du déchargement, tous les threads s’exécutant dans l’AppDomain cible sont abandonnés, les objets COM managés créés dans l’AppDomain cible sont détruits, et ainsi de suite. Avec AssemblyLoadContext, le déchargement est « coopératif ». L’appel de la AssemblyLoadContext.Unload méthode lance simplement le déchargement. Le déchargement se termine après :

  • Aucun thread n’a de méthodes de la part des assemblies chargées dans AssemblyLoadContext sur leurs piles d'appels.
  • Aucun des types des assemblages chargés dans le AssemblyLoadContext, les instances de ces types, et les assemblages eux-mêmes ne sont référencés par :

Utiliser le AssemblyLoadContext collectible

Cette section contient un didacticiel détaillé qui montre un moyen simple de charger une application .NET (Core) dans un AssemblyLoadContext collectable, d’exécuter son point d’entrée, puis de la décharger. Vous trouverez un exemple complet à l’adresse https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.

Créer un AssemblyLoadContext collectible

Dérivez votre classe de la AssemblyLoadContext méthode et remplacez sa AssemblyLoadContext.Load méthode. Cette méthode résout les références à tous les assemblys qui sont des dépendances des assemblys chargés dans ce AssemblyLoadContext.

Le code suivant est un exemple de code personnalisé AssemblyLoadContextle plus simple :

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

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

Comme vous pouvez le voir, la Load méthode retourne null. Cela signifie que tous les assemblys de dépendance sont chargés dans le contexte par défaut, et que le nouveau contexte contient uniquement les assemblys explicitement chargés dans celui-ci.

Si vous souhaitez charger certaines ou toutes les dépendances dans la AssemblyLoadContext méthode, vous pouvez également utiliser la AssemblyDependencyResolverLoad méthode. Le AssemblyDependencyResolver convertit les noms d’assembly en chemins de fichiers d’assembly absolus. Le programme de résolution utilise les fichiers .deps.json et les fichiers d’assembly dans le répertoire de l’assembly principal chargé dans le contexte.

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

Utiliser un AssemblyLoadContext collectible personnalisé

Cette section suppose que la version la plus simple du fichier TestAssemblyLoadContext est utilisée.

Vous pouvez créer une instance de l'objet personnalisé AssemblyLoadContext et charger un assembly dans celui-ci comme suit :

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

Pour chacune des assemblies référencées par l'assembly chargé, la méthode TestAssemblyLoadContext.Load est appelée afin que la méthode TestAssemblyLoadContext puisse décider de l'origine à partir de laquelle obtenir l'assembly. Dans ce cas, il retourne null pour indiquer qu’il doit être chargé dans le contexte par défaut à partir des emplacements que le runtime utilise pour charger des assemblys par défaut.

Maintenant qu’un assembly a été chargé, vous pouvez exécuter une méthode à partir de celle-ci. Exécutez la Main méthode :

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

Une fois que la méthode Main a terminé, vous pouvez initier le déchargement en appelant la méthode Unload sur le AssemblyLoadContext personnalisé ou en supprimant la référence que vous avez vers le AssemblyLoadContext.

alc.Unload();

Cela suffit pour décharger l’assembly de test. Ensuite, vous allez placer toutes ces informations dans une méthode distincte non inline pour vous assurer que le TestAssemblyLoadContext, Assembly et MethodInfo (le Assembly.EntryPoint) ne peuvent pas être maintenus actifs par les références aux emplacements de pile (locaux réels ou introduits par JIT). Cela pourrait maintenir TestAssemblyLoadContext actif et empêcher le déchargement.

En outre, fournissez une référence faible à AssemblyLoadContext afin de pouvoir l’utiliser ultérieurement pour détecter la fin du déchargement.

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

Vous pouvez maintenant exécuter cette fonction pour charger, exécuter et décharger l’assembly.

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

Toutefois, le déchargement ne se termine pas immédiatement. Comme mentionné précédemment, il s’appuie sur le garbage collector pour collecter tous les objets de l’assemblage de test. Dans de nombreux cas, il n’est pas nécessaire d’attendre la fin du déchargement. Toutefois, il existe des cas où il est utile de savoir que le déchargement est terminé. Par exemple, vous pouvez supprimer le fichier d’assembly qui a été chargé dans le disque personnalisé AssemblyLoadContext . Dans ce cas, l’extrait de code suivant peut être utilisé. Il déclenche le ramasse-miettes et attend les finaliseurs en attente dans une boucle jusqu'à ce que la référence faible à l'objet personnalisé AssemblyLoadContext soit définie sur null, indiquant que l'objet cible a été collecté. Dans la plupart des cas, une seule passe à travers la boucle est nécessaire. Toutefois, pour les cas plus complexes où les objets créés par le code exécuté dans le AssemblyLoadContext ont des finaliseurs, d'autres passages peuvent être nécessaires.

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

Limites

Les assemblies chargées dans un AssemblyLoadContext collectible doivent respecter les restrictions générales sur les assemblies collectibles. Les limitations suivantes s’appliquent également :

  • Les assemblys écrits en C++/CLI ne sont pas pris en charge.
  • Le code généré ReadyToRun est ignoré.

Événement de déchargement

Dans certains cas, il peut être nécessaire que le code chargé dans un code personnalisé AssemblyLoadContext effectue un nettoyage lorsque le déchargement est lancé. Par exemple, il peut être nécessaire d’arrêter des threads ou de nettoyer des handles GC forts. L’événement Unloading peut être utilisé dans de tels cas. Vous pouvez ajouter à cet événement un gestionnaire qui effectue le nettoyage nécessaire.

Résoudre les problèmes de non-déchargeabilité

En raison de la nature coopérative du déchargement, il est facile d’oublier les références qui pourraient garder les objets dans un collectible AssemblyLoadContext actifs et empêcher le déchargement. Voici un résumé des entités (certaines d’entre elles non évidentes) qui peuvent détenir les références :

  • Références régulières conservées à partir de l’extérieur du collectable AssemblyLoadContext qui sont stockées dans un emplacement de pile ou un registre de processeur (variables locales de méthode, créées explicitement par le code utilisateur ou implicitement par le compilateur juste-à-temps), une variable statique ou un handle GC fort (épinglé) et pointant de façon transitive vers :
    • Assembly chargé dans le collectible AssemblyLoadContext.
    • Type d'assembly de ce genre.
    • Instance d’un type provenant d’un tel assembly.
  • Threads exécutant du code à partir d’un assembly chargé dans le collectible AssemblyLoadContext.
  • Instances de types personnalisés non collectables créés AssemblyLoadContext à l'intérieur de l'élément collectable AssemblyLoadContext.
  • Instances en attente RegisteredWaitHandle avec des rappels associés à des méthodes dans le module personnalisé AssemblyLoadContext.
  • Champs de votre sous-classe personnalisée AssemblyLoadContext qui référencent des assemblys, des types ou des instances de types chargés dans la collection AssemblyLoadContext. Pendant que le déchargement est en cours, le runtime conserve un handle GC fort vers AssemblyLoadContext pour coordonner le déchargement. Cela signifie que le GC ne collecte pas ces références aux champs même après avoir abandonné votre propre référence au AssemblyLoadContext. Effacez ces champs pour que le déchargement puisse se terminer.

Conseil / Astuce

Les références d’objet stockées dans des emplacements de pile ou des registres de processeur et qui peuvent empêcher le déchargement d’un AssemblyLoadContext peuvent se produire dans les situations suivantes :

  • Lorsque les résultats des appels de fonction sont transmis directement à une autre fonction, même s’il n’existe aucune variable locale créée par l’utilisateur.
  • Lorsque le compilateur JIT conserve une référence à un objet disponible à un moment donné dans une méthode.

Déboguer les problèmes de déchargement

Les problèmes de débogage liés au déchargement peuvent être fastidieux. Il peut arriver que vous ignoriez ce qui peut maintenir un AssemblyLoadContext actif, mais que le déchargement échoue. L'outil le mieux adapté pour cela est WinDbg (ou LLDB sur Unix) avec le plug-in SOS. Vous devez trouver ce qui maintient en vie un LoaderAllocator qui fait partie du AssemblyLoadContext spécifique. Le plug-in SOS vous permet d’examiner les objets de tas GC, leurs hiérarchies et leurs racines.

Pour charger le plug-in SOS dans le débogueur, entrez l’une des commandes suivantes dans la ligne de commande du débogueur.

Dans WinDbg (s’il n’est pas déjà chargé) :

.loadby sos coreclr

Dans LLDB :

plugin load /path/to/libsosplugin.so

Vous allez maintenant déboguer un exemple de programme qui rencontre des problèmes de déchargement. Le code source est disponible dans la section Exemple de code source . Lorsque vous l’exécutez sous WinDbg, le programme s’interrompt dans le débogueur juste après avoir vérifié que le déchargement a réussi. Vous pouvez ensuite commencer à chercher les coupables.

Conseil / Astuce

Si vous déboguez à l'aide de LLDB sur Unix, les commandes SOS dans les exemples suivants ne sont pas précédées de !.

!dumpheap -type LoaderAllocator

Cette commande vide tous les objets dont le nom de type contient LoaderAllocator et qui se trouvent dans le tas GC. Voici un exemple :

         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

Dans la partie « Statistiques : », vérifiez le MT (MethodTable) qui appartient à l’objet System.Reflection.LoaderAllocatorauquel vous vous intéressez. Ensuite, dans la liste au début, trouvez l’entrée avec MT qui correspond à celle-là, et récupérez l’adresse de l’objet en question. Dans ce cas, il s’agit de « 000002b78000ce40 ».

Maintenant que vous connaissez l’adresse de l’objet LoaderAllocator , vous pouvez utiliser une autre commande pour trouver ses racines GC :

!gcroot 0x000002b78000ce40

Cette commande vide la chaîne de références d’objets qui mènent à l’instance LoaderAllocator . La liste commence par la racine, qui est l’entité qui maintient la LoaderAllocator vie et est donc le cœur du problème. La racine peut être un emplacement de pile, un registre de processeur, un handle GC ou une variable statique.

Voici un exemple de sortie de la gcroot commande :

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.

L’étape suivante consiste à déterminer où se trouve la racine afin de pouvoir la corriger. Le cas le plus simple est lorsque la racine est un emplacement de pile ou un registre de processeur. Dans ce cas, le gcroot montre le nom de la fonction dont le cadre contient la racine et le fil d'exécution qui exécute cette fonction. Le cas difficile est lorsque la racine est une variable statique ou un handle GC.

Dans l’exemple précédent, la première racine est un local de type System.Reflection.RuntimeMethodInfo stocké dans le cadre de la fonction example.Program.Main(System.String[]) à l’adresse rbp-20 (rbp est le registre rbp du processeur et -20 est un décalage hexadécimal de ce registre).

La deuxième racine est une racine normale (forte) GCHandle qui contient une référence à une instance de la test.Test classe.

La troisième racine est fixée par un GCHandle. Il s’agit en fait d’une variable statique, mais malheureusement, il n’y a aucun moyen de le dire. Les statiques pour les types de référence sont stockées dans un tableau d’objets managés dans des structures d’exécution internes.

Un autre cas qui peut empêcher le déchargement d’un AssemblyLoadContext est lorsqu’un thread a une trame d’une méthode d’un assembly chargé sur la pile AssemblyLoadContext. Vous pouvez vérifier qu’en vidant les piles d’appels managées de tous les threads :

~*e !clrstack

La commande signifie « appliquer à tous les threads la !clrstack commande ». Voici la sortie de cette commande pour l’exemple. Malheureusement, LLDB sur Unix n’a aucun moyen d’appliquer une commande à tous les threads. Vous devez donc basculer manuellement les threads et répéter la clrstack commande. Ignorez tous les threads où le débogueur indique « Impossible d'analyser la pile managée ».

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]

Comme vous pouvez le voir, le dernier thread a test.Program.ThreadProc(). Il s’agit d’une fonction de l’assembly chargé dans le AssemblyLoadContext, et elle maintient donc AssemblyLoadContext en vie.

Exemple de code source

Le code suivant qui contient des problèmes de déchargement est utilisé dans l’exemple de débogage précédent.

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

Programme chargé dans testAssemblyLoadContext

Le code suivant représente la test.dll passée à la ExecuteAndUnload méthode dans le programme de test 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;
        }
    }
}