Comment utiliser les assemblies et déboguer les problèmes provoquant leur non-chargeabilité dans .NET

.NET (Core) a introduit la possibilité de charger et de décharger ultérieurement un ensemble d’assemblys. Dans le .NET Framework, les domaines d’application personnalisés étaient 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 via AssemblyLoadContext. Vous pouvez charger un ensemble d’assemblys dans un AssemblyLoadContext pouvant être collecté, exécuter des méthodes dans ceux-ci ou simplement les inspecter par réflexion, puis finalement décharger AssemblyLoadContext. Les assemblys chargés dans AssemblyLoadContext sont alors déchargés.

Il existe une différence notable entre le déchargement qui utilise AssemblyLoadContext et celui qui utilise des domaines d’application. Avec les domaines d’application, le déchargement est forcé. Au moment du déchargement, tous les threads qui s’exécutent dans le domaine d’application cible sont abandonnés, les objets COM managés créés dans le domaine d’application cible sont détruits, etc. Avec AssemblyLoadContext, le déchargement est « coopératif ». L’appel de la méthode AssemblyLoadContext.Unload lance simplement le déchargement. Le déchargement se termine quand :

  • Aucun thread n’a de méthodes provenant des assemblys chargés dans AssemblyLoadContext sur leurs piles d’appels.
  • Aucun des types des assemblys chargés dans AssemblyLoadContext, des instances de ces types et des assemblys eux-mêmes ne sont référencés par :

Utilisation d’un AssemblyLoadContext pouvant être collecté

Cette section contient un tutoriel pas à pas qui présente un moyen simple de charger une application .NET (Core) dans un AssemblyLoadContext pouvant être collecté, d’exécuter son point d’entrée, puis de procéder au déchargement. Vous trouverez un exemple complet à l’adresse https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.

Créer un AssemblyLoadContext pouvant être collecté

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

Le code suivant est un exemple d’un AssemblyLoadContext de base personnalisé :

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

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

Comme vous pouvez le voir, la méthode Load retourne null. Cela signifie que tous les assemblys de dépendances 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 également charger une partie ou la totalité des dépendances dans AssemblyLoadContext, vous pouvez utiliser AssemblyDependencyResolver dans la méthode Load. AssemblyDependencyResolver résout les noms d’assembly en chemins absolus de fichier d’assembly. Le programme de résolution utilise le fichier .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 personnalisé pouvant être collecté

Cette section part du principe que vous utilisez la version de base de TestAssemblyLoadContext.

Pour créer une instance d’un AssemblyLoadContext personnalisé et y charger un assembly, effectuez les étapes suivantes :

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

Pour chacun des assemblys référencés par l’assembly chargé, la méthode TestAssemblyLoadContext.Load est appelée afin que TestAssemblyLoadContext puisse déterminer où obtenir l’assembly. Dans ce cas, elle retourne null pour indiquer qu’il doit être chargé dans le contexte par défaut à partir d’emplacements que le runtime utilise pour charger les assemblys par défaut.

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

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

Quand la méthode Main retourne une valeur, vous pouvez lancer le déchargement en appelant la méthode Unload sur le AssemblyLoadContext personnalisé ou en supprimant la référence que vous devez effectuer sur le AssemblyLoadContext :

alc.Unload();

Cela suffit pour décharger l’assembly de test. Ensuite, vous allez placer tout cela dans une méthode non linéaire distincte pour vous assurer que les TestAssemblyLoadContext, Assemblyet MethodInfo (le Assembly.EntryPoint) ne peuvent pas être maintenus actifs par les références d’emplacements de pile (locaux réels ou introduits juste-à-temps). En effet, le déchargement n’a pas lieu si TestAssemblyLoadContext reste actif.

Retournez également une référence faible à AssemblyLoadContext afin que vous puissiez l’utiliser par la suite 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 récupérateur de mémoire pour collecter tous les objets de l’assembly de test. Dans de nombreux cas, il n’est pas nécessaire d’attendre la fin du déchargement. Toutefois, dans certains cas, il est utile de savoir que le déchargement est terminé. Par exemple, vous pouvez supprimer le fichier d’assembly chargé dans le AssemblyLoadContext personnalisé du disque. Dans ce cas, vous pouvez utiliser l’extrait de code suivant. Il déclenche un nettoyage de la mémoire et attend les finaliseurs en attente dans une boucle jusqu’à ce que la référence faible à l’AssemblyLoadContext personnalisé soit null, ce qui indique que l’objet cible a été collecté. Dans la plupart des cas, un seul passage dans la boucle est nécessaire. Toutefois, dans les cas plus complexes où les objets créés par le code s’exécutant dans le AssemblyLoadContext ont des finaliseurs, plusieurs passages peuvent s’avérer nécessaires.

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

Événement de déchargement

Dans certains cas, le code chargé dans un AssemblyLoadContext personnalisé doit effectuer un certain nettoyage au moment du déchargement. Par exemple, il peut être amené à arrêter des threads ou à nettoyer des descripteurs de GC forts. L’événement Unloading peut être utilisé dans de tels cas. Vous pouvez raccorder un gestionnaire qui effectue le nettoyage nécessaire à cet événement.

Résoudre les problèmes de déchargement

En raison de la nature coopérative du déchargement, il est facile d’oublier les références susceptibles de maintenir actif le contenu d’un AssemblyLoadContext pouvant être collecté et d’empêcher le déchargement. Voici un résumé des entités qui peuvent détenir les références (certains ne sont pas évidents du tout) :

  • Références régulières détenues à l’extérieur de l’AssemblyLoadContext pouvant être collecté, 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 JIT), une variable statique ou un handle de GC fort/épinglé, et pointant transitivement vers :
    • Un assembly chargé dans l’AssemblyLoadContext pouvant être collecté.
    • Un type d’un tel assembly.
    • Une instance d’un type à partir d’un tel assembly.
  • Threads exécutant du code à partir d’un assembly chargé dans l’AssemblyLoadContext pouvant être collecté.
  • Instances de types AssemblyLoadContext personnalisés et ne pouvant être collectés, créées à l’intérieur des types AssemblyLoadContext pouvant être collectés.
  • Instances RegisteredWaitHandle en attente avec des rappels définis sur les méthodes dans l’AssemblyLoadContext personnalisé.

Conseil

Les références d’objet qui sont stockées dans des emplacements de pile ou des registres de processeur et qui pourraient 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 qui était disponible à un moment donné dans une méthode.

Déboguer les problèmes de déchargement

Le débogage des problèmes de déchargement peut être fastidieux. Vous pouvez vous retrouver dans des situations où le déchargement échoue sans que vous sachiez ce qui maintient un AssemblyLoadContext actif. L’outil le plus adapté est WinDbg (ou LLDB sous Unix) avec le plug-in SOS. Vous devez trouver ce qui fait qu’un LoaderAllocator qui appartient à un AssemblyLoadContext spécifique reste actif. 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. Quand vous l’exécutez sous WinDbg, le programme s’arrête dans le débogueur juste après la tentative de vérification de la réussite du déchargement. Vous pouvez ensuite commencer à rechercher les coupables.

Conseil

Si vous déboguez avec 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 au System.Reflection.LoaderAllocator, qui est l’objet qui vous intéresse. Ensuite, dans la liste située au début, recherchez l’entrée avec MT qui correspond à cet objet et obtenez l’adresse de l’objet lui-même. 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 des références d’objet qui mènent à l’instance LoaderAllocator. La liste commence par la racine, qui est l’entité qui maintient le LoaderAllocator actif et qui constitue donc le cœur du problème. La racine peut être un emplacement de pile, un registre de processeur, un handle de GC ou une variable statique.

Voici un exemple de sortie de la commande 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.

L’étape suivante consiste à déterminer où se trouve la racine pour que vous puissiez la corriger. Dans le cas le plus simple, la racine est un emplacement de pile ou un registre de processeur. Dans ce cas, gcroot affiche le nom de la fonction dont le frame contient la racine et le thread exécutant cette fonction. La situation est plus complexe quand la racine est une variable statique ou un handle de GC.

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

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

La troisième racine est un GCHandle épinglé. Celle-ci est en fait une variable statique, mais malheureusement, aucun moyen ne permet de le deviner. Les variables statiques pour les types références sont stockées dans un tableau d’objets managés dans des structures de runtime internes.

Le déchargement d’un AssemblyLoadContext peut aussi ne pas aboutir quand un thread a le frame d’une méthode d’un assembly chargé dans l’AssemblyLoadContext sur sa pile. Pour vérifier cela, videz les piles d’appels managées de tous les threads :

~*e !clrstack

La commande signifie « appliquer à tous les threads la commande !clrstack ». 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 changer manuellement les threads et répéter la commande clrstack. Ignorez tous les threads où le débogueur indique « Impossible de parcourir 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 AssemblyLoadContext qui maintient AssemblyLoadContext actif.

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 le fichier test.dll passé à la méthode ExecuteAndUnload 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;
        }
    }
}