Så här använder och felsöker du sammansättningens lossningsbarhet i .NET

.NET (Core) introducerade möjligheten att läsa in och senare ta bort en uppsättning sammansättningar. I .NET Framework användes anpassade appdomäner för detta ändamål, men .NET (Core) stöder bara en enda standardappdomän.

Avlastningsbarhet stöds genom AssemblyLoadContext. Du kan läsa in en uppsättning sammansättningar i en samlarbar AssemblyLoadContext, köra metoder i dem eller bara inspektera dem med reflektion och slutligen ta bort AssemblyLoadContext. Det tar bort de sammansättningar som läses in i AssemblyLoadContext.

Det finns en anmärkningsvärd skillnad mellan avlastning med hjälp av AssemblyLoadContext och användning av AppDomains. Med AppDomains tvingas avladdningen fram. Vid avlastning avbryts alla trådar som körs i målappdomänen, hanterade COM-objekt som skapas i målappdomänen förstörs och så vidare. Med AssemblyLoadContextär lossningen "kooperativ". AssemblyLoadContext.Unload När du anropar metoden initieras just avlastningen. Avlastningen avslutas efter:

  • Inga trådar har metoder från sammansättningarna som läses in i AssemblyLoadContext på deras anropsstackar.
  • Ingen av typerna från sammansättningarna som läses in i AssemblyLoadContext, instanser av dessa typer och själva sammansättningarna refereras av:

Använd collectible AssemblyLoadContext

Det här avsnittet innehåller en detaljerad steg-för-steg handledning som visar ett enkelt sätt att ladda ett .NET Core-program i en samlingsbar AssemblyLoadContext, köra dess startpunkt och sedan avladda det. Du hittar ett fullständigt exempel på https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.

Skapa en samlarbar sammansättningLoadContext

Härled din klass från AssemblyLoadContext och åsidosätt dess AssemblyLoadContext.Load metod. Den metoden löser referenser till alla sammansättningar som är beroenden av sammansättningar som läses in i den AssemblyLoadContext.

Följande kod är ett exempel på den enklaste anpassade AssemblyLoadContext:

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

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

Som du ser returnerar metoden nullLoad. Det innebär att alla beroendesammansättningar läses in i standardkontexten, och den nya kontexten innehåller endast de sammansättningar som uttryckligen läses in i den.

Om du vill läsa in vissa eller alla beroenden i AssemblyLoadContext också kan du använda AssemblyDependencyResolver i Load -metoden. AssemblyDependencyResolver Upplöser assemblénamn till absoluta assemblyfilsökvägar. Resolvern använder .deps.json-filen och assembliefilerna i katalogen för huvudsammansättningen som läses in i kontexten.

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

Använd en anpassad samlingsbar AssemblyLoadContext

Det här avsnittet förutsätter att den enklare versionen av TestAssemblyLoadContext används.

Du kan skapa en instans av den anpassade AssemblyLoadContext och ladda ett assembly i det på följande sätt:

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

För var och en av de sammansättningar som refereras av den inlästa sammansättningen TestAssemblyLoadContext.Load anropas metoden så att TestAssemblyLoadContext kan bestämma var sammansättningen ska hämtas från. I det här fallet returnerar null för att indikera att det ska läsas in i standardkontexten från platser som runtime använder för att ladda assemblies som standard.

Nu när en sammansättning har lästs in kan du köra en metod från den. Kör Main-metoden:

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

När metoden Main har returnerat kan du initiera avlastning genom att antingen anropa Unload på den anpassade AssemblyLoadContext eller ta bort den referens du har till AssemblyLoadContext.

alc.Unload();

Detta räcker för att lossa testsammansättningen. Därefter placerar du allt detta i en separat icke-inlinebar metod för att säkerställa att TestAssemblyLoadContext, Assembly, och MethodInfo (Assembly.EntryPoint) inte kan hållas vid liv av stackplatser referenser (verkliga eller JIT-introducerade lokaler). Det kan hålla liv i TestAssemblyLoadContext och förhindra lossningen.

Returnera också en svag referens till AssemblyLoadContext så att du kan använda den senare för att upptäcka avlastningens slutförande.

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

Nu kan du köra den här funktionen för att läsa in, köra och avlasta assemblyn.

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

Avlastningen slutförs dock inte omedelbart. Som tidigare nämnts förlitar den sig på skräpinsamlaren för att samla in alla objekt från testsammansättningen. I många fall är det inte nödvändigt att vänta tills avlastningen har slutförts. Det finns dock fall där det är användbart att veta att avlastningen har slutförts. Du kanske till exempel vill ta bort assembly-filen som har laddats in i den anpassade AssemblyLoadContext från disken. I sådana fall kan följande kodfragment användas. Den utlöser skräpinsamling och väntar på väntande slutförare i en loop tills den svaga referensen till den anpassade AssemblyLoadContext är inställd på , vilket anger att nullmålobjektet samlades in. I de flesta fall krävs bara ett varv genom slingan. För mer komplexa fall där objekt som skapas av koden som körs i AssemblyLoadContext har finalizers kan det dock behövas fler pass.

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

Begränsningar

Sammansättningar som läses in i ett samlingsobjekt AssemblyLoadContext måste följa de allmänna begränsningarna för samlarbara sammansättningar. Följande begränsningar gäller även:

Avlastningshändelse

I vissa fall kan det vara nödvändigt att koden läses in i en anpassad AssemblyLoadContext för att genomföra viss rensning när avladdningen initieras. Den kan till exempel behöva stoppa trådar eller rensa starka GC-handtag. Händelsen Unloading kan användas i sådana fall. Du kan koppla en hanterare som utför den nödvändiga rensningen till den här händelsen.

Felsöka problem med olastbarhet

På grund av lossningens samarbetskaraktär är det lätt att glömma referenser som kan hålla grejerna i ett samlarobjekt AssemblyLoadContext vid liv och förhindra lossning. Här är en sammanfattning av entiteter (några av dem icke uppenbara) som kan innehålla referenser:

  • Vanliga referenser som lagras utanför det kollektibla objektet AssemblyLoadContext som lagras i ett stackfack eller ett processorregister (metodlokalt, antingen uttryckligen skapat av användarkoden eller implicit av JIT-kompilatorn (just-in-time)), en statisk variabel eller ett starkt (fästande) GC-handtag och transitivt pekar på:
    • En sammansättning som laddas in i samlarobjektet AssemblyLoadContext.
    • En typ från en sådan sammansättning.
    • En instans av en typ från en sådan sammansättning.
  • Trådar som kör kod från en sammansättning som läses in i den samlarbara AssemblyLoadContext.
  • Instanser av anpassade, icke-insamlingsbara AssemblyLoadContext typer som skapats inom den insamlingsbara AssemblyLoadContext.
  • Väntande RegisteredWaitHandle instanser med återanrop inställda på metoder i den anpassade AssemblyLoadContext.
  • Fält i din anpassade AssemblyLoadContext underklass som refererar till sammansättningar, typer eller instanser av typer som läses in i den samlarbara AssemblyLoadContext. När avladdning pågår håller körningen ett starkt GC-handtag till AssemblyLoadContext för att samordna avladdningen. Det innebär att GC inte samlar in dessa fältreferenser även efter att du har tagit bort din egen referens till AssemblyLoadContext. Rensa fälten så att avlastningen kan slutföras.

Tips/Råd

Objektreferenser som lagras i stackfack eller processorregister och som kan förhindra avlastning av en AssemblyLoadContext kan inträffa i följande situationer:

  • När funktionsanropsresultat skickas direkt till en annan funktion, även om det inte finns någon lokal variabel som skapats av användaren.
  • När JIT-kompilatorn behåller en referens till ett objekt som var tillgängligt någon gång i en metod.

Problem med att felsöka avlastning

Det kan vara mödosamt att felsöka problem med avladdning. Du kan hamna i situationer där du inte vet vad som kan hålla en AssemblyLoadContext vid liv, men avlastningen misslyckas. Det bästa verktyget för att hjälpa till med det är WinDbg (eller LLDB på Unix) med SOS-plugin-programmet. Du måste ta reda på vad som håller en LoaderAllocator som tillhör den specifika AssemblyLoadContext igång. Med SOS-plugin-programmet kan du titta på GC-heapobjekt, deras hierarkier och rötter.

Om du vill läsa in SOS-plugin-programmet i felsökningsprogrammet anger du något av följande kommandon på kommandoraden för felsökningsprogrammet.

I WinDbg (om det inte redan har lästs in):

.loadby sos coreclr

I LLDB:

plugin load /path/to/libsosplugin.so

Nu ska du felsöka ett exempelprogram som har problem med avlastning. Källkoden är tillgänglig i avsnittet Exempel på källkod . När du kör den under WinDbg går programmet in i felsökningsprogrammet direkt efter att ha försökt kontrollera om avlastningen lyckades. Du kan sedan börja leta efter de skyldiga.

Tips/Råd

Om du felsöker med LLDB på Unix har SOS-kommandona i följande exempel inte ! framför sig.

!dumpheap -type LoaderAllocator

Det här kommandot dumpar alla objekt med ett typnamn som innehåller LoaderAllocator i GC-heapen. Här är ett exempel:

         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

I delen "Statistik:" kontrollera MT (MethodTable) som tillhör System.Reflection.LoaderAllocator, vilket är det objekt du är intresserad av. Sedan, i listan i början, hittar du posten med MT som matchar det och hämtar objektets adress. I det här fallet är det "000002b78000ce40".

Nu när du känner till objektets LoaderAllocator adress kan du använda ett annat kommando för att hitta dess GC-rötter:

!gcroot 0x000002b78000ce40

Det här kommandot dumpar kedjan med objektreferenser som leder till instansen LoaderAllocator . Listan börjar med roten, som är entiteten som håller liv i LoaderAllocator och därmed är kärnan i problemet. Roten kan vara en stackplats, ett processorregister, ett GC-handtag eller en statisk variabel.

Här är ett exempel på utdata från gcroot kommandot:

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.

Nästa steg är att ta reda på var roten finns så att du kan åtgärda den. Det enklaste fallet är när roten är en stackplats eller ett processorregister. I så fall gcroot visar namnet på den funktion vars ram innehåller roten och tråden som kör den funktionen. Det svåra fallet är när roten är en statisk variabel eller ett GC-handtag.

I föregående exempel är den första roten en lokal typ System.Reflection.RuntimeMethodInfo som lagras i ramen för funktionen example.Program.Main(System.String[]) på adressen rbp-20 (rbp är processorregistret rbp och -20 är en hexadecimal förskjutning från det registret).

Den andra roten är en normal (stark) GCHandle som innehåller en referens till en instans av test.Test klassen.

Den tredje roten är en fastnålad GCHandle. Den här är faktiskt en statisk variabel, men tyvärr finns det inget sätt att säga. Statiska värden för referenstyper lagras i en hanterad objektmatris i interna körningsstrukturer.

Ett annat fall som kan förhindra avlastning av en AssemblyLoadContext är när en tråd har en ram av en metod från en sammansättning som läses in i stacken AssemblyLoadContext . Du kan kontrollera detta genom att dumpa hanterade anropsstackar för alla trådar:

~*e !clrstack

Kommandot betyder "tillämpa !clrstack-kommandot på alla trådar". Följande är utdata från kommandot för exemplet. Tyvärr har INTE LLDB på Unix något sätt att tillämpa ett kommando på alla trådar, så du måste växla trådar manuellt och upprepa clrstack kommandot. Ignorera alla trådar där felsökaren säger "Det går inte att genomföra den hanterade stacken".

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]

Som du ser, har den sista tråden test.Program.ThreadProc(). Det här är en funktion från den monteringskod som är laddad i AssemblyLoadContext, och därför hålls AssemblyLoadContext vid liv.

Exempel på källkod

Följande kod som innehåller problem med avlastning används i föregående felsökningsexempel.

Huvudtestningsprogram

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

Programmet läses in i TestAssemblyLoadContext

Följande kod representerar test.dll som skickas till ExecuteAndUnload metoden i huvudtestprogrammet.

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