Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)

C# - Thread.Suspend 호출 시 응용 프로그램 hang 현상 (2)

지난 글에서,

C# - Thread.Suspend 호출 시 응용 프로그램 hang 현상
; https://www.sysnet.pe.kr/2/0/11473

마지막에 ETW 이벤트를 실시간으로 잡을 수 있다면 deadlock 현상을 피할 수 있을 것처럼 설명했는데요. 가만 보니, CRL Profiler 측의 콜백 이벤트가 gc가 아닌 COR_PRF_MONITOR_SUSPENDS 유에 속하는 것이라서,

COR_PRF_MONITOR Enumeration
; https://learn.microsoft.com/en-us/dotnet/framework/unmanaged-api/profiling/cor-prf-monitor-enumeration

COR_PRF_MONITOR_SUSPENDS - Controls the RuntimeSuspend*, RuntimeResume*, RuntimeThreadSuspended, and RuntimeThreadResumed callbacks in the ICorProfilerCallback interface.


시스템 부하 없이 가능할 것 같아 테스트를 해봤습니다. 이름이 유추하는 것에 따라, 각각의 ETW 이벤트는 ICorProfilerCallback 콜백에 대해 다음과 같이 대응을 하는데요,

GCSuspendEE_V1      ==> RuntimeSuspendStarted
GCSuspendEEEnd_V1   ==> RuntimeSuspendFinished
GCStart_V1          ==> (COR_PRF_MONITOR_GC 범주의) GarbageCollectionStarted
GCEnd_V1            ==> (COR_PRF_MONITOR_GC 범주의) GarbageCollectionFinished
GCRestartEEBegin_V1 ==> RuntimeResumeStarted
GCRestartEEEnd_V1   ==> RuntimeResumeFinished

자, 그럼 기본으로 만들어 둔 CLR Profiler 예제 코드에 아래의 추가 코드를 넣고,

static int s_suspend = 0;
static int s_resumed = 0;
volatile static unsigned long s_intLock = 0;

__declspec(dllexport) bool __stdcall IsRunningGC()
{
    return s_intLock == 1;
}

HRESULT CBasicClrProfiler::RuntimeSuspendStarted(COR_PRF_SUSPEND_REASON suspendReason)
{
    s_suspend++;
    s_intLock = 1;

    return S_OK;
}


HRESULT CBasicClrProfiler::RuntimeResumeFinished()
{
    s_intLock = 0;
    s_resumed++;

    return S_OK;
}

재현 코드의 GetCallStack 메서드는 IsRunningGC == false인 상태에서만 호출하게 변경합니다.

[DllImport("SampleProfiler.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsRunningGC();

private static string GetCallStack(Thread t)
{
    System.Diagnostics.StackTrace trace = null;
    bool suspended = false;

    try
    {
        if (IsRunningGC() == false)
        {
            t.Suspend();
            suspended = true;

            trace = new System.Diagnostics.StackTrace(t, false);
            return trace.ToString();
        }
    }
    catch { }
    finally
    {
        if (suspended == true)
        {
            t.Resume();
        }
    }

    return "";
}

그런데, 실제로 실행해 보면 이번에도 데드락이 걸리는 것은 마찬가지입니다. 그 상태의 덤프를 떠서 windbg로 분석해 보면,

.loadby sos clr

0:000>  !threads
ThreadCount:      7
UnstartedThread:  0
BackgroundThread: 6
PendingThread:    0
DeadThread:       0
Hosted Runtime:   no
                                                                                                        Lock  
       ID OSID ThreadOBJ           State GC Mode     GC Alloc Context                  Domain           Count Apt Exception
   0    1 609c 000001751bcd0850    2a020 Preemptive  000001751D8F5D28:000001751D8F5FB0 000001751bc96960 0     MTA 
   6    2 85b8 000001751bcf96b0    2b220 Preemptive  0000000000000000:0000000000000000 000001751bc96960 0     MTA (Finalizer) 
   7    3 a8a8 000001751bd29ae0    2b220 Preemptive  0000000000000000:0000000000000000 000001751bc96960 0     MTA 
   8    4 183c 000001751bd2d0f0    ab224 Preemptive  0000000000000000:0000000000000000 000001751bc96960 0     MTA 
   9    5 9ddc 000001751bd2f650    2b220 Preemptive  0000000000000000:0000000000000000 000001751bc96960 0     MTA 
  10    6 b970 000001751bd3c0f0    2b220 Preemptive  0000000000000000:0000000000000000 000001751bc96960 0     MTA 
  11    7 7c5c 000001751bd433c0    21220 Preemptive  0000000000000000:0000000000000000 000001751bc96960 0     Ukn 

지난번 덤프 분석과 마찬가지로 스레드 호출 스택의 대상이 되는 스레드는 trigger_gc_for_alloc을 호출하며 중지해 있고,

0:008> !ThreadState ab224
    User Suspend Pending
    Legal to Join
    Background
    CLR Owns
    CoInitialized
    In Multi Threaded Apartment
    Fully initialized
    Sync Suspended

0:008> k
 # Child-SP          RetAddr               Call Site
...[생략]...

08 0000004e`50cfe7e0 00007ff8`6dd6e69f     clr!GCToEEInterface::EnablePreemptiveGC+0x3d
09 0000004e`50cfe810 00007ff8`6e2b992b     clr!WKS::GCHeap::GarbageCollectGeneration+0x97
0a 0000004e`50cfe860 00007ff8`6e0cd99c     clr!WKS::gc_heap::trigger_gc_for_alloc+0x37
0b 0000004e`50cfe8a0 00007ff8`6de549fa     clr!WKS::gc_heap::try_allocate_more_space+0x278f80
0c 0000004e`50cfe900 00007ff8`6de5498b     clr!WKS::gc_heap::allocate_more_space+0x26
...[생략]...

GetCallStack 메서드를 호출 중인 0번 스레드는 wait_for_gc_done 호출 상태라 멈춰 있습니다.

0:000> k
 # Child-SP          RetAddr               Call Site
...[생략]...
04 0000004e`504fea40 00007ff8`6dd71732     clr!CLREventBase::WaitEx+0x71
05 0000004e`504fead0 00007ff8`6dd742f6     clr!WKS::gc_heap::wait_for_gc_done+0x2b
06 0000004e`504feb00 00007ff8`6dd50848     clr!WKS::GCHeap::Alloc+0x276
...[생략]...

그리고 이때의 C++ profiler 측 전역 변수를 살펴보면,

0:000> x SampleProfiler!s_suspend
00007ff8`6b3944bc SampleProfiler!s_suspend = 0n333

0:000> x SampleProfiler!s_resumed
00007ff8`6b3944c0 SampleProfiler!s_resumed = 0n333

0:000> x SampleProfiler!s_intLock
00007ff8`6b3944b4 SampleProfiler!s_intLock = 0

현재 trigger_gc_for_alloc 호출로 인해 런타임이 suspend 되었을 것임에도 불구하고 ICorProfilerCallback의 RuntimeSuspendStarted, RuntimeResumeFinished 이벤트는 쌍을 이뤄 이미 호출이 완료된 상태입니다. 이러한 결과에 대한 해석을 제대로 하려면 CLR 측 소스 코드를 면밀히 분석해야 하는데... 제 실력이 아직 거기까지는 안 되는군요. (혹시 이에 대해 아시는 분은 덧글 부탁드립니다. ^^)




그런데, 사실 저 동작이 정확하게 지켜졌다고 해도, IsRunningGC() 호출 결과 false일 때만 호출 스택을 구하는 것 역시 불안정하긴 마찬가지입니다. 왜냐하면, IsRunningGC 호출로 false을 얻은 바로 그 순간 GC가 발생한다면 마찬가지로 데드락에 빠질 수 있는 위험이 있기 때문입니다.

그러니까, 결국 GetCallStack 메서드의 호출과 GC는 최대한 엮이지 않는 것이 최선입니다. 이와 관련해서 생각나는 글이 있는데,

C# - Thread.Suspend 호출 시 응용 프로그램 hang 현상을 해결하기 위한 시도
; https://www.sysnet.pe.kr/2/0/12028

위에서는 GC가 임박한 경우 GetCallStack을 호출하지 않도록 변경했었는데요, 그러지 말고 GetCallStack 메서드를 호출하는 동안에는 GC가 발생하지 않도록 만드는 것은 어떨까요? 마침 .NET 4.6부터 이에 대한 기능이 추가되었습니다.

GC.TryStartNoGCRegion Method
; https://learn.microsoft.com/en-us/dotnet/api/system.gc.trystartnogcregion

GC.EndNoGCRegion Method
; https://learn.microsoft.com/en-us/dotnet/api/system.gc.endnogcregion

따라서 GetCallStack을 다음과 같이 만들어 두면,

private static string GetCallStack(Thread t)
{
    System.Diagnostics.StackTrace trace = null;
    bool suspended = false;

    try
    {
        if (GC.TryStartNoGCRegion(1024 * 1024 * 10) == true)
        {
            t.Suspend();
            suspended = true;

            trace = new System.Diagnostics.StackTrace(t, false);
            return trace.ToString();
        }
    }
    catch { }
    finally
    {
        if (GCSettings.LatencyMode == GCLatencyMode.NoGCRegion)
        {
            GC.EndNoGCRegion();
        }

        if (suspended == true)
        {
            t.Resume();
        }
    }

    return "";
}

TryStartNoGCRegion 호출 시점에 미리 10MB의 힙 공간을 확보해 두고 new System.Diagnostics.StackTrace(t, false).ToString()을 호출하게 되므로 이 과정에서 GC가 발생하지 않게 됩니다. 따라서 위의 예제를 실행하면 이전과 달리 데드락 문제가 발생하지 않습니다.

위의 코드에서 한 가지 애매한 점이 있다면, 호출 스택을 구하기 위해 미리 확보하는 10MB의 크기입니다. 사실 호출 스택은 그 깊이에 따라 천차만별일 수 있기 때문에 정확한 값을 지정할 수 없습니다. 그저 적당한 값을 넣어야 하는데 글쎄요... 10MB면 될까요? 알 수 없습니다. 참고로, 이 크기는 Ephemeral generation의 최대 세그먼트까지 지정할 수 있는데,

Ephemeral generations and segments
; https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals#ephemeral-generations-and-segments

위의 문서에 명시하듯이 32비트는 16MB, 64비트는 256MB로 가정하면 모든 환경에서 사용할 수 있습니다.

Workstation GC: 32비트 - 16MB, 64비트 - 256MB
Server GC: 32비트 - 64MB, 64비트 - 4GB
Server GC with > 4 logical CPUs: 32비트 - 32MB, 64비트 - 2GB
Server GC with > 8 logical CPUs: 32비트 - 16MB, 64비트 - 1GB

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




이걸로 해결이 된 듯싶지만, 실제로 상용 코드에 저 방법을 적용할 수는 없었습니다. 저 간단한 예제는 통과를 했지만, 제가 가지고 있는 ASP.NET 웹 애플리케이션 테스트 사이트에 저 코드를 넣었더니 특정 테스트 머신에서 불특정하게 crash가 발생했으며 이때의 덤프를 보면 다음과 같은 faulting 오류가 보입니다.

CLR!SVR::GC_HEAP::FIND_FIRST_OBJECT+83In ....dmp the assembly instruction at clr!SVR::gc_heap::find_first_object+83 in C:\Windows\Microsoft.NET\Framework64\v4.0.30319\clr.dll from Microsoft Corporation has caused an access violation exception (0xC0000005) when trying to read from memory location 0x00000000 on thread 18


0:018> k
 # Child-SP          RetAddr               Call Site
00 000000c6`30c7d678 00007fff`a337bf56     clr!SVR::gc_heap::find_first_object+0x83
01 000000c6`30c7d690 00007fff`a32c9c50     clr!SVR::GCHeap::Promote+0x147
02 000000c6`30c7d710 00007fff`a32c9d8a     clr!GcEnumObject+0x218
03 000000c6`30c7d760 00007fff`a32c8587     clr!GcInfoDecoder::EnumerateLiveSlots+0xdf3
04 000000c6`30c7dbe0 00007fff`a32c819e     clr!GcStackCrawlCallBack+0x307
05 000000c6`30c7e000 00007fff`a33748de     clr!GCToEEInterface::GcScanRoots+0x469
06 000000c6`30c7f4c0 00007fff`a3373a47     clr!SVR::gc_heap::mark_phase+0x1de
07 000000c6`30c7f560 00007fff`a3373cb6     clr!SVR::gc_heap::gc1+0xa7
08 000000c6`30c7f5b0 00007fff`a3373448     clr!SVR::gc_heap::garbage_collect+0x870
09 000000c6`30c7f650 00007fff`a3316107     clr!SVR::gc_heap::gc_thread_function+0x74
0a 000000c6`30c7f680 00007fff`a3317e14     clr!SVR::gc_heap::gc_thread_stub+0x7e
0b 000000c6`30c7f9c0 00007fff`b13b7974     clr!GCThreadStub+0x24
0c 000000c6`30c7f9f0 00007fff`b169a2d1     kernel32!BaseThreadInitThunk+0x14
0d 000000c6`30c7fa20 00000000`00000000     ntdll!RtlUserThreadStart+0x21

로그 이름:         Application
원본:            .NET Runtime
...[생략]...
설명:
Application: w3wp.exe
Framework Version: v4.0.30319
Description: The process was terminated due to an internal error in the .NET Runtime at IP 00007FF89FE91036 (00007FF89FDE0000) with exit code 80131506.

프로세스 crash이기 때문에 별도 로그를 남겨본 결과 GC.TryStartNoGCRegion 메서드까지 호출은 했으나, 그 이후에 남은 로그가 없는 걸로 봐서는 이로 인해 GC가 발생했고, (ASP.NET의 ServerGC 유형으로) 그것을 처리하는 GC 전용 스레드에서 find_first_object 호출 시 crash로 이어지는 듯합니다. 물론, 테스트를 위한 응용 프로그램이어서 약간 복잡하긴 했지만... 그래도 저 오류가 발생하는 것은 심각할 수밖에 없습니다.




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 1/19/2023]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




... 16  17  18  19  20  [21]  22  23  24  25  26  27  28  29  30  ...
NoWriterDateCnt.TitleFile(s)
13096정성태7/8/20228216.NET Framework: 2030. C# 11 - UTF-8 문자열 리터럴
13095정성태7/7/20226302Windows: 208. AD 도메인에 참여하지 않은 컴퓨터에서 Kerberos 인증을 사용하는 방법
13094정성태7/6/20225992오류 유형: 816. Golang - "short write" 오류 원인
13093정성태7/5/20226922.NET Framework: 2029. C# - HttpWebRequest로 localhost 접속 시 2초 이상 지연
13092정성태7/3/20227852.NET Framework: 2028. C# - HttpWebRequest의 POST 동작 방식파일 다운로드1
13091정성태7/3/20226676.NET Framework: 2027. C# - IPv4, IPv6를 모두 지원하는 서버 소켓 생성 방법
13090정성태6/29/20225807오류 유형: 815. PyPI에 업로드한 패키지가 반영이 안 되는 경우
13089정성태6/28/20226298개발 환경 구성: 646. HOSTS 파일 변경 시 Edge 브라우저에 반영하는 방법
13088정성태6/27/20225421개발 환경 구성: 645. "Developer Command Prompt for VS 2022" 명령행 환경의 폰트를 바꾸는 방법
13087정성태6/23/20228364스크립트: 41. 파이썬 - FastAPI / uvicorn 호스팅 환경에서 asyncio 사용하는 방법 [1]
13086정성태6/22/20227792.NET Framework: 2026. C# 11 - 문자열 보간 개선 2가지파일 다운로드1
13085정성태6/22/20227857.NET Framework: 2025. C# 11 - 원시 문자열 리터럴(raw string literals)파일 다운로드1
13084정성태6/21/20226466개발 환경 구성: 644. Windows - 파이썬 2.7을 msi 설치 없이 구성하는 방법
13083정성태6/20/20227037.NET Framework: 2024. .NET 7에 도입된 GC의 메모리 해제에 대한 segment와 region의 차이점 [2]
13082정성태6/19/20226102.NET Framework: 2023. C# - Process의 I/O 사용량을 보여주는 GetProcessIoCounters Win32 API파일 다운로드1
13081정성태6/17/20226154.NET Framework: 2022. C# - .NET 7 Preview 5 신규 기능 - System.IO.Stream ReadExactly / ReadAtLeast파일 다운로드1
13080정성태6/17/20226765개발 환경 구성: 643. Visual Studio 2022 17.2 버전에서 C# 11 또는 .NET 7.0 preview 적용
13079정성태6/17/20224547오류 유형: 814. 파이썬 - Error: The file/path provided (...) does not appear to exist
13078정성태6/16/20226561.NET Framework: 2021. WPF - UI Thread와 Render Thread파일 다운로드1
13077정성태6/15/20226893스크립트: 40. 파이썬 - PostgreSQL 환경 구성
13075정성태6/15/20225852Linux: 50. Linux - apt와 apt-get의 차이 [2]
13074정성태6/13/20226153.NET Framework: 2020. C# - NTFS 파일에 사용자 정의 속성값 추가하는 방법파일 다운로드1
13073정성태6/12/20226349Windows: 207. Windows Server 2022에 도입된 WSL 2
13072정성태6/10/20226649Linux: 49. Linux - ls 명령어로 출력되는 디렉터리 색상 변경 방법
13071정성태6/9/20227224스크립트: 39. Python에서 cx_Oracle 환경 구성
13070정성태6/8/20227041오류 유형: 813. Windows 11에서 입력 포커스가 바뀌는 문제 [1]
... 16  17  18  19  20  [21]  22  23  24  25  26  27  28  29  30  ...