.NET(Core)에는 어셈블리 집합을 로드하고 나중에 언로드하는 기능이 도입되었습니다. .NET Framework에서 사용자 지정 앱 도메인은 이 용도로 사용되었지만 .NET(Core)은 단일 기본 앱 도메인만 지원합니다.
언로드 기능은 AssemblyLoadContext을 통해 지원됩니다. 어셈블리 집합을 수집 가능한 AssemblyLoadContext으로 로드하고, 메서드를 실행하거나 리플렉션을 사용하여 검사하며, 마지막으로 AssemblyLoadContext을 언로드합니다. 로드된 어셈블리를 AssemblyLoadContext에서 언로드합니다.
AppDomains를 사용하는 AssemblyLoadContext 언로드와 사용 간에는 한 가지 중요한 차이점이 있습니다. AppDomains를 사용하면 언로드가 강제로 적용됩니다. 언로드 시 대상 AppDomain에서 실행되는 모든 스레드가 중단되고, 대상 AppDomain에서 만든 관리되는 COM 개체가 제거됩니다.
AssemblyLoadContext에서는 언로드가 "협력적"입니다. 메서드를 호출하면 AssemblyLoadContext.Unload 언로드가 시작됩니다. 다음 후에 언로드가 완료됩니다.
- 스레드에는 호출 스택에 로드된 어셈블리의
AssemblyLoadContext메서드가 없습니다. - 어셈블리에 로드된
AssemblyLoadContext의 모든 형식, 그 형식의 인스턴스 및 어셈블리 자체는 다음에서 참조되지 않습니다.-
AssemblyLoadContext외부의 참조, 단 약한 참조(WeakReference 또는 WeakReference<T>)는 제외합니다. - 내부 및 외부에서 강력한 GC(가비지 수집기) 핸들(GCHandleType.Normal 또는
AssemblyLoadContext)을 처리합니다.
-
collectible 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, Assembly, 및 MethodInfo를 스택 슬롯 참조(실제 로컬 또는 JIT에서 도입된 로컬)를 통해 활성 상태로 유지할 수 없도록 Assembly.EntryPoint(인라인할 수 없는 별도의 메서드)에 배치하십시오.
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어셈블리에 대한 일반적인 제한을 준수해야 합니다. 다음 제한 사항이 추가로 적용됩니다.
- C++/CLI로 작성된 어셈블리는 지원되지 않습니다.
- ReadyToRun 생성 코드는 무시됩니다.
언로드 이벤트
경우에 따라 언로드가 시작될 때 사용자 지정 AssemblyLoadContext 에 로드된 코드가 일부 정리를 수행해야 할 수 있습니다. 예를 들어 스레드를 중지하거나 강한 GC 핸들을 처리해야 할 수 있습니다. 이러한 Unloading 경우 이벤트를 사용할 수 있습니다. 이 이벤트에 필요한 정리를 수행하는 처리기를 후크할 수 있습니다.
언로드 불가능 문제 해결
언로드의 협조적 특성으로 인해 물건을 수집 가능한 AssemblyLoadContext 상태로 유지하고 언로드를 방지할 수 있는 참조를 잊기 쉽습니다. 참조를 보유할 수 있는 엔터티는 다음과 같습니다(일부는 명백하지 않을 수 있습니다).
- 스택 슬롯 또는 프로세서 레지스터(사용자 코드에서 명시적으로 만들거나 JIT(Just-In-Time) 컴파일러에서 암시적으로 만든 메서드 로컬), 정적 변수, 강력한(고정된) GC 핸들에 있는 일반 참조는, 수집 가능한
AssemblyLoadContext외부에서 유지되며, 전이적으로 가리킵니다.- 어셈블리가 수집 가능한 모듈에 로드되었습니다
AssemblyLoadContext. - 이러한 어셈블리의 유형입니다.
- 이렇게 어셈블리로부터 나온 형식의 한 인스턴스.
- 어셈블리가 수집 가능한 모듈에 로드되었습니다
- 수집 가능한
AssemblyLoadContext어셈블리에 로드된 어셈블리에서 코드를 실행하는 스레드입니다. - 수집할 수 있는
AssemblyLoadContext내부에서 생성된 사용자 지정AssemblyLoadContext형식의 수집할 수 없는 인스턴스입니다. -
RegisteredWaitHandle 인스턴스 보류 중, 콜백이 사용자 지정
AssemblyLoadContext의 메서드로 설정됨. - 로드 가능한
AssemblyLoadContext에 로드된 형식의 어셈블리, 형식 또는 인스턴스를 참조하는 사용자 지정AssemblyLoadContext하위 클래스의 필드입니다. 언로드가 진행되는 동안 런타임은 강력한 GC 핸들을AssemblyLoadContext에 보유하여 언로드를 조정합니다. 즉, GC는 직접 참조를 삭제한 후에도 해당 필드 참조를AssemblyLoadContext수집하지 않습니다. 언로드를 완료할 수 있도록 이러한 필드를 지웁다.
팁 (조언)
스택 슬롯 또는 프로세서 레지스터에 저장되고 언로드 AssemblyLoadContext 를 방지할 수 있는 개체 참조는 다음과 같은 경우에 발생할 수 있습니다.
- 사용자가 만든 지역 변수가 없더라도 함수 호출 결과가 다른 함수에 직접 전달되는 경우
- JIT 컴파일러가 메서드의 특정 지점에서 사용할 수 있는 개체에 대한 참조를 유지하는 경우
언로드 문제 디버깅
언로드와 관련된 디버깅 문제는 지루할 수 있습니다. 오브젝트가 살아 있는 상태를 유지하도록 하는 것이 무엇인지 알 수 없는 상황에 빠질 수 있지만, 그러나 언로드에는 실패합니다. 이를 도와주는 가장 좋은 도구는 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 핸들인 경우입니다.
이전 예제에서 첫 번째 루트는 주소 System.Reflection.RuntimeMethodInfo 에 있는 함수 example.Program.Main(System.String[]) 프레임에 저장된 형식 rbp-20 의 로컬입니다(rbp프로세서 레지스터 rbp 이고 -20 해당 레지스터의 16진수 오프셋임).
두 번째 루트는 클래스 인스턴스 GCHandle 에 대한 참조를 보유하는 일반(강력한) test.Test 입니다.
세 번째 루트는 고정된 GCHandle입니다. 이것은 실제로 정적 변수이지만 불행히도 알 수있는 방법은 없습니다. 참조 형식에 대한 정적은 내부 런타임 구조의 관리되는 개체 배열에 저장됩니다.
언로드를 방지할 수 있는 또 다른 경우는 스레드의 스택에 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 나타냅니다.
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;
}
}
}
.NET