Jak używać i debugować możliwość zwolnienia zestawu na platformie .NET

Platforma .NET (Core) wprowadziła możliwość ładowania i późniejszego zwalniania zestawu zestawów. W programie .NET Framework niestandardowe domeny aplikacji były używane w tym celu, ale platforma .NET (Core) obsługuje tylko jedną domyślną domenę aplikacji.

Możliwość zwolnienia jest obsługiwana za pośrednictwem programu AssemblyLoadContext. Zestaw zestawów można załadować do obiektu zbieralnego AssemblyLoadContext, wykonać w nich metody lub po prostu sprawdzić je przy użyciu odbicia, a na koniec zwolnić element AssemblyLoadContext. Zwalnia to zestawy załadowane do pliku AssemblyLoadContext.

Istnieje jedna godna uwagi różnica między zwalnianiem przy użyciu i AssemblyLoadContext używaniem obiektów AppDomains. W przypadku parametrów AppDomains zwalnianie jest wymuszane. W czasie zwolnienia wszystkie wątki uruchomione w docelowej domenie aplikacji są przerywane, zarządzane obiekty COM utworzone w docelowej domenie aplikacji są niszczone itd. W przypadku AssemblyLoadContext, rozładunek jest "spółdzielnia". AssemblyLoadContext.Unload Wywołanie metody po prostu inicjuje zwalnianie. Zwalnianie kończy się po:

  • Żadne wątki nie mają metod z zestawów załadowanych do AssemblyLoadContext stosów wywołań.
  • Żadne z typów z zestawów załadowanych do AssemblyLoadContextwystąpienia tych typów i same zestawy nie są przywoływane przez:
    • Odwołania poza AssemblyLoadContextelementem , z wyjątkiem słabych odwołań (WeakReference lub WeakReference<T>).
    • Silne uchwyty modułu odśmiecania pamięci (GCHandleType.Normal lub GCHandleType.Pinned) zarówno wewnątrz, jak i na zewnątrz AssemblyLoadContext.

Używanie funkcji Collectible AssemblyLoadContext

Ta sekcja zawiera szczegółowy samouczek krok po kroku, który przedstawia prosty sposób ładowania aplikacji platformy .NET (Core) do obiektu zbieralnego AssemblyLoadContext, wykonywania punktu wejścia, a następnie zwalniania aplikacji. Pełny przykład można znaleźć na stronie https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.

Tworzenie zbieralnego elementu AssemblyLoadContext

Utwórz klasę na podstawie AssemblyLoadContext metody i zastąpij jej AssemblyLoadContext.Load metodę. Ta metoda rozpoznaje odwołania do wszystkich zestawów, które są zależnościami zestawów załadowanych do tej AssemblyLoadContextmetody .

Poniższy kod jest przykładem najprostszego niestandardowego AssemblyLoadContextkodu:

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

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

Jak widać, Load metoda zwraca nullwartość . Oznacza to, że wszystkie zestawy zależności są ładowane do kontekstu domyślnego, a nowy kontekst zawiera tylko zestawy jawnie załadowane do niego.

Jeśli chcesz załadować niektóre lub wszystkie zależności do AssemblyLoadContext elementu , możesz użyć AssemblyDependencyResolver metody w metodzie Load . Element AssemblyDependencyResolver rozpoznaje nazwy zestawów na bezwzględne ścieżki plików zestawu. Narzędzie rozpoznawania używa plików .deps.json i plików zestawów w katalogu głównego zestawu załadowanego do kontekstu.

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

Używanie niestandardowego zbieralnego elementu AssemblyLoadContext

W tej sekcji założono, że używana jest prostsza wersja elementu TestAssemblyLoadContext .

Możesz utworzyć wystąpienie niestandardowe AssemblyLoadContext i załadować do niego zestaw w następujący sposób:

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

Dla każdego z zestawów, do których odwołuje się załadowany zestaw, metoda jest wywoływana tak, TestAssemblyLoadContext.Load aby TestAssemblyLoadContext można było zdecydować, skąd ma być pobierany zestaw. W takim przypadku zwraca null wartość , aby wskazać, że powinien zostać załadowany do domyślnego kontekstu z lokalizacji używanych przez środowisko uruchomieniowe do ładowania zestawów domyślnie.

Teraz, gdy zestaw został załadowany, możesz wykonać z niego metodę. Uruchom metodę Main :

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

Po powrocie Main metody można zainicjować zwalnianie, wywołując metodę Unload w niestandardowym AssemblyLoadContext lub usuwając odwołanie do elementu AssemblyLoadContext:

alc.Unload();

Wystarczy zwolnić zestaw testowy. Następnie umieścisz wszystkie te elementy w oddzielnej metodzie nieinlineable, aby upewnić się, że TestAssemblyLoadContextnie można zachować aktywności elementów , Assemblyi MethodInfo () Assembly.EntryPointprzez odwołania do miejsca stosu (lokalne wprowadzone w rzeczywistości lub JIT). To może utrzymać TestAssemblyLoadContext życie i zapobiec rozładowaniu.

Ponadto zwróć słabe odwołanie do elementu AssemblyLoadContext , aby można było go później użyć do wykrywania zakończenia zwolnienia.

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

Teraz możesz uruchomić tę funkcję, aby załadować, wykonać i zwolnić zestaw.

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

Jednak zwolnienie nie zostanie ukończone natychmiast. Jak wspomniano wcześniej, opiera się na modułze odśmiecającym elementy do zbierania wszystkich obiektów z zestawu testowego. W wielu przypadkach nie trzeba czekać na zakończenie zwolnienia. Istnieją jednak przypadki, w których warto wiedzieć, że zwolnienie zostało zakończone. Możesz na przykład usunąć plik zestawu, który został załadowany do niestandardowego AssemblyLoadContext dysku. W takim przypadku można użyć następującego fragmentu kodu. Wyzwala odzyskiwanie pamięci i czeka na oczekujące finalizatory w pętli, dopóki słabe odwołanie do niestandardowego AssemblyLoadContext nie zostanie ustawione na null, wskazując, że obiekt docelowy został zebrany. W większości przypadków wymagana jest tylko jedna pętla przekazywania. Jednak w przypadku bardziej złożonych przypadków, w których obiekty utworzone przez kod uruchomiony w AssemblyLoadContext finalizatorach mogą być potrzebne więcej przebiegów.

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

Zdarzenie zwalniania

W niektórych przypadkach może być konieczne załadowanie kodu do niestandardowego AssemblyLoadContext w celu przeprowadzenia czyszczenia podczas inicjowania zwolnienia. Na przykład może być konieczne zatrzymanie wątków lub wyczyszczenie silnych uchwytów GC. Zdarzenie Unloading może być używane w takich przypadkach. Można podłączyć procedurę obsługi, która wykonuje niezbędne czyszczenie tego zdarzenia.

Rozwiązywanie problemów z rozładowywaniem

Ze względu na spółdzielczy charakter rozładowy, łatwo zapomnieć o odniesieniach, które mogą być utrzymanie rzeczy w przedmiotach zbieralnych AssemblyLoadContext przy życiu i zapobieganie rozładowaniu. Oto podsumowanie jednostek (niektóre z nich nieobjętne), które mogą zawierać odwołania:

  • Regularne odwołania przechowywane spoza kolekcji AssemblyLoadContext , które są przechowywane w miejscu stosu lub rejestrze procesora (metody lokalne, jawnie utworzone przez kod użytkownika lub niejawnie przez kompilator just in time (JIT), zmienną statyczną lub silne (przypinanie) uchwyt GC i przechodnio wskazujące:
    • Zestaw załadowany do kolekcji AssemblyLoadContext.
    • Typ z takiego zestawu.
    • Wystąpienie typu z takiego zestawu.
  • Wątki uruchamiane z zestawu załadowane do kolekcji AssemblyLoadContext.
  • Wystąpienia niestandardowych, niekolektowalnych AssemblyLoadContext typów utworzonych wewnątrz kolekcji .AssemblyLoadContext
  • Oczekujące RegisteredWaitHandle wystąpienia z wywołaniami zwrotnymi ustawione na metody w niestandardowym AssemblyLoadContextobiekcie .

Napiwek

Odwołania do obiektów, które są przechowywane w miejscach stosu lub rejestrach procesora i które mogą zapobiec rozładowaniu obiektu AssemblyLoadContext , mogą wystąpić w następujących sytuacjach:

  • Gdy wyniki wywołania funkcji są przekazywane bezpośrednio do innej funkcji, mimo że nie ma zmiennej lokalnej utworzonej przez użytkownika.
  • Gdy kompilator JIT przechowuje odwołanie do obiektu, który był dostępny w pewnym momencie w metodzie.

Debugowanie problemów z zwalnianiem

Problemy z debugowaniem podczas zwalniania mogą być żmudne. Możesz dostać się do sytuacji, w których nie wiesz, co może być trzymane AssemblyLoadContext przy życiu, ale zwalnianie kończy się niepowodzeniem. Najlepszym narzędziem, które pomaga w tym jest WinDbg (lub LLDB w systemie Unix) z wtyczką SOS. Musisz znaleźć, co utrzymuje LoaderAllocator element, który należy do konkretnego AssemblyLoadContext żywego. Wtyczka SOS umożliwia przyjrzenie się obiektom sterty GC, ich hierarchiom i katalogom korzeniowym.

Aby załadować wtyczkę SOS do debugera, wprowadź jedno z następujących poleceń w wierszu polecenia debugera.

W systemie WinDbg (jeśli jeszcze nie został załadowany):

.loadby sos coreclr

W usłudze LLDB:

plugin load /path/to/libsosplugin.so

Teraz będziesz debugować przykładowy program, który ma problemy z zwalnianiem. Kod źródłowy jest dostępny w sekcji Przykładowy kod źródłowy. Po uruchomieniu go w systemie WinDbg program dzieli się na debuger bezpośrednio po próbie sprawdzenia powodzenia zwolnienia. Następnie możesz zacząć szukać sprawców.

Napiwek

W przypadku debugowania przy użyciu usługi LLDB w systemie Unix polecenia SOS w poniższych przykładach nie są ! przed nimi dostępne.

!dumpheap -type LoaderAllocator

To polecenie zrzutuje wszystkie obiekty o nazwie typu zawierającej LoaderAllocator sterty GC. Oto przykład:

         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

W części "Statistics:" (Statystyka:) sprawdź MT element (MethodTable), który należy do System.Reflection.LoaderAllocatorobiektu , którego dotyczy. Następnie na liście na początku znajdź wpis zgodny z MT tym elementem i pobierz adres samego obiektu. W tym przypadku jest to "000002b78000ce40".

Teraz, gdy znasz adres LoaderAllocator obiektu, możesz użyć innego polecenia, aby znaleźć jego korzenie GC:

!gcroot 0x000002b78000ce40

To polecenie zrzutuje łańcuch odwołań do obiektów, które prowadzą do LoaderAllocator wystąpienia. Lista zaczyna się od katalogu głównego, który jest jednostką, która utrzymuje LoaderAllocator przy życiu, a tym samym jest rdzeniem problemu. Katalog główny może być miejscem stosu, rejestrem procesora, uchwytem GC lub zmienną statyczną.

Oto przykład danych wyjściowych gcroot polecenia:

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.

Następnym krokiem jest ustalenie, gdzie znajduje się katalog główny, aby można było go naprawić. Najprostszym przypadkiem jest, gdy katalog główny jest miejscem stosu lub rejestrem procesora. W takim przypadku parametr pokazuje nazwę funkcji, gcroot której ramka zawiera katalog główny i wątek wykonujący tę funkcję. Trudny przypadek polega na tym, że katalog główny jest zmienną statyczną lub uchwytem GC.

W poprzednim przykładzie pierwszy katalog główny jest lokalnym typem System.Reflection.RuntimeMethodInfo przechowywanym w ramce funkcji example.Program.Main(System.String[]) pod adresem rbp-20 (rbp czy rejestr rbp procesora i -20 jest przesunięciem szesnastkowym z tego rejestru).

Drugi element główny to normalny (silny), GCHandle który przechowuje odwołanie do wystąpienia test.Test klasy.

Trzeci katalog główny jest przypięty GCHandle. Jest to rzeczywiście zmienna statyczna, ale niestety, nie ma sposobu, aby powiedzieć. Statyczne typy odwołań są przechowywane w tablicy obiektów zarządzanych w wewnętrznych strukturach środowiska uruchomieniowego.

Innym przypadkiem, który może zapobiec rozładowaniu elementu AssemblyLoadContext , jest sytuacja, gdy wątek ma ramkę metody z zestawu załadowanego do stosu AssemblyLoadContext . Możesz to sprawdzić, dumpingując zarządzane stosy wywołań wszystkich wątków:

~*e !clrstack

Polecenie oznacza "zastosuj do wszystkich wątków !clrstack polecenia". Poniżej przedstawiono dane wyjściowe tego polecenia dla przykładu. Niestety, usługa LLDB w systemie Unix nie ma możliwości zastosowania polecenia do wszystkich wątków, więc należy ręcznie przełączać wątki i powtarzać clrstack polecenie. Ignoruj wszystkie wątki, w których debuger mówi "Nie można przejść przez zarządzany stos".

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]

Jak widać, ostatni wątek ma wartość test.Program.ThreadProc(). Jest to funkcja z zestawu załadowanego do AssemblyLoadContextelementu , a więc utrzymuje żywcem AssemblyLoadContext .

Przykładowy kod źródłowy

Poniższy kod, który zawiera problemy z rozładowaniem, jest używany w poprzednim przykładzie debugowania.

Główny program testowania

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

Program załadowany do elementu TestAssemblyLoadContext

Poniższy kod reprezentuje test.dll przekazaną ExecuteAndUnload do metody w głównym programie testowym.

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