Cara menggunakan dan men-debug unloadability assembly di .NET

.NET (Core) memperkenalkan kemampuan untuk memuat dan kemudian membongkar satu set rakitan. Di .NET Framework, domain aplikasi kustom digunakan untuk tujuan ini, tetapi .NET (Core) hanya mendukung satu domain aplikasi default.

Unloadability didukung melalui AssemblyLoadContext. Anda dapat memuat satu set assembly ke dalam metode yang dapat AssemblyLoadContextdikumpulkan, menjalankan metode di dalamnya atau hanya memeriksanya menggunakan refleksi, dan akhirnya membongkar AssemblyLoadContext. Yang membongkar assembly yang dimuat ke dalam AssemblyLoadContext.

Ada satu perbedaan penting antara pembongkaran menggunakan AssemblyLoadContext dan menggunakan AppDomains. Dengan AppDomains, pembongkaran dipaksa. Pada waktu unload, semua utas yang berjalan di AppDomain target dibatalkan, objek COM terkelola yang dibuat di AppDomain target dihancurkan, dan sebagainya. Dengan AssemblyLoadContext, bongkar adalah "kooperatif". Memanggil metode AssemblyLoadContext.Unload hanya memulai pembongkaran. Pembongkaran selesai setelah:

  • Tidak ada utas yang memiliki metode dari assembly yang dimuat ke AssemblyLoadContext dalam tumpukan panggilan mereka.
  • Tidak ada jenis dari rakitan yang dimuat ke dalam , instans jenis tersebut AssemblyLoadContext, dan rakitan itu sendiri direferensikan oleh:

Menggunakan AssemblyLoadContext yang dapat dikumpulkan

Bagian ini berisi tutorial langkah demi langkah terperinci yang menunjukkan cara sederhana untuk memuat aplikasi .NET (Core) ke dalam collectible AssemblyLoadContext, jalankan titik masuknya, lalu bongkar. Anda dapat menemukan sampel lengkap di https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading.

Membuat AssemblyLoadContext yang dapat dikumpulkan

Dapatkan kelas Anda dari AssemblyLoadContext dan ambil alih metodenya AssemblyLoadContext.Load . Metode itu menyelesaikan referensi ke semua assembly yang merupakan dependensi assembly yang dimuat ke dalamnya AssemblyLoadContext.

Kode berikut adalah contoh kustom AssemblyLoadContextpaling sederhana :

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

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

Seperti yang dapat Anda lihat, metode Load mengembalikan null. Itu berarti bahwa semua assembly dependensi dimuat ke dalam konteks default, dan konteks baru hanya berisi assembly yang dimuat secara eksplisit ke dalamnya.

Jika Anda ingin memuat beberapa atau semua dependensi ke AssemblyLoadContext dalamnya juga, Anda dapat menggunakan AssemblyDependencyResolver dalam metode .Load AssemblyDependencyResolver menyelesaikan nama assembly ke jalur file assembly absolut. Resolver menggunakan file .deps.json dan file assembly di direktori assembly utama yang dimuat ke dalam konteks.

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

Menggunakan AssemblyLoadContext yang dapat dikumpulkan kustom

Bagian ini mengasumsikan versi yang lebih sederhana dari TestAssemblyLoadContext sedang digunakan.

Anda dapat membuat instans kustom AssemblyLoadContext dan memuat assembly ke dalamnya sebagai berikut:

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

Untuk setiap assembly yang direferensikan oleh assembly yang dimuat, TestAssemblyLoadContext.Load metode dipanggil sehingga TestAssemblyLoadContext dapat memutuskan dari mana assembly mendapatkan assembly. Dalam hal ini, ia kembali null untuk menunjukkan bahwa itu harus dimuat ke dalam konteks default dari lokasi yang digunakan runtime untuk memuat rakitan secara default.

Sekarang setelah assembly dimuat, Anda dapat menjalankan metode darinya. Menjalankan metode Main:

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

Main Setelah metode kembali, Anda dapat memulai pembongkaran dengan memanggil Unload metode pada kustom AssemblyLoadContext atau menghapus referensi yang Anda miliki ke AssemblyLoadContext:

alc.Unload();

Ini cukup untuk membongkar assembly pengujian. Selanjutnya, Anda akan memasukkan semua ini ke dalam metode terpisah yang tidak dapat dibariskan untuk memastikan bahwa TestAssemblyLoadContext, , Assemblydan MethodInfo ( Assembly.EntryPoint) tidak dapat tetap hidup dengan referensi slot tumpukan (lokal yang diperkenalkan nyata atau JIT). Itu bisa membuat TestAssemblyLoadContext tetap hidup dan mencegah pembongkaran.

Selain itu, kembalikan referensi yang lemah ke AssemblyLoadContext sehingga Anda dapat menggunakannya nanti untuk mendeteksi penyelesaian pembongkaran.

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

Sekarang Anda dapat menjalankan fungsi ini untuk memuat, menjalankan, dan membongkar assembly.

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

Namun, pembongkaran tidak segera selesai. Seperti disebutkan sebelumnya, mengandalkan pengumpul sampah untuk mengumpulkan semua objek dari assembly pengujian. Dalam banyak kasus, tidak perlu menunggu penyelesaian bongkar muatan. Namun, ada kasus di mana berguna untuk mengetahui bahwa bongkar telah selesai. Misalnya, Anda mungkin ingin menghapus file rakitan yang dimuat ke dalam kustom AssemblyLoadContext dari disk. Dalam kasus seperti itu, cuplikan kode berikut dapat digunakan. Ini memicu pengumpulan sampah dan menunggu finalizer yang tertunda dalam perulangan sampai referensi lemah ke kustom AssemblyLoadContext diatur ke null, menunjukkan objek target dikumpulkan. Dalam kebanyakan kasus, hanya satu lewati perulangan yang diperlukan. Namun, untuk kasus yang lebih kompleks di mana objek yang dibuat oleh kode yang AssemblyLoadContext berjalan di memiliki finalizer, lebih banyak pass mungkin diperlukan.

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

Batasan

Rakitan yang dimuat dalam koleksi AssemblyLoadContext harus mematuhi pembatasan umum pada rakitan yang dapat dikumpulkan. Batasan berikut juga berlaku:

Kejadian Pembongkaran

Dalam beberapa kasus, mungkin perlu bagi kode yang dimuat ke dalam kustom AssemblyLoadContext untuk melakukan pembersihan saat pembongkaran dimulai. Misalnya, mungkin perlu menghentikan utas atau membersihkan handel GC yang kuat. Kejadian Unloading dapat digunakan dalam kasus semacam itu. Anda dapat menghubungkan handler yang melakukan pembersihan yang diperlukan untuk kejadian ini.

Memecahkan masalah pembongkaran

Karena sifat kooperatif dari pembongkaran, mudah untuk melupakan referensi yang mungkin menjaga barang-barang dalam kolektif AssemblyLoadContext hidup dan mencegah pembongkaran. Berikut adalah ringkasan entitas (beberapa di antaranya tidak berbahaya) yang dapat menyimpan referensi:

  • Referensi reguler disimpan dari luar koleksi AssemblyLoadContext yang disimpan dalam slot tumpukan atau daftar prosesor (metode lokal, baik yang dibuat secara eksplisit oleh kode pengguna atau secara implisit oleh pengkompilasi just-in-time (JIT), variabel statis, atau handel GC yang kuat (menyematkan), dan secara transitif menunjuk ke:
    • Assembly yang dimuat ke dalam AssemblyLoadContext yang dapat dikumpulkan.
    • Jenis dari assembly seperti itu.
    • Instans jenis dari assembly tersebut.
  • Utas yang menjalankan kode dari assembly yang dimuat ke dalam AssemblyLoadContext yang dapat dikumpulkan.
  • Instans jenis kustom dan tidak dapat diungkapkan AssemblyLoadContext yang dibuat di dalam koleksi AssemblyLoadContext.
  • Instans tertunda RegisteredWaitHandle dengan panggilan balik diatur ke metode dalam kustom AssemblyLoadContext.

Tip

Referensi objek yang disimpan dalam slot tumpukan atau register prosesor dan yang dapat mencegah pembongkaran AssemblyLoadContext dapat terjadi dalam situasi berikut:

  • Saat hasil panggilan fungsi diteruskan langsung ke fungsi lain, meski tidak ada variabel lokal yang dibuat pengguna.
  • Saat pengompilasi JIT menyimpan referensi ke objek yang tersedia di beberapa titik dalam metode.

Masalah pembongkaran debug

Masalah penelusuran kesalahan dengan pembongkaran bisa membosankan. Anda bisa masuk ke situasi di mana Anda tidak tahu apa yang bisa menahan AssemblyLoadContext hidup, tetapi pembongkaran gagal. Alat terbaik untuk membantu dengan itu adalah WinDbg (atau LLDB di Unix) dengan plugin SOS. Anda perlu menemukan apa yang menjaga LoaderAllocator yang milik spesifik AssemblyLoadContext hidup. Plugin SOS memungkinkan Anda melihat objek tumpukan GC, hierarkinya, dan akarnya.

Untuk memuat plugin SOS ke debugger, masukkan salah satu perintah berikut di baris perintah debugger.

Di WinDbg (jika belum dimuat):

.loadby sos coreclr

Dalam LLDB:

plugin load /path/to/libsosplugin.so

Sekarang Anda akan men-debug contoh program yang memiliki masalah dengan pembongkaran. Kode sumber tersedia di bagian Contoh kode sumber. Saat Anda menjalankannya di bawah WinDbg, program memecah ke debugger tepat setelah mencoba memeriksa keberhasilan pembongkaran. Anda kemudian dapat mulai mencari pelakunya.

Tip

Jika Anda men-debug menggunakan LLDB di Unix, perintah SOS dalam contoh berikut tidak memiliki ! di depannya.

!dumpheap -type LoaderAllocator

Perintah ini mencadangkan semua objek dengan nama jenis yang berisi LoaderAllocator yang ada di tumpukan GC. Berikut contohnya:

         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

Di bagian "Statistik:", periksa MT (MethodTable) milik System.Reflection.LoaderAllocator, yang merupakan objek yang Anda pedulikan. Kemudian, dalam daftar di awal, temukan entri dengan yang cocok dengan MT yang satu itu, dan dapatkan alamat objek itu sendiri. Dalam hal ini, itu adalah "000002b78000ce40".

Sekarang setelah Anda mengetahui alamat LoaderAllocator objek, Anda dapat menggunakan perintah lain untuk menemukan akar GC-nya:

!gcroot 0x000002b78000ce40

Perintah ini mencadangkan rantai referensi objek yang mengarah ke instans LoaderAllocator. Daftar dimulai dengan akar, yang merupakan entitas yang menjaga LoaderAllocator tetap hidup dan dengan demikian adalah inti dari masalah. Akar dapat berupa slot tumpukan, register prosesor, handel GC, atau variabel statis.

Berikut adalah contoh output gcroot perintah:

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.

Langkah selanjutnya adalah mencari tahu di mana akar berada sehingga Anda dapat memperbaikinya. Kasus termudah adalah saat akar adalah slot tumpukan atau register prosesor. Dalam hal ini, gcroot menunjukkan nama fungsi yang bingkainya berisi akar dan utas yang menjalankan fungsi tersebut. Kasus yang sulit adalah saat akar adalah variabel statis atau handel GC.

Dalam contoh sebelumnya, akar pertama adalah lokal dari jenis System.Reflection.RuntimeMethodInfo yang disimpan dalam bingkai fungsi example.Program.Main(System.String[]) di alamat rbp-20 (rbp adalah register rbp prosesor dan -20 adalah offset heksadesimal dari register tersebut).

Akar kedua adalah normal (kuat) GCHandle yang menyimpan referensi ke instans kelas test.Test.

Akar ketiga GCHandle disematkan. Yang satu ini sebenarnya adalah variabel statis, tetapi sayangnya, tidak ada cara untuk mengetahuinya. Statis untuk jenis referensi disimpan dalam array objek terkelola dalam struktur runtime bahasa umum internal.

Kasus lain yang dapat mencegah pembongkaran AssemblyLoadContext adalah saat utas memiliki bingkai metode dari assembly yang dimuat ke dalam AssemblyLoadContext tumpukannya. Anda dapat memeriksanya dengan mencadangkan tumpukan panggilan terkelola dari semua utas:

~*e !clrstack

Perintah berarti "berlaku untuk semua utas perintah !clrstack". Berikut ini adalah output dari perintah tersebut untuk contoh. Sayangnya, LLDB di Unix tidak memiliki cara apa pun untuk menerapkan perintah ke semua utas, jadi Anda harus beralih utas secara manual dan mengulangi clrstack perintah. Abaikan semua utas di mana debugger mengatakan "Tidak dapat berjalan di tumpukan terkelola".

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]

Seperti yang Anda lihat, utas terakhir memiliki test.Program.ThreadProc(). Ini adalah fungsi dari assembly yang dimuat ke dalam AssemblyLoadContext, dan sehingga menjaga AssemblyLoadContext tetap hidup.

Contoh kode sumber

Kode berikut yang berisi masalah unloadability digunakan dalam contoh penelusuran kesalahan sebelumnya.

Program pengujian utama

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 yang dimuat ke dalam TestAssemblyLoadContext

Kode berikut mewakili test.dll yang diteruskan ke metode ExecuteAndUnload dalam program pengujian utama.

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