Microsoft MVP성태의 닷넷 이야기
.NET Framework: 922. C# - .NET ThreadPool의 Local/Global Queue [링크 복사], [링크+제목 복사],
조회: 19466
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

(시리즈 글이 5개 있습니다.)
.NET Framework: 369. ThreadPool.QueueUserWorkItem의 실행 지연
; https://www.sysnet.pe.kr/2/0/1455

.NET Framework: 919. C# - 닷넷에서의 진정한 비동기 호출을 가능케 하는 I/O 스레드 사용법
; https://www.sysnet.pe.kr/2/0/12250

.NET Framework: 922. C# - .NET ThreadPool의 Local/Global Queue
; https://www.sysnet.pe.kr/2/0/12253

.NET Framework: 2010. C# - ThreadPool.SetMaxThreads 사용법
; https://www.sysnet.pe.kr/2/0/13058

.NET Framework: 2011. C# - CLR ThreadPool의 I/O 스레드에 작업을 맡기는 방법
; https://www.sysnet.pe.kr/2/0/13059




C# - .NET ThreadPool의 Local/Global Queue

오~~~ 멋진 글을 하나 읽어서 공유합니다. ^^

.NET ThreadPool starvation, and how queuing makes it worse
; https://medium.com/criteo-labs/net-threadpool-starvation-and-how-queuing-makes-it-worse-512c8d570527

위의 글에 포함된 링크를 보면 .NET ThreadPool의 Local/Global 큐에 대한 동작 방식을 확실히 알 수 있습니다. ^^

New and Improved CLR 4 Thread Pool Engine
; http://www.danielmoth.com/Blog/New-And-Improved-CLR-4-Thread-Pool-Engine.aspx

Work-Stealing in .NET 4.0
; https://learn.microsoft.com/en-us/archive/blogs/jennifer/work-stealing-in-net-4-0

정리해 보면, 스레드 풀은 1개의 Global Queue와 스레드 풀 내의 스레드 별로 1개씩의 Local Queue를 가집니다.

ThreadPool - Global Queue
    Thread #1 - LocalQueue
    Thread #2 - LocalQueue
    ...

그리고, 작업을 할당(Enqueue)할 때의 규칙은 이렇게 정리됩니다.

Global Queue에 추가하는 규칙
    - ThreadPool 외부의 스레드가 작업을 할당하는 경우
    -            내부의 스레드가 작업을 할당하는 경우
                    * ThreadPool.QueueUserWorkItem or ThreadPool.UnsafeQueueUserWorkItem
                    * Task.Factory.StartNew with the TaskCreationOptions.PreferFairness
                    * Task.Yield on the default task scheduler

Local Queue에 추가하는 규칙
    - 그 외의 모든 경우

가만 보면, 어차피 ThreadPool에 속하지 않은 외부의 스레드는 LocalQueue를 소유하고 있지 않기 때문에 GlobalQueue에 넣을 수밖에 없습니다. 그리고, 특별히 LocalQueue를 가지고 있는 ThreadPool 내의 스레드일지라도 3가지 규칙을 제외하고는 모두 LocalQueue에 넣는 정도로 이해하면 됩니다.

그리고, 이렇게 큐에 할당된 작업을 스레드 풀의 여유 스레드가 가져가는(Dequeue) 규칙은 이렇습니다.

ThreadPool 내의 스레드가 자유롭게 되면,
    - 해당 스레드의 LocalQueue에서 마지막 추가된(LIFO) 항목, 즉 큐의 tail에 있는 작업을 꺼내서 실행
        ; 마지막에 추가된 항목, 즉 최근 추가된 항목을 처리하는 이유는 cache의 locality에 따른 적중률을 높이기 위함
    - LocalQueue가 비었으면 GlobalQueue에서 오래된 항목(FIFO), 즉 큐의 head에 있는 작업을 꺼내서 실행
        ; 어차피 GlobalQueue라면 현재 스레드가 실행 중인 CPU의 cache 적중률이 높지 않을 것이므로 FIFO로 처리
    - GlobalQueue도 비었으면, 다른 스레드의 LocalQueue에서 오래된 항목(FIFO)을 꺼내서 실행
        ; 어차피 다른 스레드의 작업 항목이라면 마찬가지로 cache 적중률이 높지 않을 것이므로 FIFO 처리

보는 바와 같이 꽤나 상식적인 수준입니다. 우선 자신의 LocalQueue를 보고, 없으면 GlobalQueue를 보고, 그래도 없으면 ThreadPool 내의 다른 스레드가 소유한 LocalQueue를 보는 것입니다.




그런데, ".NET ThreadPool starvation, and how queuing makes it worse" 글에서 hang 현상에 빠지면서도 스레드는 계속 증가하는 재미있는 예제를 제시합니다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            ThreadPool.SetMinThreads(8, 8);

            Task.Factory.StartNew(
                Producer,
                TaskCreationOptions.None);

            Console.ReadLine();
        }

        static void Producer()
        {
            while (true)
            {
                Process();

                Thread.Sleep(200);
            }
        }

        static async Task Process()
        {
            await Task.Yield();

            var tcs = new TaskCompletionSource<bool>();

            Task.Run(() =>
            {
                Thread.Sleep(1000);
                tcs.SetResult(true);
            });

            tcs.Task.Wait();

            Console.WriteLine($"Ended - {DateTime.Now} {tid} {mid}");
        }

        static int tid => AppDomain.GetCurrentThreadId();
        static int mid => Thread.CurrentThread.ManagedThreadId;
    }
}

위의 프로그램을 실행하면 다음과 같은 몇 번의 출력이 있은 다음 hang 상태에 빠집니다. (출력의 횟수는 실행할 때마다 달라질 수 있습니다.)

Ended - 2020-07-02 오후 10:44:50 7408 4
Ended - 2020-07-02 오후 10:44:51 9424 6
Ended - 2020-07-02 오후 10:44:51 21152 7
Ended - 2020-07-02 오후 10:44:51 5264 5
Ended - 2020-07-02 오후 10:44:52 14436 8
Ended - 2020-07-02 오후 10:44:52 20184 9
Ended - 2020-07-02 오후 10:44:53 5264 5
Ended - 2020-07-02 오후 10:44:53 7408 4

재미있는 것은, hang 상태를 겪으면서도 스레드는 계속 증가한다는 점입니다. 찬찬히 이 현상을 분석해 볼까요? ^^ 처음 Producer 메서드를,

- Main 스레드가 Producer를 GlobalQueue에 추가
- 1번 ThreadPoolThread가 Producer 꺼내서 실행

이후 200ms 마다 (이상적인 조건 하에) 다음과 같은 동작을 하다 결국 hang 상태에 빠집니다.

    0 - Process를 GlobalQueue에 추가
        2번 ThreadPoolThread가 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        3번 ThreadPoolThread가 2번의 LocalQueue에 있는 작업을 실행 (1초 소모)
  200 - Process를 GlobalQueue에 추가
        4번 ThreadPoolThread가 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        5번 ThreadPoolThread가 4번의 LocalQueue에 있는 작업을 실행 (1초 소모)
  400 - Process를 GlobalQueue에 추가
        6번 ThreadPoolThread가 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        7번 ThreadPoolThread가 6번의 LocalQueue에 있는 작업을 실행 (1초 소모)
  600 - Process를 GlobalQueue에 추가
        8번 ThreadPoolThread가 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
  800 - Process를 GlobalQueue에 추가
 1000 - Process를 GlobalQueue에 추가
        3번 ThreadPoolThread가 2번의 LocalQueue에 있는 작업 실행 완료 후, 
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        2번 ThreadPoolThread가 깨어나고,
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        9번 ThreadPoolThread 추가
            Local/Global 모두 비어 있으므로 다른 스레드(예: 8번)의 LocalQueue에 있는 작업을 실행 (1초 소모)
 1200 - Process를 GlobalQueue에 추가
        5번 ThreadPoolThread가 4번의 LocalQueue에 있는 작업 실행 완료 후, 
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        4번 ThreadPoolThread가 깨어나고,
            Local/Global 모두 비어 있으므로 다른 스레드(예: 3번)의 LocalQueue에 있는 작업을 실행 (1초 소모)
 1400 - Process를 GlobalQueue에 추가
        7번 ThreadPoolThread가 6번의 LocalQueue에 있는 작업 실행 완료 후, 
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        6번 ThreadPoolThread가 깨어나고,
            Local/Global 모두 비어 있으므로 다른 스레드(예: 5번)의 LocalQueue에 있는 작업을 실행 (1초 소모)
 1600 - Process를 GlobalQueue에 추가
 1800 - Process를 GlobalQueue에 추가
 2000 - Process를 GlobalQueue에 추가
        9번 ThreadPoolThread가 8번의 LocalQueue에 있는 작업 실행 완료 후, 
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        8번 ThreadPoolThread가 깨어나고,
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
       10번 ThreadPoolThread 추가
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
 2200 - Process를 GlobalQueue에 추가
        4번 ThreadPoolThread가 3번의 LocalQueue에 있는 작업 실행 완료 후, 
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        3번 ThreadPoolThread가 깨어나고,
            Local/Global 모두 비어 있으므로 다른 스레드(예: 7번)의 LocalQueue에 있는 작업을 실행 (1초 소모)
 2400 - Process를 GlobalQueue에 추가
        6번 ThreadPoolThread가 5번의 LocalQueue에 있는 작업 실행 완료 후, 
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
        5번 ThreadPoolThread가 깨어나고,
            Local/Global 모두 비어 있으므로 다른 스레드(예: 9번)의 LocalQueue에 있는 작업을 실행 (1초 소모)
 2600 - Process를 GlobalQueue에 추가
 2800 - Process를 GlobalQueue에 추가
 3000 - Process를 GlobalQueue에 추가
       11번 ThreadPoolThread 추가
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
 3200 - Process를 GlobalQueue에 추가
        3번 ThreadPoolThread가 7번의 LocalQueue에 있는 작업 실행 완료 후, 
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
 3400 - Process를 GlobalQueue에 추가
        5번 ThreadPoolThread가 9번의 LocalQueue에 있는 작업 실행 완료 후, 
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
 3600 - Process를 GlobalQueue에 추가
 3800 - Process를 GlobalQueue에 추가
 4000 - Process를 GlobalQueue에 추가
       12번 ThreadPoolThread 추가
            GlobalQueue로부터 Process를 실행하고, LocalQueue에 Thread.Sleep(1000)을 포함하는 작업 추가
 4200 - Process를 GlobalQueue에 추가
 4400 - Process를 GlobalQueue에 추가
 4600 - Process를 GlobalQueue에 추가
 4800 - Process를 GlobalQueue에 추가
    .... 이후 반복 (LocalQueue에 있는 Thread.Sleep(1000)을 포함하는 작업을 꺼내올 스레드가 없어 모든 스레드 풀의 스레드가 작업이 완료되지 않은 체로 hang)

좀 지겹게 긴데 ^^ 간단하게 요약해 보면, SetMinThreads를 8로 지정했기 때문에 처음 8개의 스레드는 스레드 풀 내에서 요청이 있으면 곧바로 생성되지만, 그 이후의 스레드는 필요하면 1초 정도의 지연 후에 스레드 풀에 추가되므로 1초에 5개의 작업이 생성되는 것을 못 따라갑니다. 문제는, 못 따라가는 것뿐만 아니라 해당 Task가 종료되기 위해 내부에서 다시 생성한 Task의 작업을 담당할 스레드가 없게 되어 결국 hang이 걸릴 수밖에 없는 것입니다.

이 현상을 없애려면, Process 메서드 작업을 Global Queue가 아닌 Local Queue에 등록하도록 하면 됩니다. 따라서 소스 코드 중 (Global Queue에 등록하는) Task.Yield를 (Local Queue로 등록하는) Task.Factory.StartNew로 대체하면 hang 현상이 해결됩니다.

static void Producer()
{
    while (true)
    {
        // Process 작업을 Local Queue에 추가
        Task.Factory.StartNew(Process);
        Thread.Sleep(200);
    }
}

static async Task Process()
{
    // Removed the Task.Yield

    var tcs = new TaskCompletionSource();
    Guid guid = Guid.NewGuid();

    Task.Run(() =>
    {
        Thread.Sleep(1000);
        tcs.SetResult(true);
    });

    tcs.Task.Wait();

    Console.WriteLine($"Ended - {guid} {DateTime.Now} {tid} {mid}");
}

물론 위의 소스 코드에서도 Task.Factory.StartNew에 TaskCreationOptions.PreferFairness 옵션을 주면 Global Queue에 추가를 하므로 다시 hang 현상이 발생합니다.

static void Producer()
{
    while (true)
    {
        Task.Factory.StartNew(Process, TaskCreationOptions.PreferFairness); // Global Queue에 추가하므로 hang 현상 발생
        Thread.Sleep(200);
    }
}




위의 문제를 얼핏 보면, 개발자가 그럼 Global Queue와 Local Queue에 대한 것도 감안해서 코딩을 해야 하느냐...라는 질문을 할 수 있습니다. 물론 그렇긴 한데, 보다 더 간단하게 "스레드 풀 내의 스레드에서 다른 작업의 완료를 기다리는 처리는 주의해야 한다"라는 것으로 원칙을 삼으면 됩니다.

그러고 보니, 이와 유사한 문제를 전에도 async/await을 다루면서 설명한 적이 있습니다. ^^

async/await 사용 시 hang 문제가 발생하는 경우
; https://www.sysnet.pe.kr/2/0/1541

참고로, 이 문제는 Global/Local 큐에 대한 규칙과 함께 ThreadPool에 신규 스레드를 추가하는데 1초 정도의 지연이 발생하는 문제가 함께 겹쳐서 발생하는 것이기도 합니다.

ThreadPool.QueueUserWorkItem의 실행 지연
; https://www.sysnet.pe.kr/2/0/1455

따라서, "원 글"에서 제시한 방법인 Global Queue를 사용하지 않도록 하는 것 외에도, 초기 스레드 풀의 스레드 수를 넉넉하게 지정하는 해결책도 있습니다. 즉, 문제가 되었던 코드에서 SetMinThreads를 다음과 같이 바꿔도 hang 현상 없이 정상적으로 잘 서비스가 됩니다.

ThreadPool.SetMinThreads(30, 30);

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




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







[최초 등록일: ]
[최종 수정일: 5/29/2023]

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

비밀번호

댓글 작성자
 




... 151  152  153  154  155  156  157  158  159  160  161  162  [163]  164  165  ...
NoWriterDateCnt.TitleFile(s)
972정성태1/7/201124192개발 환경 구성: 95. SQL Server 2008 R2 이하 버전 정보 확인
971정성태1/5/201133764.NET Framework: 199. .NET 코드 - Named Pipe 닷넷 서버와 VC++ 클라이언트 제작 [2]파일 다운로드1
970정성태1/4/201134292.NET Framework: 198. 윈도우 응용 프로그램에 Facebook 로그인 연동 [1]파일 다운로드1
969정성태12/31/201040365VC++: 45. Winsock 2 Layered Service Provider - Visual Studio 2010용 프로젝트 [1]파일 다운로드1
968정성태12/30/201026652개발 환경 구성: 94. 개발자가 선택할 수 있는 윈도우에서의 네트워크 프로그래밍 기술 [2]
967정성태12/27/201028426.NET Framework: 197. .NET 코드 - 단일 Process 실행파일 다운로드1
966정성태12/26/201026380.NET Framework: 196. .NET 코드 - 창 흔드는 효과파일 다운로드1
965정성태12/25/201027890개발 환경 구성: 93. MSBuild를 이용한 닷넷 응용프로그램의 다중 어셈블리 출력 빌드파일 다운로드1
964정성태12/21/2010143047개발 환경 구성: 92. 윈도우 서버 환경에서, 최대 생성 가능한 소켓(socket) 연결 수는 얼마일까? [14]
963정성태12/13/201027909개발 환경 구성: 91. MSBuild를 이용한 닷넷 응용프로그램의 플랫폼(x86/x64)별 빌드 [2]파일 다운로드1
962정성태12/10/201022766오류 유형: 110. GAC 등록 - Failure adding assembly to the cache: Invalid file or assembly name.
961정성태12/10/201099809개발 환경 구성: 90. 닷넷에서 접근해보는 PostgreSQL DB [5]
960정성태12/8/201045131.NET Framework: 195. .NET에서 코어(Core) 관련 CPU 정보 알아내는 방법파일 다운로드1
959정성태12/8/201031955.NET Framework: 194. Facebook 연동 - API Error Description: Invalid OAuth 2.0 Access Token
958정성태12/7/201028958개발 환경 구성: 89. 배치(batch) 파일에서 또 다른 배치 파일을 동기 방식으로 실행 및 반환값 얻기 [2]
957정성태12/6/201031715디버깅 기술: 31. Windbg - Visual Studio 디버그 상태에서 종료해 버리는 응용 프로그램 [3]
953정성태11/28/201036928.NET Framework: 193. 페이스북(Facebook) 계정으로 로그인하는 C# 웹 사이트 제작 [5]
952정성태11/25/201025356.NET Framework: 192. GC의 부하는 상대적인 것! [4]
950정성태11/18/201076726.NET Framework: 191. ClickOnce - 관리자 권한 상승하는 방법 [17]파일 다운로드2
954정성태11/29/201048716    답변글 .NET Framework: 191.1. [답변] 클릭원스 - 요청한 작업을 수행하려면 권한 상승이 필요합니다. (Exception from HRESULT: 0x800702E4) [2]
949정성태11/16/201027254오류 유형: 109. System.ServiceModel.Security.SecurityNegotiationException
948정성태11/16/201036084.NET Framework: 190. 트위터 계정으로 로그인하는 C# 웹 사이트 제작 [7]파일 다운로드1
947정성태11/14/201041697.NET Framework: 189. Mono Cecil로 만들어 보는 .NET Decompiler [1]파일 다운로드1
946정성태11/11/201041554.NET Framework: 188. .NET 64비트 응용 프로그램에서 왜 (2GB) OutOfMemoryException 예외가 발생할까? [1]파일 다운로드1
945정성태11/11/201025057VC++: 44. C++/CLI 컴파일 오류 - error C4368: mixed types are not supported
944정성태11/11/201031589VC++: 43. C++/CLI 컴파일 오류 - error C2872: 'IServiceProvider' : ambiguous symbol could be ...
... 151  152  153  154  155  156  157  158  159  160  161  162  [163]  164  165  ...