Szerelvény eltávolíthatóságának használata és hibakeresése a .NET-ben

A .NET (Core) lehetővé teszi az assemblyk betöltését és későbbi eltávolítását. A .NET-keretrendszerben egyéni alkalmazástartományokat használtak erre a célra, de a .NET (Core) csak egyetlen alapértelmezett alkalmazástartományt támogat.

A kiüríthetőséget a AssemblyLoadContext támogatja. Szerelvénykészletet betölthet egy gyűjthető AssemblyLoadContext-be, végrehajthat bennük metódusokat, vagy megvizsgálhatja őket reflexió használatával, és végül eltávolíthatja a AssemblyLoadContext-et. Ez eltávolítja a berakott szerelvényeket a AssemblyLoadContext.

Van egy figyelemre méltó különbség a AssemblyLoadContext-val történő kirakodás és az AppDomains használatával végzett kirakodás között. Az AppDomains használatával a kiürítés kényszerítve van. A kiürítéskor a cél AppDomainben futó összes szál megszakad, a cél AppDomainben létrehozott felügyelt COM-objektumok el lesznek pusztítva, és így tovább. Ezzel AssemblyLoadContexta kipakolás "kooperatív". A AssemblyLoadContext.Unload metódus meghívása csak a betöltés megszüntetésének folyamatát kezdeményezi. A kiürítés a következő után fejeződik be:

  • Egyetlen szál sem rendelkezik metódusokkal a hívásverembe AssemblyLoadContext betöltött szerelvényekből.
  • Az ilyen típusú szerelvényekbe betöltött AssemblyLoadContextszerelvények egyik típusára sem, és maguk a szerelvényekre a következők hivatkoznak:

Collectible AssemblyLoadContext használata

Ez a szakasz részletes oktatóanyagot tartalmaz, amely bemutatja, hogyan tölthet be egy .NET (Core) alkalmazást egy gyűjthetőbe AssemblyLoadContext, végrehajthatja belépési pontját, majd kirakhatja azt. A teljes minta a következő helyen https://github.com/dotnet/samples/tree/main/core/tutorials/Unloadingtalálható: .

Gyűjthető AssemblyLoadContext létrehozása

Származtassa az osztályát a AssemblyLoadContext-ból, és bírálja felül annak AssemblyLoadContext.Load metódusát. Ez a módszer feloldja az összes olyan szerelvényre mutató hivatkozást, amely az ebbe AssemblyLoadContextbetöltött szerelvények függőségei.

Az alábbi kód a legegyszerűbb egyéni AssemblyLoadContextpélda:

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

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

Mint látható, a Load metódus ad vissza null. Ez azt jelenti, hogy az összes függőségi szerelvény betöltődik az alapértelmezett környezetbe, és az új környezet csak a kifejezetten betöltött szerelvényeket tartalmazza.

Ha a függőségek egy részét vagy egészét is be szeretné tölteni a AssemblyLoadContext-ba, használhatja a AssemblyDependencyResolver-t a Load metódusban. A AssemblyDependencyResolver a szerelvényneveket abszolút szerelvényfájl-elérési utakra oldja fel. A feloldó a .deps.json fájlt és a szerelvényfájlokat használja a kontextusba betöltött főszerelvény könyvtárában.

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

Saját gyűjthető AssemblyLoadContext használata

Ez a szakasz feltételezi, hogy a rendszer a TestAssemblyLoadContext használt egyszerűbb verziót használja.

Létrehozhatja az egyéni AssemblyLoadContext példányt, és az alábbiak szerint tölthet be egy assembly-t:

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

A rendszer meghívja a TestAssemblyLoadContext.Load metódust a betöltött összeállítás által hivatkozott összeállítások mindegyikére annak érdekében, hogy a TestAssemblyLoadContext eldönthesse, honnan szerezze be az összeállítást. Ebben az esetben null értéket ad vissza annak jelzésére, hogy azt az alapértelmezett környezetbe kell betölteni, olyan helyekről, amelyeket a futtatókörnyezet alapértelmezés szerint a szerelvények betöltésére használ.

Most, hogy egy assembly betöltődött, végrehajthat belőle egy metódust. Futtassa a(z) Main metódust.

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

A metódus visszatérése után kezdeményezheti a Main kirakodást, ha meghívja a Unload metódust a testreszabott AssemblyLoadContext-re, vagy eltávolítja a AssemblyLoadContext-ra mutató hivatkozást.

alc.Unload();

Ez elegendő a tesztszerelvény kipakolásához. Ezután mindezt egy külön, nem elválasztható metódusba helyezi, amely biztosítja, hogy a TestAssemblyLoadContext, Assemblyés MethodInfo (a Assembly.EntryPoint) nem tartható életben veremhelyhivatkozásokkal (valós vagy JIT által bevezetett helyiek). Ez életben tarthatja az TestAssemblyLoadContext embert, és megakadályozhatja a kirakodást.

Emellett adjon vissza egy gyenge hivatkozást a AssemblyLoadContext fájlra, hogy később használhassa a kipakolás befejezésének észleléséhez.

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

Most futtathatja ezt a függvényt a szerelvény betöltéséhez, végrehajtásához és eltávolításához.

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

A kirakodás azonban nem fejeződik be azonnal. Mint korábban említettük, a szemétgyűjtőre támaszkodik, hogy összegyűjtse az összes objektumot a tesztszerelvényből. Sok esetben nem szükséges megvárni a kiürítés befejezését. Vannak azonban olyan esetek, amikor hasznos tudni, hogy a kirakodás befejeződött. Előfordulhat például, hogy törölni szeretné a testreszabott AssemblyLoadContext-be betöltött assembly-fájlt a lemezről. Ilyen esetben a következő kódrészlet használható. Elindítja a szemétgyűjtést, és várakozik a függőben lévő véglegesítőkre egy hurokban, amíg az egyénire AssemblyLoadContext mutató gyenge hivatkozás be nem van állítva null, jelezve, hogy a célobjektum összegyűjtve lett. A legtöbb esetben csak egy áthaladás szükséges a hurokon. Összetettebb esetekben azonban, amikor a kód AssemblyLoadContext által létrehozott objektumok véglegesítőkkel rendelkeznek, több passzra lehet szükség.

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

Korlátozások

A gyűjthető szerelvényekre AssemblyLoadContext szerelvényekre vonatkozó általános korlátozásoknak. A következő korlátozások is érvényesek:

  • A C++/CLI nyelven írt szerelvények nem támogatottak.
  • A ReadyToRun által létrehozott kód figyelmen kívül lesz hagyva.

A kiürítési esemény

Bizonyos esetekben szükséges lehet, hogy a kód, amely egy egyéni AssemblyLoadContext-ba van betöltve, végrehajtson némi tisztítást, amikor a betöltés leállítása megkezdődik. Előfordulhat például, hogy le kell állítania a szálakat, vagy törölnie kell az erős GC-fogantyúkat. Ilyen Unloading esetekben az esemény használható. Az eseményhez csatlakoztathat egy kezelőt, amely elvégzi a szükséges tisztítást.

Betölthetetlenségi problémák elhárítása

A kirakodás kooperatív jellege miatt könnyű elfelejteni azokat a hivatkozásokat, amelyek életben tartják a cuccot egy gyűjtőben AssemblyLoadContext , és megakadályozzák a kirakodást. Íme az entitások összegzése (amelyek némelyike nem látható), amelyek tartalmazhatják a hivatkozásokat:

  • A gyűjtőn AssemblyLoadContext kívülről tárolt rendszeres hivatkozások, amelyek egy veremhelyen vagy egy processzorregisztrálóban vannak tárolva (a metódus helyi beállításai, amelyeket explicit módon a felhasználói kód hoz létre, vagy implicit módon a just-in-time (JIT) fordító), egy statikus változó vagy egy erős (rögzítési) GC-leíró, és tranzitív módon a következőkre mutat:
    • A gyűjtőbe AssemblyLoadContextbetöltött szerelvény.
    • Egy ilyen szerelvény típusa.
    • Egy ilyen szerelvényből származó típuspéldány.
  • A gyűjtőbe AssemblyLoadContextbetöltött szerelvényből kódot futtató szálak.
  • Az összegyűjthető AssemblyLoadContext keretein belül létrehozott egyéni, nem összegyűjthető AssemblyLoadContext típusok példányai.
  • Függőben lévő RegisteredWaitHandle példányok, amelyeknél a visszahívások testreszabott AssemblyLoadContext metódusokra vannak beállítva.
  • Az egyéni AssemblyLoadContext alosztály mezői, amelyek a betölthető AssemblyLoadContext-be betöltött szerelvényekre, típusokra vagy típuspéldányokra hivatkoznak. Amíg a kirakodás folyamatban van, a futtatókörnyezet egy erős GC hivatkozást tart a AssemblyLoadContext kirakodásának koordinálásához. Ez azt jelenti, hogy a memóriakezelő nem fogja gyűjteni ezeket a mezőreferenciákat, még akkor sem, ha a saját hivatkozását elveti a AssemblyLoadContext. Szabadítsa fel ezeket a mezőket, hogy a kiürítés befejeződhessen.

Jótanács

Veremhelyeken vagy a processzor regisztereiben tárolt objektumhivatkozások, amelyek megakadályozhatják egy AssemblyLoadContext eltávolítását, a következő helyzetekben fordulhatnak elő:

  • Ha a függvényhívás eredményei közvetlenül egy másik függvénynek lesznek átadva, annak ellenére, hogy nincs felhasználó által létrehozott helyi változó.
  • Amikor a JIT-fordító egy olyan objektumra mutat, amely egy metódus egy bizonyos pontján elérhető volt.

Kirakodási problémák hibakeresése

A kiürítéssel kapcsolatos hibakeresési problémák fárasztóak lehetnek. Keresztülmehet olyan helyzeteken, amikor nem tudja, mi tarthat egy AssemblyLoadContext-t életben, de a kiürítés meghiúsul. A legjobb eszköz, ami segíthet abban, az a WinDbg (vagy a Unix rendszereken az LLDB) az SOS bővítménnyel. Meg kell találnia, mi tartja életben a meghatározott AssemblyLoadContext-hez tartozó LoaderAllocator-t. Az SOS beépülő modul segítségével áttekintheti a GC halomobjektumokat, azok hierarchiáit és gyökereit.

Az SOS beépülő modul hibakeresőbe való betöltéséhez írja be az alábbi parancsok egyikét a hibakereső parancssorába.

WinDbg-ben (ha még nincs betöltve):

.loadby sos coreclr

LLDB-ben:

plugin load /path/to/libsosplugin.so

Most egy olyan példaprogramot fog hibakeresésre végezni, amely problémákat tapasztal a kiürítéssel kapcsolatban. A forráskód a Példa forráskód szakaszban érhető el. Amikor a WinDbg alatt futtatja, a program közvetlenül azután tör be a hibakeresőbe, hogy megkísérelte ellenőrizni a törlés sikerességét. Ezután elkezdheti keresni a bűnösöket.

Jótanács

Ha a Unix operációs rendszer használatával hibakeresést végez az LLDB-ben, az alábbi példák SOS-parancsai nem rendelkeznek !-val előttük.

!dumpheap -type LoaderAllocator

Ez a parancs a GC-halomtárban található típusnévvel rendelkező LoaderAllocator összes objektumot kiírja. Íme egy példa:

         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

A "Statisztika:" részben ellenőrizze a MTMethodTable() objektumot, amely a System.Reflection.LoaderAllocatorfontos objektumhoz tartozik. Ezután az elején található listában keresse meg azt a bejegyzést, amely MT megfelel az adottnak, és kérje le magának az objektumnak a címét. Ebben az esetben ez "000002b78000ce40".

Most, hogy megismerte az LoaderAllocator objektum címét, egy másik paranccsal megkeresheti annak GC-gyökerét:

!gcroot 0x000002b78000ce40

Ez a parancs kiírja az objektumhivatkozások láncolatát, amely a LoaderAllocator példányhoz vezet. A lista a gyökérrel kezdődik, amely az az entitás, amely életben tartja a LoaderAllocator problémát, és így a probléma magja. A gyökér lehet a veremhely, a processzorregiszter, a GC fogantyú vagy a statikus változó.

Íme egy példa a parancs kimenetére 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.

A következő lépés annak kiderítése, hogy hol található a gyökér, hogy kijavíthassa. A legegyszerűbb eset az, ha a gyökér egy veremhely vagy egy processzorregiszter. Ebben az esetben a gcroot függvény neve jelenik meg, amelynek kerete tartalmazza a függvényt végrehajtó gyökér- és szálat. A nehéz eset az, ha a gyökér statikus változó vagy GC-leíró.

Az előző példában az első gyökér egy helyi System.Reflection.RuntimeMethodInfo típusú, amely a függvény example.Program.Main(System.String[]) keretében tárolódik, a rbp-20 címnél (rbp a processzor regisztere és -20 a hexadecimális eltolás ebből a regiszterből).

A második gyökér normál (erős) GCHandle, amely az test.Test osztály egy példányára mutató hivatkozást tartalmaz.

A harmadik gyökér egy rögzített GCHandle. Ez valójában egy statikus változó, de sajnos nem lehet megmondani. A referenciatípusok statikus elemeit egy felügyelt objektumtömb tárolja belső futtatókörnyezeti struktúrákban.

Egy másik eset, amely megakadályozhatja a kipakolást, AssemblyLoadContext ha egy szál egy metóduskerettel rendelkezik a verembe AssemblyLoadContext betöltött szerelvényből. Az összes szál felügyelt hívásveremének memóriaképét kiírva ellenőrizheti ezt:

~*e !clrstack

A parancs azt jelenti, hogy alkalmazza a !clrstack parancsot minden szálra. A példában a következő parancs kimenete látható. Sajnos a Unix LLDB-jének nincs módja arra, hogy parancsot alkalmazzon az összes szálra, ezért manuálisan kell váltania a szálakat, és meg kell ismételnie a clrstack parancsot. Hagyja figyelmen kívül azokat a szálakat, ahol a hibakereső azt jelzi, hogy "Nem lehet a kezelt veremhez hozzáférni".

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]

Mint látható, az utolsó szál van test.Program.ThreadProc(). Ez egy függvény a AssemblyLoadContext-be betöltött szerelvényből, és ez tartja életben a AssemblyLoadContext-t.

Példa forráskódra

Az előző hibakeresési példában a következő, eltávolíthatósági problémákat tartalmazó kódot használjuk.

Fő tesztelési 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}");
        }
    }
}

A TestAssemblyLoadContextbe betöltött program

Az alábbi kód a test.dll fájlt jelöli, amelyet a fő tesztelési programExecuteAndUnload metódusának adtak át.

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