.NET에서 어셈블리 언로드 기능을 사용하고 디버그하는 방법

.NET(Core)에는 어셈블리 집합을 로드하고 나중에 언로드하는 기능이 도입되었습니다. .NET Framework에서 사용자 지정 앱 도메인은 이러한 용도로 사용되지만 .NET(Core)에서는 단일 기본 앱 도메인만 지원합니다.

언로드 기능은 AssemblyLoadContext을 통해 지원됩니다. 어셈블리 집합을 수집 가능한 AssemblyLoadContext에 로드하고, 해당 어셈블리에서 메서드를 실행하거나 리플렉션을 사용하여 검사만 한 다음, 마지막으로 AssemblyLoadContext를 언로드할 수 있습니다. 그러면 AssemblyLoadContext에 로드된 어셈블리를 언로드합니다.

AssemblyLoadContext를 사용하여 언로드하는 것과 AppDomain을 사용하여 언로드하는 데는 중요한 차이점이 있습니다. AppDomain을 사용하면 언로드가 강제로 적용됩니다. 언로드 시 대상 AppDomain에서 실행 중인 모든 스레드가 중단되고 대상 AppDomain에서 만들어진 관리형 COM 개체가 제거됩니다. AssemblyLoadContext에서 언로드는 “협조적”입니다. AssemblyLoadContext.Unload 메서드를 호출하면 언로드를 시작하기만 합니다. 다음과 같은 경우 언로드가 완료됩니다.

  • 호출 스택에서 AssemblyLoadContext에 로드된 어셈블리의 메서드가 있는 스레드가 없습니다.
  • AssemblyLoadContext에 로드된 어셈블리의 유형, 해당 유형의 인스턴스 및 어셈블리 자체는 다음에서 참조하지 않습니다.

수집 가능한 AssemblyLoadContext 사용

이 섹션에는 .NET(Core) 애플리케이션을 수집 가능한 AssemblyLoadContext에 로드하고, 진입점을 실행한 다음, 언로드하는 간단한 방법을 보여주는 자세한 단계별 자습서가 포함되어 있습니다. https://github.com/dotnet/samples/tree/main/core/tutorials/Unloading에서 전체 샘플을 찾을 수 있습니다.

수집 가능한 AssemblyLoadContext 만들기

AssemblyLoadContext에서 클래스를 파생시키고 AssemblyLoadContext.Load 메서드를 재정의해야 합니다. 이 메서드는 해당 AssemblyLoadContext에 로드된 어셈블리의 종속 항목인 모든 어셈블리의 참조를 확인합니다.

다음 코드는 가장 간단한 사용자 지정 AssemblyLoadContext의 예입니다.

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

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

여기에서 볼 수 있듯이 Load 메서드는 null을 반환합니다. 즉, 모든 종속성 어셈블리가 기본 컨텍스트에 로드되고 새 컨텍스트에는 명시적으로 로드된 어셈블리만 포함됩니다.

종속 항목의 일부 또는 전체를 AssemblyLoadContext에 로드하려는 경우 Load 메서드에서 AssemblyDependencyResolver를 사용할 수 있습니다. AssemblyDependencyResolver는 어셈블리 이름을 절대 어셈블리 파일 경로로 확인합니다. 확인자는 컨텍스트에 로드된 주 어셈블리의 디렉터리에 있는 .deps.json 파일 및 어셈블리 파일을 사용합니다.

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

수집 가능한 사용자 지정 AssemblyLoadContext 사용

이 섹션에서는 TestAssemblyLoadContext의 간단한 버전을 사용한다고 가정합니다.

사용자 지정 AssemblyLoadContext의 인스턴스를 만들고 다음과 같이 어셈블리를 로드할 수 있습니다.

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

로드된 어셈블리에서 참조하는 각 어셈블리에 대해 TestAssemblyLoadContext.Load 메서드를 호출하므로, TestAssemblyLoadContext에서 어셈블리를 가져올 위치를 결정할 수 있습니다. 이 경우 null을(를) 반환하여, 런타임에서 기본적으로 어셈블리를 로드하는 데 사용하는 위치에서 기본 컨텍스트에 로드되어야 함을 나타냅니다.

이제 어셈블리가 로드되었으므로 여기에서 메서드를 실행할 수 있습니다. 다음과 같이 Main 메서드를 실행합니다.

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

Main 메서드가 리턴하고 나면 사용자 지정 AssemblyLoadContext에서 Unload 메서드를 호출하거나 AssemblyLoadContext에 대한 참조를 제거하여 언로드를 시작할 수 있습니다.

alc.Unload();

그러면 테스트 어셈블리를 언로드할 수 있습니다. TestAssemblyLoadContext, AssemblyMethodInfo(Assembly.EntryPoint)이(가) 스택 슬롯 참조를 통해 활성 상태를 유지할 수 없도록(real 또는 JIT 도입 로컬) 인라인화할 수 없는 개별 메서드로 이 모두를 통합해 보겠습니다. 그러면 TestAssemblyLoadContext를 활성 상태로 유지하고 언로드를 방지할 수 있습니다.

또한 나중에 언로드 완료를 검색하는 데 사용할 수 있도록 AssemblyLoadContext의 약한 참조를 반환합니다.

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

이제 이 함수를 실행하여 어셈블리를 로드, 실행 및 언로드할 수 있습니다.

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

그러나 언로드는 즉시 완료되지 않습니다. 앞에서 설명한 것처럼 가비지 수집기를 사용하여 테스트 어셈블리에서 모든 개체를 수집합니다. 대부분의 경우 언로드가 완료될 때까지 기다릴 필요가 없습니다. 그러나 언로드가 완료되었음을 확인하는 것이 유용한 경우도 있습니다. 예를 들어, 디스크에서 사용자 지정 AssemblyLoadContext로 로드된 어셈블리 파일을 삭제할 수 있습니다. 이 경우 다음 코드 조각을 사용할 수 있습니다. 이 코드 조각은 가비지 수집을 트리거하고 사용자 지정 AssemblyLoadContext에 대한 약한 참조가 null로 설정되어 대상 개체가 수집되었음을 나타낼 때까지 루프에서 보류 중인 종료자를 대기합니다. 대부분의 경우 루프를 한 번만 통과하면 됩니다. 그러나 AssemblyLoadContext에서 실행 중인 코드를 통해 만든 개체에 종료자가 있는 복잡한 경우에는 추가 통과가 필요합니다.

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

언로딩 이벤트

이 이벤트는 경우에 따라 언로드를 시작할 때 일부 정리를 수행하기 위해 사용자 지정 AssemblyLoadContext에 로드된 코드에 필요할 수 있습니다. 예를 들어 스레드를 중지하고, 강력한 GC 핸들을 정리해야 할 수 있습니다. 이 경우 Unloading 이벤트를 사용할 수 있습니다. 필요한 정리를 수행하는 처리기를 이 이벤트에 연결할 수 있습니다.

로드 불가능 문제 해결

언로드의 협조적 특성 때문에 수집 가능한 AssemblyLoadContext의 요소가 활성 상태를 유지하게 하고 언로드를 방지할 수 있는 참조를 잊기 쉽습니다. 다음은 참조를 보유할 수 있는 엔터티(일부는 명확하지 않음)의 요약입니다.

  • 수집 가능한 AssemblyLoadContext의 외부에서 보유하며, 스택 슬롯 또는 프로세서 레지스터(사용자 코드를 사용하여 명시적으로 만들거나 JIT(Just-In-Time) 컴파일러에서 내재적으로 생성하는 메서드 로컬), 정적 변수 또는 강력한/고정 GC 핸들에 저장되며 다음을 가리키는 일반 참조.
    • 수집 가능한 AssemblyLoadContext에 로드된 어셈블리.
    • 이러한 어셈블리의 형식입니다.
    • 이러한 어셈블리에 있는 형식의 인스턴스입니다.
  • 수집 가능한 AssemblyLoadContext에 로드된 어셈블리의 스레드 실행 코드.
  • 수집 가능한 AssemblyLoadContext 내부에서 만들어진 수집 불가능한 사용자 지정 AssemblyLoadContext 형식의 인스턴스입니다.
  • 콜백이 사용자 지정 AssemblyLoadContext의 메서드로 설정된 보류 중인 RegisteredWaitHandle 인스턴스

스택 슬롯 또는 프로세서 레지스터에 저장되고 다음과 같은 상황에서 AssemblyLoadContext 언로드를 금지할 수 있는 개체 참조

  • 사용자가 만든 로컬 변수가 없더라도 함수 호출 결과를 다른 함수에 직접 전달하는 경우
  • JIT 컴파일러가 메서드의 특정 지점에서 사용할 수 있었던 개체에 대한 참조를 유지하는 경우

디버그 언로드 문제

언로드 관련 문제를 디버깅하는 것이 지루한 작업이 될 수 있습니다. AssemblyLoadContext를 활성 상태로 유지할 수 있는 항목을 알 수 없지만 언로드가 실패하는 상황이 발생할 수 있습니다. 이 문제를 해결하는 가장 좋은 도구는 SOS 플러그 인을 사용하는 WinDbg(또는 Unix의 LLDB)입니다. 특정 AssemblyLoadContext에 속한 LoaderAllocator을(를) 활성 상태로 유지하는 사항을 찾아야 합니다. SOS 플러그 인을 사용하면 GC 힙 개체, 해당 계층 구조 및 루트를 확인할 수 있습니다.

SOS 플러그 인을 디버거로 로드하려면 디버거 명령줄에서 다음 명령 중 하나를 입력합니다.

WinDbg에서(아직 로드되지 않은 경우):

.loadby sos coreclr

LLDB에서:

plugin load /path/to/libsosplugin.so

언로드에 문제가 있는 예제 프로그램을 디버깅해 보겠습니다. 소스 코드는 예제 소스 코드 섹션에서 사용할 수 있습니다. WinDbg에서 실행하면 언로드 성공 여부를 확인한 후 즉시 프로그램이 디버거를 시작합니다. 그런 다음 원인을 검색하기 시작할 수 있습니다.

Unix에서 LLDB를 사용하여 디버깅하는 경우 다음 예제의 SOS 명령 앞에 !가 없습니다.

!dumpheap -type LoaderAllocator

이 명령은 GC 힙에 있는 LoaderAllocator를 포함하는 형식 이름을 사용하는 모든 개체를 덤프합니다. 예를 들면 다음과 같습니다.

         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

"통계:" 부분에서 관심 있는 개체인 System.Reflection.LoaderAllocator에 속하는MT(MethodTable)을(를) 확인합니다. 그런 다음 시작 부분에 있는 목록에서 해당 항목과 일치하는 MT이(가) 있는 항목을 찾아 개체 자체의 주소를 가져옵니다. 이 경우 "000002b78000ce40"입니다.

이제 LoaderAllocator 개체의 주소를 알고 있으므로 다른 명령을 사용하여 GC 루트를 찾을 수 있습니다.

!gcroot 0x000002b78000ce40

이 명령은 LoaderAllocator 인스턴스를 초래하는 개체 참조의 체인을 덤프합니다. 이 목록은 LoaderAllocator을(를) 활성 상태로 유지하므로 문제의 핵심이 되는 엔터티인 루트로 시작합니다. 루트는 스택 슬롯, 프로세서 레지스터, GC 핸들 또는 정적 변수일 수 있습니다.

다음은 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.

다음 단계는 문제를 해결할 수 있도록 루트의 위치를 파악하는 것입니다. 가장 쉬운 사례는 루트가 스택 슬롯 또는 프로세서 레지스터인 경우입니다. 이 경우 gcroot는 프레임에 해당 함수를 실행하는 루트 및 스레드가 포함된 함수의 이름을 표시합니다. 루트가 정적 변수이거나 GC 핸들인 경우 해결하기가 어렵습니다.

이전 예제에서 첫 번째 루트는 rbp-20 주소(여기서 rbp는 프로세서 레지스터 rbp이고 -20은 해당 레지스터로부터의 16진수 오프셋임)에서 example.Program.Main(System.String[]) 함수의 프레임에 저장된 System.Reflection.RuntimeMethodInfo 형식의 로컬입니다.

두 번째 루트는 test.Test 클래스의 인스턴스에 대한 참조를 보유하는 기본 (강력) GCHandle입니다.

세 번째 루트는 고정된 GCHandle입니다. 실제로는 정적 변수이지만 알 수 있는 방법은 없습니다. 참조 형식의 정적은 내부 런타임 구조체의 관리형 개체 배열에 저장됩니다.

AssemblyLoadContext를 언로드하지 못하게 하는 또 다른 경우는 스레드에 스택의 AssemblyLoadContext에 로드된 어셈블리의 메서드 프레임이 있는 경우입니다. 모든 스레드의 관리형 호출 스택을 덤프하여 확인할 수 있습니다.

~*e !clrstack

명령은 “모든 스레드에 !clrstack 명령 적용”을 의미합니다. 다음은 예제에 대한 해당 명령의 출력입니다. 안타깝게도 Unix의 LLDB에서는 모든 스레드에 명령을 적용할 수 있는 방법이 없으므로 수동으로 스레드를 전환하고 clrstack 명령을 반복해야 합니다. 디버거가 "관리형 스택을 탐색할 수 없습니다"라고 표시하는 경우 모든 스레드를 무시합니다.

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]

여기에서 볼 수 있듯이 마지막 스레드에는 test.Program.ThreadProc()가 있습니다. 이 함수는 AssemblyLoadContext에 로드된 어셈블리의 함수이므로 AssemblyLoadContext를 활성 상태로 유지합니다.

예제 소스 코드

언로드 가능성 문제가 포함된 다음 코드는 이전 디버깅 예제에서 사용됩니다.

기본 테스트 프로그램

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

TestAssemblyLoadContext에 로드된 프로그램

다음 코드는 기본 테스트 프로그램의 ExecuteAndUnload 메서드에 전달되는 test.dll을 나타냅니다.

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