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

.NET 응용 프로그램에 기본 생성되는 스레드들에 대한 탐구

이론을 배웠으면, 검증하고 싶은 마음이 생기는데요. ^^ 이번엔, .NET 응용 프로그램에서 생성되는 '기본 스레드들'에 대해서 탐구해 볼까 합니다.

복잡한 응용 프로그램에서는 테스트하기가 어려우니까, 간단하게 "Console Application"으로 분석을 해보겠습니다.

검증 작업을 돕기 위해 몇 가지 유틸리티 함수가 필요한데요. 우선, 현재 프로그램에 실행 중인 스레드 목록을 다음과 같이 확인해 볼 수 있습니다.

static void OutputThreads()
{
    Console.WriteLine();
    int n = 0;
    foreach (ProcessThread thread in Process.GetCurrentProcess().Threads)
    {
        Console.WriteLine("[" + n + "] " + thread.Id + ", (0x" + thread.Id.ToString("x") + ")");
        n++;
    }
}

실행해 보면, 다음과 같은 식으로 결과를 보여줍니다.

[0] 6712, (0x1a38)
[1] 8976, (0x2310)
[2] 9244, (0x241c)
[3] 2280, (0x8e8)

이렇게 실행된 프로세스에 windbg로 연결하면 스레드 목록을 확인할 수 있습니다.

0:004> ~*
   0  Id: 52c.1a38 Suspend: 1 Teb: 7efdd000 Unfrozen
      Start: *** WARNING: Unable to verify checksum for D:\...\Debug\ConsoleApplication1.exe
ConsoleApplication1!COM+_Entry_Point <PERF> (ConsoleApplication1+0x2a9e) (011c2a9e) 
      Priority: 0  Priority class: 32  Affinity: ff
   1  Id: 52c.2310 Suspend: 1 Teb: 7efda000 Unfrozen
      Start: clr!DebuggerRCThread::ThreadProcStatic (7261b30c) 
      Priority: 0  Priority class: 32  Affinity: ff
   2  Id: 52c.241c Suspend: 1 Teb: 7efd7000 Unfrozen
      Start: clr!Thread::intermediateThreadProc (726fc018) 
      Priority: 2  Priority class: 32  Affinity: ff
   3  Id: 52c.08e8 Suspend: 1 Teb: 7efaf000 Unfrozen
      Start: ntdll!TppWaiterpThread (77a541f3) 
      Priority: 0  Priority class: 32  Affinity: ff
.  4  Id: 52c.128c Suspend: 1 Teb: 7efac000 Unfrozen
      Start: ntdll!DbgUiRemoteBreakin (77aaf7ea) 
      Priority: 0  Priority class: 32  Affinity: ff

원래 4개였다가 windbg를 붙이면서 5개가 되었는데, 마지막 4번 Start 함수의 ntdll!DbgUiRemoteBreakin이라는 이름에서 디버거용 스레드가 해당 프로세스에 생성된 것임을 미뤄 짐작할 수 있습니다. (따라서 4번은 무시하고!)

그 외에, 0번은 Console Application의 '주 스레드'임이 확실할 것 같고.

남은 것은 1~3번 스레드이지만, SOS 확장 DLL의 도움을 받아서 "!threads" 명령어를 실행해 보면 다음과 같이 2번 스레드가 "Finalizer"라는 것도 알 수 있습니다.

0:004> !threads
ThreadCount:      2
UnstartedThread:  0
BackgroundThread: 1
PendingThread:    0
DeadThread:       0
Hosted Runtime:   no
                                   PreEmptive   GC Alloc                Lock
       ID  OSID ThreadOBJ    State GC           Context       Domain   Count APT Exception
   0    1  1a38 006c7710      a020 Enabled  02822310:02823fe8 006bc7f0     1 MTA
   2    2  241c 006d25e0      b220 Enabled  00000000:00000000 006bc7f0     0 MTA (Finalizer)

자... 이제 남은 것은 1번과 3번 스레드군요.




사실, 제가 목표한 것은 1번과 3번 중의 하나는 Concurrent GC 스레드일 것이라고 생각했었습니다. 기본적으로 app.config에 아무런 GC 설정 없이 응용 프로그램을 실행하면 적용되는 것이 Concurrent-GC인데, 별도로 2세대 힙만을 전용으로 GC하는 스레드가 존재하는 유형입니다. 이에 대해서는 유경상 님이 쓰신 GC 관련 글에서 다음의 이미지가 잘 나타내 주고 있습니다.

[Concurrent-GC 모드 (출처: http://www.simpleisbest.net/post/2011/04/25/Garbage-Collection-Modes.aspx)]
thread_type_1.png

그리고, 또 한 가지는 (제 예상으로) ThreadPool을 담당하는 스레드입니다. 전용 ThreadPool을 만들어 보신 분들은 아시겠지만, ThreadPool을 구현하려면 그것만을 담당하는 스레드 하나가 있어야만 합니다. 그래야, 스레드 풀 내에 maxThreads 수만큼 늘어난 스레드 수가 할 일이 없을 때 다시 minThreads 수로 줄어드는 식의 구현을 할 수 있기 때문입니다.

위의 가정을 바탕으로 테스트를 진행해 볼 텐데요. ^^

우선, 그중에 하나가 ThreadPool을 관리하는지 테스트해 보려면 어떻게 해야 할까요?

제 생각에는, 보통의 상태에서 ThreadPool.QueueUserWorkItem 메서드를 수행하는 것과 1번과 3번 스레드를 강제로 죽인 상황에서 다시 ThreadPool.QueueUserWorkItem을 실행한 경우와 비교해 보면 될 것 같았습니다.

그런데, 직접 해보면 2가지 상황 모두 ThreadPool.QueueUserWorkItem으로 정상적으로 새롭게 스레드가 생성이 되고, 심지어 생성된 스레드들은 할 일을 마치고 정확히 20초 후에 종료되는 관리적인 면까지 동일하게 수행되었습니다. 아래는 제가 이에 대해 테스트한 간단한 콘솔 응용 프로그램입니다.

[정상적인 스레드들이 운용되고 있을 때의 스레드 풀 사용 변화]
thread_type_2.png

[2개의 스레드를 종료한 이후 스레드 풀을 사용했을 때의 변화]
thread_type_3.png

음... 아쉽게도 증명에 실패했습니다. ^^; 일단 더 테스트해 볼 수 있는 시나리오가 생각나지 않으므로 이에 대해서는 넘어가고.




그다음, 1번과 3번 중의 하나는 Concurrent GC용 스레드일 것이라는 가정을 테스트해 보겠습니다.

방법은 간단한데요. Concurrent GC 스레드가 2세대 힙을 GC하는 역할을 하기 때문에 1번과 3번 스레드를 모두 죽인 후, 2 세대 GC 카운트가 여전히 발생하는지를 테스트해 보면 됩니다. 코드로는 다음의 2가지 메서드를 상황에 따라 실행하면 되는 작업입니다.

private static void HeapAllocation()
{
    Console.WriteLine("Old Count: " + OutputGCCount());
    for (int i = 0; i < 1000; i++)
    {
        byte[] contents = new byte[1024 * 50];
    }
    Console.WriteLine("New Count: " + OutputGCCount());
}

static string OutputGCCount()
{
    int gen0 = GC.CollectionCount(0);
    int gen1 = GC.CollectionCount(1);
    int gen2 = GC.CollectionCount(2);

    return ("Gen 0: " + gen0 + ", Gen1: " + gen1 + ", Gen2: " + gen2);
}

아래는 실제로 제가 테스트해 본 과정입니다.

thread_type_4.png

만약, 그 두 개 중의 하나가 Concurrent-GC였다면 위와 같이 2세대 힙에 대한 GC가 발생하는 일은 없었어야 합니다. (물론, 스레드의 수에 변화가 없이 2세대 힙에 대한 GC가 발생했습니다.) 음... 이번에도 역시 증명에 실패했군요. ^^;

혹시, Concurrent-GC를 사용하지 않겠다고 app.config에 명시하면 어떻게 될까요? 초기 스레드 수가 줄어들까요? 확인을 위해 app.config에 다음과 같이 명시해봤습니다.

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <runtime>
        <gcConcurrent enabled="false" />
    </runtime>
</configuration>

위와 같이 설정하게 되면, Non-Concurrent-GC가 동작하게 되고 이렇게 되면 응용 프로그램 내의 스레드가 new를 할 때마다 힙이 부족한 경우, 그런 상황을 유발한 그 스레드를 제외하고 다른 모든 Managed 스레드들은 멈추게 되고 new 할당을 시도하려던 스레드가 직접 GC를 수행하기 때문에 별도의 스레드가 필요가 없어서 예상대로라면 3개의 기본 스레드로 응용 프로그램이 시작되어야 합니다.

그러나 실행해 봤지만, 여전히 응용 프로그램 시작 시 4개의 스레드가 생성되는 것에는 변함이 없었습니다.




이 글을 시작할 때만 해도, 4개의 스레드에 대한 모든 정체를 밝히리라 ^^ 다짐했었는데, 결국 2개의 스레드만 정체를 밝히고 나머지는 더욱 혼란만 가중시켜버린 것 같습니다. 혹시, 이 글을 읽는 분들 중에서 나머지 2개에 대한 스레드의 정체를 알고 계신 분이 있거나, 혹은 정체를 밝힐 수 있는 기발한 아이디어가 있다면 덧글 부탁드리겠습니다.

(위에서 제가 한 테스트를 여러분들도 하실 수 있도록, 첨부 파일에 코드를 포함시켜 두었습니다.)




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

[연관 글]






[최초 등록일: ]
[최종 수정일: 7/7/2021]

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

비밀번호

댓글 작성자
 



2019-04-24 09시03분
using System;
using System.Diagnostics;
using System.Threading;

namespace Program
{
    public class Program
    {
        public static void Main(string[] args)
        {
            ThreadPool.GetMinThreads(out int workerThreads, out int completionPortThreads);
            Console.WriteLine($"workerThreads: {workerThreads}");

            OutputThreads();

            Console.WriteLine("Attach WinDBG and check!");
            Console.ReadLine();
        }

        static void OutputThreads()
        {
            int id = AppDomain.GetCurrentThreadId();

            Console.WriteLine();
            int n = 0;
            foreach (ProcessThread thread in Process.GetCurrentProcess().Threads)
            {
                string threadType = "(ThreadPool)";
                if (id == thread.Id)
                {
                    threadType = "(Managed)";
                }

                if (thread.BasePriority == 10)
                {
                    threadType = "(Finalizer)";
                }

                Console.WriteLine($"[{n}] {thread.Id}, (0x" + thread.Id.ToString("x") + $") {threadType}");
                n++;
            }
        }
    }
}
정성태

... 106  107  108  109  110  [111]  112  113  114  115  116  117  118  119  120  ...
NoWriterDateCnt.TitleFile(s)
11149정성태2/21/201723014오류 유형: 378. A 64-bit test cannot run in a 32-bit process. Specify platform as X64 to force test run in X64 mode on X64 machine.
11148정성태2/20/201721962.NET Framework: 644. AppDomain에 대한 단위 테스트 시 알아야 할 사항
11147정성태2/19/201721200오류 유형: 377. Windows 10에서 Fake 어셈블리를 생성하는 경우 빌드 시 The type or namespace name '...' does not exist in the namespace 컴파일 오류 발생
11146정성태2/19/201719855오류 유형: 376. Error VSP1033: The file '...' does not contain a recognized executable image. [2]
11145정성태2/16/201721336.NET Framework: 643. 작업자 프로세스(w3wp.exe)가 재시작되는 시점을 알 수 있는 방법 - 두 번째 이야기 [4]파일 다운로드1
11144정성태2/6/201724676.NET Framework: 642. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (부록 1) - CallingConvention.StdCall, CallingConvention.Cdecl에 상관없이 왜 호출이 잘 될까요?파일 다운로드1
11143정성태2/5/201722089.NET Framework: 641. [Out] 형식의 int * 인자를 가진 함수에 대한 P/Invoke 호출 방법파일 다운로드1
11142정성태2/5/201730085.NET Framework: 640. 닷넷 - 배열 크기의 한계 [2]파일 다운로드1
11141정성태1/31/201724367.NET Framework: 639. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (4) - CLR JIT 컴파일러의 P/Invoke 호출 규약 [1]파일 다운로드1
11140정성태1/27/201720129.NET Framework: 638. RSAParameters와 RSA파일 다운로드1
11139정성태1/22/201722808.NET Framework: 637. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (3) - x64 환경의 __fastcall과 Name mangling [1]파일 다운로드1
11138정성태1/20/201721069VS.NET IDE: 113. 프로젝트 생성 시부터 "Enable the Visual Studio hosting process" 옵션을 끄는 방법 - 두 번째 이야기 [3]
11137정성태1/20/201719788Windows: 135. AD에 참여한 컴퓨터로 RDP 연결 시 배경 화면을 못 바꾸는 정책
11136정성태1/20/201718990오류 유형: 375. Hyper-V 내에 구성한 Active Directory 환경의 시간 구성 방법 - 두 번째 이야기
11135정성태1/20/201719977Windows: 134. Windows Server 2016의 작업 표시줄에 있는 시계가 사라졌다면? [1]
11134정성태1/20/201727400.NET Framework: 636. System.Threading.Timer를 이용해 타이머 작업을 할 때 유의할 점 [5]파일 다운로드1
11133정성태1/20/201723508.NET Framework: 635. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (2) - x86 환경의 __fastcall [1]파일 다운로드1
11132정성태1/19/201734993.NET Framework: 634. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (1) - x86 환경에서의 __cdecl, __stdcall에 대한 Name mangling [1]파일 다운로드1
11131정성태1/13/201723943.NET Framework: 633. C# - IL 코드 분석을 위한 팁 [2]
11130정성태1/11/201724437.NET Framework: 632. x86 실행 환경에서 SECURITY_ATTRIBUTES 구조체를 CreateEvent에 전달할 때 예외 발생파일 다운로드1
11129정성태1/11/201728827.NET Framework: 631. async/await에 대한 "There Is No Thread" 글의 부가 설명 [9]파일 다운로드1
11128정성태1/9/201723279.NET Framework: 630. C# - Interlocked.CompareExchange 사용 예제 [3]파일 다운로드1
11127정성태1/8/201722796기타: 63. (개발자를 위한) Visual Studio의 "with MSDN" 라이선스 설명
11126정성태1/7/201727536기타: 62. Edge 웹 브라우저의 즐겨찾기(Favorites)를 편집/백업/복원하는 방법 [1]파일 다운로드1
11125정성태1/7/201724367개발 환경 구성: 310. IIS - appcmd.exe를 이용해 특정 페이지에 클라이언트 측 인증서를 제출하도록 설정하는 방법
11124정성태1/4/201727816개발 환경 구성: 309. 3년짜리 유효 기간을 제공하는 StartSSL [2]
... 106  107  108  109  110  [111]  112  113  114  115  116  117  118  119  120  ...