C# - 관리 스레드와 비관리 스레드
닷넷 세계에서 스레드는 2가지로 나뉩니다.
Managed and unmanaged threading in Windows
; https://learn.microsoft.com/en-us/dotnet/standard/threading/managed-and-unmanaged-threading-in-windows
관리 스레드는, 1) 닷넷에서 Thread 타입을 이용해 생성했거나, 2) 비관리 스레드일지라도 관리 환경으로 진입하는 코드를 실행했어야 합니다. 두 번째 사례의 좋은 예가 바로 P/Invoke나 COM Interop 호출입니다. 이런 유의 호출에서 관리/비관리 유무를 확인하는 방법은 TLS에 보관된 관리 Thread 개체를 찾아보는 거라고 합니다.
When an unmanaged thread enters the runtime through, for example, a COM callable wrapper, the system checks the thread-local store of that thread to look for an internal managed Thread object. If one is found, the runtime is already aware of this thread. If it cannot find one, however, the runtime builds a new Thread object and installs it in the thread-local store of that thread.
만약 있다면 그대로 호출을 진행하면 되고, 없다면 TLS에 관리 Thread 개체를 보관해 둔다고.
그리고 그 외의 모든 스레드는 비관리 스레드입니다.
위의 구분으로 문제가 될 수 있는 상황은, "비관리 스레드"에서 닷넷 메서드를 직접 호출하는 경우입니다. 즉, 비관리 스레드에서는 "관리 한경으로 진입하는 코드" 실행 없이 직접 닷넷 메서드를 실행하면 오동작을 할 수 있는 것입니다.
하지만, 저런 사실이 그동안은 별로 문제가 되지 않았습니다. 왜냐하면, 비관리 스레드에 닷넷 메서드의 함수 포인터를 직접 넘겨주는 방법이 쉽지 않았기 때문입니다. 그런데, C# 9.0에 추가된 함수 포인터로 인해,
C# 9.0 - (6) 함수 포인터(Function pointers)
; https://www.sysnet.pe.kr/2/0/12374
드디어 저 상황을 쉽게 재현할 수 있는 사례가 발생합니다. ^^
함수 포인터는 "managed" 유형과 "unmanaged" 유형으로 나뉩니다.
[managed 함수를 가리키는 함수 포인터]
delegate*<string, void> writeLineFunc = &Program.WriteLine;
// 또는
delegate* managed<string, void> writeLineFunc = &Program.WriteLine;
[unmanaged 함수를 가리키는 함수 포인터]
delegate* unmanaged[Stdcall]<int, bool, int> sleepExFunc = (delegate* unmanaged[Stdcall]<int, bool, int>)ptrSleepEx;
하지만 2개 모두 어차피 "포인터"라는 점에는 동일합니다. 즉, 해당 메서드의 메모리 주소를 직접 가리키는 포인터 값입니다. 그렇다면, 이 포인터 값들을 Native 스레드에서 호출하면 어떻게 될까요?
이것을 테스트할 수 있는 예제가 바로 지난 글에 있습니다. ^^
C# - WAV 파일의 PCM 사운드 재생(Windows Multimedia)
; https://www.sysnet.pe.kr/2/0/13599
위의 예제에서 Win32 API에 Callback 함수를 넘겨주는 코드가 다음과 같이 이뤄져 있습니다.
delegate* unmanaged[Stdcall]<IntPtr, WaveMessage, IntPtr, IntPtr, IntPtr, void> waveOutCallbackFunc = &Program.waveOutCallback;
// ...[생략]...
MmResult result = NativeMethods.waveOutOpen(out IntPtr hWaveOut, -1, &format, waveOutCallbackFunc,
IntPtr.Zero, WaveInOutOpenFlags.CallbackFunction);
// ...[생략]...
[UnmanagedCallersOnly(CallConvs = new Type[] { typeof(CallConvStdcall) })]
static void waveOutCallback(IntPtr hWaveOut, WaveMessage uMsg, IntPtr dwInstance, IntPtr wavhdr, IntPtr dwReserved)
{
System.Console.WriteLine($"waveOutCallback: {uMsg}");
}
그런데 위의 예제를 delegate* unmanaged가 아니라 delegate*로 바꾸면,
delegate* <IntPtr, WaveMessage, IntPtr, IntPtr, IntPtr, void> waveOutCallbackFunc = &Program.waveOutCallback;
MmResult result = NativeMethods.waveOutOpenManaged(out IntPtr hWaveOut, -1, &format, waveOutCallbackFunc,
IntPtr.Zero, WaveInOutOpenFlags.CallbackFunction);
static void waveOutCallback(IntPtr hWaveOut, WaveMessage uMsg, IntPtr dwInstance, IntPtr wavhdr, IntPtr dwReserved)
{
System.Console.WriteLine($"waveOutCallback");
}
[DllImport("winmm.dll", EntryPoint = "waveOutOpen")]
public static extern MmResult waveOutOpenManaged(out IntPtr hWaveOut, IntPtr uDeviceID, WaveFormatEx* lpFormat, delegate* <IntPtr, WaveMessage, IntPtr, IntPtr, IntPtr, void> dwCallback, IntPtr dwInstance, WaveInOutOpenFlags dwFlags);
waveOutWrite로 시작한 소리 재생이 완료되는 시점에, 즉 waveOutCallback이 호출되는 시점에 프로그램이 강제 종료됩니다. 그래서 출력 결과가 이런 식으로 나옵니다.
waveOutCallback
waveOutPrepareHeader: Prepared
waveOutWrite: Prepared, InQueue
.
.
.
.
.
.
.
C:\temp\ConsoleApp1\bin\Debug\net8.0\ConsoleApp1.exe (process 73216) exited with code -1073741819.
문제 분석을 위해, Windbg에서 EXE 파일을 열어 직접 실행하면, 소리 재생이 끝날 즈음 다음과 같이 Access violation 예외가 발생하는 것을 볼 수 있습니다.
(1002c.e420): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
coreclr!Volatile<enum Thread::ThreadState>::LoadWithoutBarrier [inlined in coreclr!JIT_MonEnterWorker_Portable+0x36]:
00007ff9`2e175536 418b4008 mov eax,dword ptr [r8+8] ds:00000000`00000008=????????
이때의 호출 스택은,
0:017> k
# Child-SP RetAddr Call Site
00 (Inline Function) --------`-------- coreclr!Volatile<enum Thread::ThreadState>::LoadWithoutBarrier [D:\a\_work\1\s\src\coreclr\inc\volatile.h @ 382]
01 (Inline Function) --------`-------- coreclr!Thread::HasThreadStateOpportunistic [D:\a\_work\1\s\src\coreclr\vm\threads.h @ 1082]
02 (Inline Function) --------`-------- coreclr!Thread::CatchAtSafePointOpportunistic [D:\a\_work\1\s\src\coreclr\vm\threads.h @ 1244]
03 (Inline Function) --------`-------- coreclr!Object::TryEnterObjMonitorSpinHelper+0x1d [D:\a\_work\1\s\src\coreclr\vm\object.inl @ 117]
04 00000037`186bf430 00007ff9`1b2c64a3 coreclr!JIT_MonEnterWorker_Portable+0x36 [D:\a\_work\1\s\src\coreclr\vm\jithelpers.cpp @ 3809]
05 00000037`186bf470 00007ff9`bc0b95b0 System_Private_CoreLib!System.IO.TextWriter.SyncTextWriter.WriteLine+0x33 [/_/src/libraries/System.Private.CoreLib/src/System/IO/TextWriter.cs @ 874]
06 00000037`186bf4d0 00007ff8`ce614829 System_Console!System.Console.WriteLine+0x20 [/_/src/libraries/System.Console/src/System/Console.cs @ 839]
07 00000037`186bf500 00007ffa`558b56f9 0x00007ff8`ce614829
08 00000037`186bf540 00007ffa`3683358b winmmbase!DriverCallback+0xa9
09 00000037`186bf580 00007ffa`3683334e msacm32!mapWaveDriverCallback+0x3b
0a 00000037`186bf5d0 00007ffa`558b56f9 msacm32!mapWaveCallback+0x17e
0b 00000037`186bf600 00007ffa`1dca4083 winmmbase!DriverCallback+0xa9
0c 00000037`186bf640 00007ffa`1dca3dd0 wdmaud!CWaveHeader::~CWaveHeader+0x53
0d 00000037`186bf690 00007ffa`1dca31c3 wdmaud!CWaveHandle::Work+0x2f0
0e 00000037`186bf700 00007ffa`1dca2ff0 wdmaud!CWorker::_ThreadProc+0x1a3
0f 00000037`186bf990 00007ffa`782a257d wdmaud!CWorker::_StaticThreadProc+0x40
10 00000037`186bf9c0 00007ffa`7922aa48 KERNEL32!BaseThreadInitThunk+0x1d
11 00000037`186bf9f0 00000000`00000000 ntdll!RtlUserThreadStart+0x28
콜백 함수까지는 잘 진입이 되었지만, 그 함수가 포함한 Console.WriteLine 내의 CLR 함수를 호출하는 단계에서 예외가 발생한 것을 볼 수 있습니다. 그리고, 이런 문제가 네이티브 스레드에서의 호출로 인한 것이었다는 점을 "!threads" 명령어로 유추할 수 있습니다.
0:018> !threads
ThreadCount: 5
UnstartedThread: 0
BackgroundThread: 4
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
Lock
DBG ID OSID ThreadOBJ State GC Mode GC Alloc Context Domain Count Apt Exception
0 1 d0c4 0000015CFB9DC120 202a020 Preemptive 0000015D00013B90:0000015D00014020 0000015cfb9d18a0 -00001 MTA
13 2 271c 0000015CFBA85C40 2b220 Preemptive 0000000000000000:0000000000000000 0000015cfb9d18a0 -00001 MTA (Finalizer)
15 4 b944 0000015C80435FC0 302b220 Preemptive 0000015D0000D4F8:0000015D0000EBA0 0000015cfb9d18a0 -00001 MTA (Threadpool Worker)
16 5 6fd0 0000015C80438B80 302b220 Preemptive 0000015D0000EFA0:0000015D00010BC0 0000015cfb9d18a0 -00001 MTA (Threadpool Worker)
17 6 2730 0000015C80442050 102b220 Preemptive 0000015D00010EF0:0000015D00010FD0 0000015cfb9d18a0 -00001 MTA (Threadpool Worker)
보는 바와 같이 (callback 함수를 실행한) 18번 스레드가 없습니다. 즉, 18번 스레드는 비관리 스레드이고 그런 환경에서 닷넷 메서드를 호출했기 때문에 결국 crash가 발생한 것입니다. 따라서, managed 함수 포인터를 절대로 native 측에 전달해서는 안 됩니다.
"
C# - WAV 파일의 PCM 사운드 재생(Windows Multimedia)" 예제가 재미있는 것은, Callback 메서드가 초기에는 정상적으로 실행이 되었다는 점입니다.
waveOutCallback
waveOutPrepareHeader: Prepared
waveOutWrite: Prepared, InQueue
.
.
.
.
.
.
.
C:\temp\ConsoleApp1\bin\Debug\net8.0\ConsoleApp1.exe (process 73216) exited with code -1073741819.
즉, NativeMethods.waveOutOpen 호출 시점에는 callback 메서드의 코드가 잘 수행되었다는 점인데요, 아니... Native 스레드에서는 managed 함수 포인터를 실행해서는 안 되는데 왜 저 시점에서는 잘 된 것일까요?
왜냐하면, waveOutOpen 시에 발생한 callback은 해당 함수를 실행한 스레드에서 직접 실행하기 때문입니다. 즉, 닷넷 런타임이 초기화된 스레드에서 managed 함수가 실행된 것이므로 문제가 없었던 것입니다. 반면, 소리 재생이 완료된 시점에는 윈도우 운영체제가 임의로 선택한 Native Thread에서 managed 함수 포인터를 실행하기 때문에 문제가 발생한 것입니다.
물론, "managed *" 함수 포인터가 아닌, 기존의 Delegate를 전달하는 유형이라면 위와 같은 문제가 발생하지 않습니다.
waveOutCallbackDelegate waveOutCallbackFunc = Program.waveOutCallback;
MmResult result = NativeMethods.waveOutOpenDelegate(out IntPtr hWaveOut, -1, &format, waveOutCallbackFunc,
IntPtr.Zero, WaveInOutOpenFlags.CallbackFunction);
static void waveOutCallback(IntPtr hWaveOut, WaveMessage uMsg, IntPtr dwInstance, IntPtr wavhdr, IntPtr dwReserved)
{
System.Console.WriteLine($"waveOutCallback: {uMsg}");
}
delegate void waveOutCallbackDelegate(IntPtr hWaveOut, WaveMessage uMsg, IntPtr dwInstance, IntPtr wavhdr, IntPtr dwReserved);
[DllImport("winmm.dll", EntryPoint = "waveOutOpen")]
public static extern MmResult waveOutOpenDelegate(out IntPtr hWaveOut, IntPtr uDeviceID, WaveFormatEx* lpFormat, waveOutCallbackDelegate dwCallback, IntPtr dwInstance, WaveInOutOpenFlags dwFlags);
(단지, 이런 경우 GC 후에도 waveOutCallbackFunc
인스턴스가 제거되지 않도록 전역 변수 처리를 해야 합니다.)
위와 같이 Delegate를 전달해 callback 함수가 Native 함수에서 불리는 상태의 callstack을 보면,
ConsoleApp1.dll!ConsoleApp1.Program.waveOutCallback(nint hWaveOut = 0x0000020f80515790, AudioLibrary.WaveMessage uMsg = WaveOutDone, nint dwInstance = 0x0000000000000000, nint wavhdr = 0x0000008adf3ae748, nint dwReserved = 0x0000000000000000) Line 91
[Native to Managed Transition]
winmmbase.dll!DriverCallback()
msacm32.drv!mapWaveDriverCallback()
msacm32.drv!mapWaveCallback()
winmmbase.dll!DriverCallback()
wdmaud.drv!CWaveHeader::~CWaveHeader(void)
wdmaud.drv!CWaveHandle::Work()
wdmaud.drv!CWorker::_ThreadProc()
wdmaud.drv!CWorker::_StaticThreadProc(void *)
kernel32.dll!BaseThreadInitThunk()
ntdll.dll!RtlUserThreadStart()
저렇게 중간에 "Native to Managed Transition" 층을 볼 수 있습니다. Delegate가 감싼 덕분에 생긴 층으로 바로 저 코드가 있기 때문에 문제없이 닷넷 코드들이 잘 동작한 것입니다. 반면, (Delegate가 아닌) managed 함수 포인터로 전달한 경우에는 다음과 같은 호출 스택이 나옵니다.
ntdll.dll!RtlpExecuteHandlerForException()
ntdll.dll!RtlDispatchException()
ntdll.dll!KiUserExceptionDispatch()
System.Private.CoreLib.dll!00007ff94bca72a1()
System.Private.CoreLib.dll!00007ff94bbc1b68()
winmmbase.dll!DriverCallback()
msacm32.drv!mapWaveDriverCallback()
msacm32.drv!mapWaveCallback()
winmmbase.dll!DriverCallback()
wdmaud.drv!CWaveHeader::~CWaveHeader(void)
wdmaud.drv!CWaveHandle::Work()
wdmaud.drv!CWorker::_ThreadProc()
wdmaud.drv!CWorker::_StaticThreadProc(void *)
kernel32.dll!BaseThreadInitThunk()
ntdll.dll!RtlUserThreadStart()
DriverCallback에서 Program.waveOutCallback 메서드를 호출하는 중간에 "Native to Managed Transition" 층이 없고, 이로 인해 닷넷 런타임에 의존한 코드를 callback 메서드에서 실행하는 경우 crash가 발생하는 것입니다.
그런데,
UnmanagedCallersOnly 특성을 부여한 unmanaged 함수 포인터를 Native가 호출한 경우에는 어떻게 정상적으로 닷넷 코드를 실행할 수 있었던 걸까요? Native 스레드 입장에서는 닷넷이 전달한 함수 포인터를 그대로 실행했을 텐데, 그렇다면 그 경우에도 마찬가지로 crash가 발생했어야 합니다.
그 이유는, UnmanagedCallersOnly 특성으로 인해 해당 메서드의 JIT 컴파일이 달라지기 때문입니다.
[UnmanagedCallersOnly(CallConvs = new Type[] { typeof(CallConvStdcall) })]
static void waveOutCallback(IntPtr hWaveOut, WaveMessage uMsg, IntPtr dwInstance, IntPtr wavhdr, IntPtr dwReserved)
{
System.Console.WriteLine($"waveOutCallback: {uMsg}");
}
위의 waveOutCallback 메서드에 대한 disassembly 코드를 보면,
00007FF8EC5EE300 55 push rbp
00007FF8EC5EE301 57 push rdi
00007FF8EC5EE302 56 push rsi
00007FF8EC5EE303 48 83 EC 60 sub rsp,60h
00007FF8EC5EE307 48 8B EC mov rbp,rsp
00007FF8EC5EE30A C5 D8 57 E4 vxorps xmm4,xmm4,xmm4
00007FF8EC5EE30E C5 F9 7F 65 20 vmovdqa xmmword ptr [rbp+20h],xmm4
00007FF8EC5EE313 C5 F9 7F 65 30 vmovdqa xmmword ptr [rbp+30h],xmm4
00007FF8EC5EE318 C5 F9 7F 65 40 vmovdqa xmmword ptr [rbp+40h],xmm4
00007FF8EC5EE31D C5 F9 7F 65 50 vmovdqa xmmword ptr [rbp+50h],xmm4
00007FF8EC5EE322 48 89 8D 80 00 00 00 mov qword ptr [rbp+80h],rcx
00007FF8EC5EE329 89 95 88 00 00 00 mov dword ptr [rbp+88h],edx
00007FF8EC5EE32F 4C 89 85 90 00 00 00 mov qword ptr [rbp+90h],r8
00007FF8EC5EE336 4C 89 8D 98 00 00 00 mov qword ptr [rbp+98h],r9
00007FF8EC5EE33D 48 8D 4D 20 lea rcx,[rbp+20h]
00007FF8EC5EE341 E8 DA AF A9 5F call JIT_ReversePInvokeEnter (07FF94C089320h)
00007FF8EC5EE346 83 3D DB FF 07 00 00 cmp dword ptr [7FF8EC66E328h],0
00007FF8EC5EE34D 74 05 je ConsoleApp1.Program.waveOutCallback(IntPtr, AudioLibrary.WaveMessage, IntPtr, IntPtr, IntPtr)+054h (07FF8EC5EE354h)
00007FF8EC5EE34F E8 FC E2 C5 5F call JIT_DbgIsJustMyCode (07FF94C24C650h)
...[생략]...
중간에 JIT_ReversePInvokeEnter 호출이 있습니다. 아마도 저 함수가 관리 스레드 개체를 TLS에 설정하는 코드로 짐작됩니다. 실제로 디버거를 통해 호출 스택을 보면 위의 경우 "[Native to Managed Transition]" 층이 있는 것을 확인할 수 있습니다. (그런데, 한 가지 재미있는 점은, 도대체 저 "층"을 어떻게 알고 Windbg는 보여주는 걸까요? 단순히 저렇게 함수 호출로 인한 차이라면 "층"이 있다는 것을 알 수 없는데 뭔가 제가 모르는 또 다른 처리가 있는 듯합니다. 혹시 알고 계신 분은 덧글 부탁드립니다. ^^)
반면, UnmanagedCallersOnly 특성이 없다면,
static void waveOutCallback(IntPtr hWaveOut, WaveMessage uMsg, IntPtr dwInstance, IntPtr wavhdr, IntPtr dwReserved)
{
System.Console.WriteLine($"waveOutCallback: {uMsg}");
}
이렇게 바뀌는데요,
00007FF8EBFD6120 55 push rbp
00007FF8EBFD6121 57 push rdi
00007FF8EBFD6122 56 push rsi
00007FF8EBFD6123 48 83 EC 50 sub rsp,50h
00007FF8EBFD6127 48 8B EC mov rbp,rsp
00007FF8EBFD612A C5 D8 57 E4 vxorps xmm4,xmm4,xmm4
00007FF8EBFD612E C5 F9 7F 65 20 vmovdqa xmmword ptr [rbp+20h],xmm4
00007FF8EBFD6133 C5 F9 7F 65 30 vmovdqa xmmword ptr [rbp+30h],xmm4
00007FF8EBFD6138 C5 F9 7F 65 40 vmovdqa xmmword ptr [rbp+40h],xmm4
00007FF8EBFD613D 48 89 4D 70 mov qword ptr [rbp+70h],rcx
00007FF8EBFD6141 89 55 78 mov dword ptr [rbp+78h],edx
00007FF8EBFD6144 4C 89 85 80 00 00 00 mov qword ptr [rbp+80h],r8
00007FF8EBFD614B 4C 89 8D 88 00 00 00 mov qword ptr [rbp+88h],r9
00007FF8EBFD6152 83 3D CF 81 08 00 00 cmp dword ptr [7FF8EC05E328h],0
00007FF8EBFD6159 74 05 je ConsoleApp1.Program.waveOutCallback(IntPtr, AudioLibrary.WaveMessage, IntPtr, IntPtr, IntPtr)+040h (07FF8EBFD6160h)
00007FF8EBFD615B E8 F0 64 C9 5F call JIT_DbgIsJustMyCode (07FF94BC6C650h)
...[생략]...
당연히 닷넷 메서드이기 때문에 비관리 스레드에서 호출될 것을 신경 쓰지 않아도 되므로 JIT_ReversePInvokeEnter 호출이 없습니다. 따라서, 비관리 스레드에서 호출되는 경우 "[Native to Managed Transition]" 층을 거치지 않으므로 CLR 함수 호출 시 crash가 발생하는 것입니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]