Jak používat a ladit možnost uvolnění sestavení v .NET

.NET (Core) zavedl možnost načtení a pozdějšího uvolnění sady sestavení. V rozhraní .NET Framework se pro tento účel používaly vlastní domény aplikací, ale .NET (Core) podporuje pouze jednu výchozí doménu aplikace.

Unloadability is supported through AssemblyLoadContext. Sadu sestavení můžete načíst do collectible AssemblyLoadContext, spustit metody v nich nebo jen zkontrolovat pomocí reflexe a nakonec uvolnit AssemblyLoadContext. Tím se uvolní sestavení načtená do souboru AssemblyLoadContext.

Mezi uvolňováním a AssemblyLoadContext používáním appDomains je jeden pozoruhodný rozdíl. U appDomains je uvolnění vynucené. V době uvolnění jsou všechna vlákna spuštěná v cílové doméně AppDomain přerušena, spravované objekty MODELU COM vytvořené v cílové doméně AppDomain jsou zničeny atd. S AssemblyLoadContext, unload je "družstevní". Volání metody AssemblyLoadContext.Unload právě zahájí uvolňování. Uvolnění skončí po:

  • Žádná vlákna nemají metody ze sestavení načtených AssemblyLoadContext do jejich zásobníků volání.
  • Na žádné typy ze sestavení načtených do AssemblyLoadContext, instance těchto typů a samotné sestavení se odkazují:

Použití collectible AssemblyLoadContext

Tato část obsahuje podrobný kurz, který ukazuje jednoduchý způsob načtení aplikace .NET (Core) do collectible AssemblyLoadContext, spuštění jeho vstupního bodu a jeho následné uvolnění. Kompletní ukázku najdete na adrese https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.

Vytvoření collectible AssemblyLoadContext

Odvozujte třídu z AssemblyLoadContext metody a přepište její AssemblyLoadContext.Load metodu. Tato metoda vyřeší odkazy na všechna sestavení, která jsou závislostmi sestavení načtených do tohoto AssemblyLoadContext.

Následující kód je příkladem nejjednoduššího vlastního AssemblyLoadContextkódu:

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

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

Jak vidíte, Load metoda vrátí null. To znamená, že všechna sestavení závislostí jsou načtena do výchozího kontextu a nový kontext obsahuje pouze sestavení explicitně načtená do něj.

Pokud chcete také načíst některé nebo všechny závislosti do AssemblyLoadContext této metody, můžete použít metodu AssemblyDependencyResolverLoad . Přeloží AssemblyDependencyResolver názvy sestavení na absolutní cesty k souborům sestavení. Překladač používá soubor .deps.json a soubory sestavení v adresáři hlavního sestavení načteného do kontextu.

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

Použití vlastního collectible AssemblyLoadContext

V této části se předpokládá, že se používá jednodušší verze TestAssemblyLoadContext .

Instanci vlastního AssemblyLoadContext objektu můžete vytvořit a načíst do ní sestavení následujícím způsobem:

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

Pro každé sestavení odkazované načteným sestavením TestAssemblyLoadContext.Load je volána metoda, aby TestAssemblyLoadContext se mohl rozhodnout, odkud získat sestavení. V tomto případě se vrátí null k označení, že by se měl načíst do výchozího kontextu z umístění, která modul runtime používá k načtení sestavení ve výchozím nastavení.

Teď, když bylo načteno sestavení, můžete z něj spustit metodu. Spusťte metodu Main :

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

Main Po vrácení metody můžete zahájit uvolňování voláním Unload metody na vlastní AssemblyLoadContext nebo odebráním odkazu, který máte na AssemblyLoadContext:

alc.Unload();

To stačí k uvolnění testovacího sestavení. Dále ho vložíte do samostatné nelineable metody, abyste zajistili, že TestAssemblyLoadContextodkazy na sloty zásobníku ( Assemblyreal- nebo JIT-zavedené místní prostředí) a MethodInfo (the Assembly.EntryPoint) nemohou být udržovány naživu. To by mohlo udržet TestAssemblyLoadContext život a zabránit uvolnění.

Také vraťte slabý odkaz, AssemblyLoadContext abyste ho mohli později použít ke zjištění dokončení uvolnění.

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

Teď můžete tuto funkci spustit a načíst, spustit a uvolnit sestavení.

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

Uvolnění se ale nedokončí okamžitě. Jak už bylo zmíněno dříve, spoléhá na systém uvolňování paměti ke shromáždění všech objektů z testovacího sestavení. V mnoha případech není nutné čekat na dokončení uvolnění. Existují však případy, kdy je užitečné vědět, že se uvolnění dokončilo. Můžete například chtít odstranit soubor sestavení, který byl načten do vlastního AssemblyLoadContext disku. V takovém případě je možné použít následující fragment kódu. Aktivuje uvolňování paměti a čeká na čekající finalizátory ve smyčce, dokud slabý odkaz na vlastní AssemblyLoadContext není nastaven na nullhodnotu , což znamená, že cílový objekt byl shromážděn. Ve většině případů se vyžaduje jenom jeden průchod smyčkou. V případě složitějších případů, kdy objekty vytvořené kódem spuštěným v AssemblyLoadContext finalizačních metodách mohou být potřeba více průchodů.

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

Událost uvolňování

V některých případech může být nutné, aby kód načtený do vlastního AssemblyLoadContext souboru provedl vyčištění při zahájení uvolňování. Může například potřebovat zastavit vlákna nebo vyčistit silné úchyty GC. Událost Unloading lze v takových případech použít. Můžete zavěsit obslužnou rutinu, která provede potřebné vyčištění této události.

Řešení potíží s uvolněním zatížení

Vzhledem k družstevní povaze uvolňování je snadné zapomenout na odkazy, které by mohly uchovávat věci v sběritelné AssemblyLoadContext naživu a zabránit uvolnění. Tady je souhrn entit (některé z nich neposlušné), které můžou obsahovat odkazy:

  • Pravidelné odkazy uchovávané mimo kolekci AssemblyLoadContext , které jsou uložené v slotu zásobníku nebo v registru procesoru (místní metody, buď explicitně vytvořené uživatelským kódem, nebo implicitně kompilátorem JIT), statickou proměnnou nebo silnou (připnutou) rukojeť GC a přechodně odkazující na:
    • Sestavení načtené do sběrného AssemblyLoadContextobjektu .
    • Typ z takového sestavení.
    • Instance typu z takového sestavení.
  • Vlákna se spuštěným kódem ze sestavení načteného do collectible AssemblyLoadContext.
  • Instance vlastních neshromažďovatelných AssemblyLoadContext typů vytvořených uvnitř collectible AssemblyLoadContext.
  • Čekající RegisteredWaitHandle instance se zpětnými voláními nastavenými na metody ve vlastním AssemblyLoadContextobjektu .

Tip

Odkazy na objekty uložené v slotech zásobníku nebo registrech procesorů a které by mohly zabránit uvolnění objektu AssemblyLoadContext v následujících situacích:

  • Když se výsledky volání funkce předají přímo jiné funkci, i když neexistuje žádná místní proměnná vytvořená uživatelem.
  • Když kompilátor JIT uchovává odkaz na objekt, který byl k dispozici v určitém okamžiku v metodě.

Ladění problémů s uvolňováním

Ladění problémů s uvolňováním může být zdlouhavé. Můžete se dostat do situací, kdy nevíte, co může držet naživu AssemblyLoadContext , ale uvolnění selže. Nejlepší nástroj, který vám pomůže s tím, je WinDbg (nebo LLDB v Unixu) s modulem plug-in SOS. Potřebujete zjistit, co je udržování LoaderAllocator , které patří konkrétnímu AssemblyLoadContext živému. Modul plug-in SOS umožňuje podívat se na objekty haldy GC, jejich hierarchie a kořeny.

Pokud chcete do ladicího programu načíst modul plug-in SOS, zadejte do příkazového řádku ladicího programu jeden z následujících příkazů.

V WinDbg (pokud ještě není načten):

.loadby sos coreclr

V LLDB:

plugin load /path/to/libsosplugin.so

Teď budete ladit ukázkový program, který má problémy s uvolněním. Zdrojový kód je k dispozici v části Příklad zdrojového kódu . Když ji spustíte v systému WinDbg, program se hned po pokusu o kontrolu úspěšného uvolnění rozdělí do ladicího programu. Pak můžete začít hledat viníky.

Tip

Pokud ladíte pomocí LLDB v unixu, příkazy SOS v následujících příkladech nemají ! před sebou.

!dumpheap -type LoaderAllocator

Tento příkaz vysadí všechny objekty s názvem typu, LoaderAllocator který je v haldě GC. Tady je příklad:

         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

V části Statistika:, zkontrolujte MT (MethodTable), která patří do objektu System.Reflection.LoaderAllocator, který vás zajímá. Pak v seznamu na začátku vyhledejte položku, MT která odpovídá této položce, a získejte adresu samotného objektu. V tomto případě je to "000002b78000ce40".

Teď, když znáte adresu objektu LoaderAllocator , můžete pomocí jiného příkazu najít jeho kořeny GC:

!gcroot 0x000002b78000ce40

Tento příkaz vysadí řetěz odkazů na objekty, které vedou k LoaderAllocator instanci. Seznam začíná kořenem, což je entita, která udržuje LoaderAllocator aktivní, a proto je jádrem problému. Kořenem může být slot zásobníku, registr procesoru, úchyt GC nebo statická proměnná.

Tady je příklad výstupu gcroot příkazu:

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.

Dalším krokem je zjistit, kde se nachází kořen, abyste ho mohli opravit. Nejjednodušším případem je, když je kořen slotem zásobníku nebo registrem procesoru. V takovém případě se zobrazí název funkce, gcroot jejíž rámec obsahuje kořen a vlákno, které danou funkci spouští. Složitým případem je, že kořen je statická proměnná nebo popisovač GC.

V předchozím příkladu je první kořen místním typem System.Reflection.RuntimeMethodInfo uloženým v rámci funkce example.Program.Main(System.String[]) na adrese rbp-20 (rbp je registr rbp procesoru a -20 je šestnáctkový posun od tohoto registru).

Druhý kořen je normální (silná), GCHandle která obsahuje odkaz na instanci test.Test třídy.

Třetí kořen je připnutý GCHandle. Tato proměnná je ve skutečnosti statická, ale bohužel neexistuje způsob, jak to říct. Statické objekty pro odkazové typy jsou uloženy ve spravovaném poli objektů v interních strukturách modulu runtime.

Další případ, který může zabránit uvolnění je AssemblyLoadContext , když vlákno má rámec metody ze sestavení načteno do jeho zásobníku AssemblyLoadContext . Můžete to zkontrolovat dumpingem spravovaných zásobníků volání všech vláken:

~*e !clrstack

Příkaz znamená "použít na všechna vlákna, která !clrstack příkaz". Následuje výstup tohoto příkazu pro příklad. LLDB v Unixu bohužel nemá žádný způsob, jak použít příkaz pro všechna vlákna, takže musíte ručně přepnout vlákna a opakovat clrstack příkaz. Ignorujte všechna vlákna, ve kterých ladicí program říká "Nejde procházet spravovaný zásobník".

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 vidíte, poslední vlákno má test.Program.ThreadProc(). Jedná se o funkci ze sestavení načteného AssemblyLoadContextdo a tak udržuje naživu AssemblyLoadContext .

Příklad zdrojového kódu

Následující kód, který obsahuje problémy s uvolněním, se používá v předchozím příkladu ladění.

Hlavní testovací program

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 načtený do testAssemblyLoadContext

Následující kód představuje test.dll předán metodě ExecuteAndUnload v hlavním testovacím programu.

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