.NET Console 프로젝트에서의 ThreadPool 낭비?

.NET에는 SynchronizationContext가 있다는 것을 다들 아실 거 같습니다.

Console Application은 SynchronizationContext가 null이라서 await 이후 Thread ID가 변경된 상태로 이후의 코드들이 쭉 진행되고,

Windows Forms, WPF, ASP.NET은 각자 SynchronizationContext 구현체가 기본적으로 존재하기 때문에 await 이후로 원래 Thread로 실행 Context가 복귀되기 때문에 Thread ID가 변경되지 않는 현상을 볼 수 있습니다.

여담으로, 저는 웹을 하지 않아서 몰랐는데 ASP.NET Core의 경우에도 Console Application 때처럼 SynchronizationContext가 없다는 소식을 접하게 되어, 검색해봤더니 진짜로 없는 거 같습니다.

아무튼 각 .NET의 각 프로젝트마다 구현이 다른 것이 문제라면 문제일 수 있겠으나, 이 글에서 궁금한 것은 아래와 같은데요.

SynchronizationContext가 null인 어떤 .NET 프로세스의 환경에 대해서 TAP방식을 이용하여 Worker Thread를 Thread Pool에서 가져다 썼을 경우, 다시 await를 호출하여 다른 Thread로 변경하지 않는다면 Thread Pool의 Thread를 계속 점유하는 것인가?

입니다.

Thread Pool은 제가 알기로는 기본적으로 .NET Process가 시작될 때 4개를 갖고 시작하는 것으로 알고 있으며 장시간 Thread Pool에서 Thread를 가져다가 사용하지 않을 경우 Thread Pool의 Thread도 1개까지 내려가는 것으로 알고 있습니다.

상황을 가정하겠습니다. 상황이 맞는지 검토해주시면 감사하겠습니다.

Main Thread - M Thread
Thread Pool Thread - A, B, C, D Thread

  1. MainThread를 Entry Point로 진입.
    • M Thread 1개 사용
    • A,B,C,D Thread 대기
  2. await 를 1번 사용
    • M Thread 1개 노는 중
    • B,C,D Thread 대기 중
    • A Thread 사용 중
    • MainThread 놀고 있음???
  3. await Task.WhenAll 로 작업시간이 충분히 긴 Task 4개 호출. D Thread가 가장 마지막으로 끝난다고 가정.
    • M Thread 1개 노는 중
    • B,C,D Thread 사용 중
    • E Thread가 Thread Pool에서 새로 생성되어 사용 중
    • Thread ID를 확인할 시점에 Context가 가장 마지막에 끝나는 D Thread일 것이므로 A Thread는 노는 중
  4. 현재 상태
    • M Thread 노는 중
    • A Thread 대기하다가 Thread Pool로 반환
    • B,C,E는 대기하다가 Thread Pool로 반환
    • 현재 D Thread에서 코드 실행 중

이 가정이 맞을까요? 그러면 SynchronizationContext가 없는 경우에는 TAP를 한번이라도 한다면 Main Thread는 계속 놀게 되는 것일까요?

8개의 좋아요

문서를 찾아본 것은 아니고 동작 원리? 랑 간단한 샘플링을 통해서 얻은 결론은 Main Thread는 계속 논다 입니다; await이후 사용하여야 할 스레드 목록이 ThreadPool에서 관리하고 있는데 Main Thread는 ThreadPool에서 관리하는 스레드가 아니기 때문입니다.

4개의 좋아요

아 제가 질문을 덜 드렸네요.
우선 의견 감사드립니다.

Main Thread가 Thread Pool의 Thread가 아닌 것은 알고 있는데 놀게 되냐고 여쭤봤던 것은,

기본적으로 Thread Pool에서 생성된 Thread가 Long Running으로 선언된 것이 아니라서 Thread Pool에 포함된 Thread인지 체크하면 맞다라고 나오는데(Long Running으로 동작하는 Task Thread는 Thread Pool의 Thread가 아니라고 나오죠.),

Thread Pool의 자원인 Thread를 계속해서 하나를 점유하게 되는 것이므로 고작 1개 Thread의 자원 낭비이지만 굳이 자원 낭비의 측면으로 바라볼 경우 왜 이렇게 디자인했을까? 이런 부분이 궁금할 것 같아서입니다.

별거 아닌거 같아도 깐깐하게 보시는 분들은 그런 생각이 드실 수도 있을 듯하고 저조차도 이제는 그렇게도 볼 수 있지 않나 라는 생각이 있었기 때문입니다…!

글쎄요. 정확한 이유를 찾기 위해서는 await에서 소비되는 ThreadPool의 Thread 스텁(Stub… 스텁이라고 읽는것 맞나요?)을 살펴봐야 할 텐데요, Main Thread는 프로세스가 시작하면서 생성되기 때문에 스텁을 구성하지 못하고 그래서 Thread Pool의 구성원이 될 수 없다고 전제하면… 이유는 명확할 것 같습니다.

여기서 스텁은… 실행해야 할 대리(delegate) 코드가 종료하면 다시 대기 상태로 머무르는 코드인데, Main Thread에서는 그것을 구성할 수 없기 때문 같아요.

– 정확한 것은 Thread Pool에서 Thread를 빌려주고 반환하는 원리-및 코드를 살펴봐야 Main Thread로 반환할 수 없는 이유가 나올것 같아요 ^^

2개의 좋아요

흥미롭군요.

main도 Task를 반환 할 수 있으니
메인문에서 await 을 사용하는 경우 메인 쓰래드를 쓰래드 풀에 돌려줄 거 같은데요.

2개의 좋아요

저도 정확하게 뜯어보지는 않아서 모르겠지만, 본래의 Thread로 회귀하는 기능을 가지는 것이 SynchronizationContext인데 이것이 Console Project는 null이라 돌아올 수 없는 것으로 알고 있습니다.

아마도 말씀하신 MainThread로 돌아오는 기능을 구현한다면 SynchronizationContext가 abstract class 라서 여기에 복귀하는 기능을 만들면 될 거 같습니다. 검색해보면 SynchronizationContext를 상속하여 구현하는 방법도 나와있으니까요. 물론 쉽지 않겠지만!

(SynchronizationContext는 확인해보니 abstract class가 아니네요 저의 착오)

그런데 태생부터 왜 MS가 디자인을 이렇게 했을까가 궁금했던 부분이었습니다. 당연히 만드신 당사자분들이 이 포럼을 볼 확률이 적을 거라서 .NET HERO 분들의 의견이 궁금했습니다 ㅎㅎ

왜 Console Application은 SynchronizationContext가 기본적으로 null이 되도록 설계했으며, WindowsFormsSynchronizationContext, DispatcherSynchronizationContext, AspNetSynchronizationContext 는 구현을 했기 때문에 본래 Thread로 복귀하게끔 만들었으며, 왜 최신 버전인 ASP.NET Core에서는 다시 빼버렸는지 궁금하네요.

왜 굳이 Thread Pool의 Thread 1개를 잡아놔서 1개만큼의 Overhead를 만들어내도록 만들었는지…그런 이유들이요 ㅎㅎ

2개의 좋아요

SynchronizationContext는 dotnet 의 디자인 결함인데요.

처음 필요했던 이유는 UI Thread 때문이었죠.

UI Thread는 UI에 접근할 수 있는 유일한 쓰래드이고 Task 작업이 완료됐을 때 UI Thread로 돌아갈 필요가 있었습니다.

UI Thread를 다룰 이유가 없는 콘솔이나 Asp.net의 경우 SynchronizationContext가 불필요한

4개의 좋아요

본 질문에 대한 답은 @dimohy 님의 의견으로 갈음 하고,
위 인용문에 대한 답변을 제 개인적인 생각으로 말씀 드리자면.,

  1. 왜 Console Application은 SynchronizationContext가 기본적으로 null 인가?
    => 아시는 것 처럼 await 이후의 구문은 SynchronizationContext 에게 작업을 맡깁니다.

    그런데 UI 에 관련된 작업은 반드시 해당 UI를 생성한 스레드에서만 접근해야 합니다. (그렇지 않으면 크로스스레드 오류가 발생 합니다.)
    이런 이유로 보았을때 당연하게도 Console 환경은 UI가 없으므로 SynchronizationContext 는 null 인 것이 당연합니다.

    만약 윈폼, WPF 환경에서 SynchronizationContext 제공이 없었다면 async / await 구문과 동시에 BeginInvoke 같은 문법을 동시에 사용하는 번거로움이 있었을 것입니다.

  2. 왜 WindowsFormsSynchronizationContext, DispatcherSynchronizationContext AspNetSynchronizationContext 가 각각 존재?
    => 윈폼, WPF, ASP.NET(구 웹폼) 에서 동기화 스레드를 다루는 방식이 모두 달라서 이기 때문입니다. 그래서 이런 상황에서 공통으로 사용 할 수 있도록 만들어 진 것이 SynchronizationContext 이고, BackgroundWorker 클래스가 실제로 SynchronizationContext 를 사용해서 윈폼/WPF/ASP.NET 환경에서 공통으로 UI접근 스레드를 처리 할 수 있습니다.

  3. 왜 최신 버전인 ASP.NET Core 에 다시 빼버렸는지
    => 이전 ASP.NET 환경에선 윈폼과 비슷하게 UI 처리 (서버 컨트롤) 를 하는 환경이 있습니다. 웹폼 이라고 불리우는데 아마도 이 환경에서는 윈폼과 같이 SynchronizationContext 가 필수였던 것 같습니다.
    하지만 Core로 와서 MVC 아키텍처 패턴이 도입되어 레이저 문법 등이 도입되어 바인딩으로 처리 되기에 스레드에 UI 접근이 불필요 하여, SynchronizationContext 는 다시 빠진것이 아닐지 추측합니다.

6개의 좋아요

MSDN Magazine: Parallel Computing - It’s All About the SynchronizationContext | Microsoft Learn

위 페이지에 대부분의 답이 있을 있을 것 같습니다.

그런데, 위 글이 더 이상 MS에서 업데이트 않는다고 표시되듯이 SynchronizationContext 는 async/await 에 충분히 녹아 들었지 않았나 하는 생각이 듭니다.

참고로, 아래 내용과 관련해서는,

async Main 은 컴파일러에 의해 아래와 같이 변경되므로,

private static void $GeneratedMain() => Main().GetAwaiter().GetResult();
static async Task Main() => await DoAsync();

이 앱이 스레드풀을 기반한 SynchronizationContext 를 채택하고 있다면, Main 은 스레드풀 스레드에 의해 실행된다고 보는 것이 맞을 것 같습니다.

메인 스레드 : $GeneratedMain
스레드풀 스레드1 : Main

닷넷의 비동기 지원은 백그라운드 스레드(스레드 풀 스레드) 기반입니다.

비동기 문법을 위해 존재하는 스레드 풀이 낭비적인 것이라 생각된다면, 비동기 코드를 작성하지 않거나, 아주 옛날 병렬 실행 API를 사용하면 될 것 같습니다.

2개의 좋아요

아, @Chris_Shim 님도 디자인 결함으로 보시는군요…
의견 감사합니다.

하긴 이게 있었네요. 이번에도 지난 번 GC를 관리 코드로 착각했던 것처럼 아는 범위 내에서 뇌절을 일으켰네요…ㄷㄷ

1개의 좋아요

역시…정리해주셔서 감사합니다.

방향잃고 뇌절하다가 다시 되돌아온 거 같습니다.

이 부분은 웹맛을 못봐서 전혀 생각하지 못했었는데 그럴 수 있겠다 생각이 드는군요. 웹이라서 UI가 없는 거는 매한가지인데 왜 ASP.NET만 있고 ASP.NET Core는 없는지를 해결해주는 근거있는 추측이라고 생각이 듭니다.

그런데 이것을 보다가 갑자기 또 추가 궁금증이 생기는 것은, 그럼 크로스플랫폼인 AvaloniaUI나 Uno Platform는 MS가 만든 플랫폼들이 아닌데 SynchronizationContext와 유사한 기능을 하는 것이 있어야 할 듯한데 있는지 궁금해졌네요. 좀 찾아봐야겠습니다.

1개의 좋아요

이 사진과 다른 의미로 보신다는 의미이신거죠??

예를 들면 MainThread는 ThreadPool의 Thread는 맞지만 LongRunning Thread로 동작하면서 ThreadPool에서 분리되었다 라는 맥락으로 말씀하신 거로 이해가 되었습니다.

음 제 맥락이랑 다른 맥락같습니다.
저는 ThreadPool의 존재가 낭비라고 생각하지 않으며 비판적이지도 않습니다.
다만 Console이든 Windows Forms던 WPF던 ASP.NET이던 무조건 SynchronizationContext가 존재하여 원래 Thread로 복귀하도록 만들어줬다면 이런 오해가 없었을 것이라는 겁니다.

콘솔 앱에서 비동기호출 후 Main Thread로 돌아가지 않는 것은 역시 제 기준에서는 낭비라고 보여집니다.

그러나 그걸 원하지 않는다고 하여 모던 .NET 에서는 비동기할 때 TAP 방식을 장려하고 있도록 코드 설탕도 만들어주는 이 상황에서 굳이 다른 방법으로 비동기를 할 이유는 없다고 생각합니다.

1개의 좋아요

Avalonia UI는 문서화가 되어있어서 찾기 쉬웠는데 Uno Platform은 문서는 없어서 Github에서 따로 찾아봤습니다.

AvaloniaUI의 SynchronizationContext == AvaloniaSynchronizationContext


Uno Platform의 SynchronizationContext == NativeDispatcherSynchronizationContext

1개의 좋아요

같은 의미인데, 반대 증거입니다.
보여 주신 Main 메서드는 await 이 없으므로, 당연히 메인 스레드에서 실행되는 것이죠.

async Main의 예제로는,

// 메인 스레드(GeneratedMain의 스레드)에서 실행함
Console.WriteLine($"Async Main code before await is a threadpool thread? : {Thread.CurrentThread.IsThreadPoolThread}!");

// GeneratedMain 로 반환.
await DoAsync();

// ContinueWith
Console.WriteLine($"Async Main code after await is a threadpool thread? : {Thread.CurrentThread.IsThreadPoolThread}!");

static Task DoAsync() => Task.Run(() =>
Console.WriteLine($"DoAsync is a threadpool thread? : {Thread.CurrentThread.IsThreadPoolThread}!")
);

결과:

Async Main code before await is a threadpool thread? : False!
DoAsync is a threadpool thread? : True!
Async Main code after await is a threadpool thread? : True!

1개의 좋아요
// 메인 스레드(GeneratedMain의 스레드)에서 실행함
Console.WriteLine($"Async Main code before await is a threadpool thread? : {Thread.CurrentThread.IsThreadPoolThread}!, Thread ID: {Thread.CurrentThread.ManagedThreadId}");

// GeneratedMain 로 반환.
await DoAsync();

// ContinueWith
Console.WriteLine($"Async Main code after await is a threadpool thread? : {Thread.CurrentThread.IsThreadPoolThread}!, Thread ID: {Thread.CurrentThread.ManagedThreadId}");

static Task DoAsync() => Task.Run(() =>
Console.WriteLine($"DoAsync is a threadpool thread? : {Thread.CurrentThread.IsThreadPoolThread}!, Thread ID: {Thread.CurrentThread.ManagedThreadId}")
);

주신 코드에 Thread ID를 추가해봤습니다.

제가 말하는 의미는, 마지막에 await 이후에 Thread ID가 최초에 찍힌 MainThread ID가 찍혀서 돌아와야 낭비가 아니라는 의견이었습니다.

결국 그럼 MainThread라고하는 Entry Point로 진입한 Thread는 놀게되는 것이죠.

사진에서 제가 말하는 MainThread의 ID는 1입니다.

await 호출 후 7로 바뀐 것이고 결국 다시 1로 돌아가지 못하는 이상, Thread ID가 1인 Thread는 놀게 되는 것이란 의미입니다.

“MainThread” 라는 단어가 약간 혼란스러운 것 같아, "Main의 스레드"라고 부르겠습니다.

Top level statements 라도, 본문에 await 이 있으면, 이는 아래 코드와 같습니다.

class Program
{
  static async Task Main() 
  {
      // ...
      await DoAsync()
  }
 static Task DoAsync() => //...
}

이는 컴파일러에 의해, 아래와 같이 변경됩니다.

class Program
{
  static void $GeneratedMain() => Main().GetAwaiter().GetResult();
  static async Task Main() 
  {
      // ...
      await // ...
  }
  static Task DoAsync() => //...
}

이 앱의 프로세스(메인 스레드)는 GeneratedMain 을 실행하고, 비동기 코드는 전부 스레드 풀 스레드에서 실행됩니다. 여기에는 Main 메서드까지 포함됩니다.

Main 과 DoAsync 가 동일 스레드에서 실행된다는 것은 스레드 풀이 효율적으로 운영됨을 의미합니다. 각각 스레드를 할당하지 않고, 하나의 active 스레드로 두 delegate를 처리하기 때문입니다.

그리고, GenerateMain 은 await 에 의해 Thread.Join 되기 때문에, 어쩔 수 없이 놀아야 합니다.

이는 다중 실행 모델에서 동기화에 필수적인 것이지, 낭비적인 것이 아닙니다.

Thread.Join 이 필요 없다면, 아래와 같이 하면 되는데,

// await DoAsync();
var unawaitedTask = DoAsync();

결과는 보통 아래와 같이 나오겠지만, 마지막 줄이 두 번째로 갈 수도 있고, 아예 안 나올 수도 있습니다.

Async Main code before await is a threadpool thread? : False!
Async Main code after is a threadpool thread? : False!
DoAsync is a threadpool thread? : True!

이는 우리가 원하는 동기적 결과가 보증되지 않음을 의미합니다.

1개의 좋아요

네…Top Level Statements에서 코드가 저렇게 변환되는 것은 이번에 알았지만,

말씀하신 부분들은 모두 제가 과거부터 알고 있던 내용과 같습니다.

다만 아래 부분에 대한 관점이 다른 듯하여 여기까지 이 스레드가 진행된 것 같습니다.

물론 저도 낭비가 아니라 당연한 것이라고 볼 수도 있지만, 아마도 제가 .NET 이외에 다른 언어는 안 해봤어서 이런 의문을 가질 수도 있다고 생각한 거 같습니다.

다른 언어들의 다중 실행환경에서도 .NET처럼 이런 구조를 가지고 있다면 끝까지 이해가 안 되고 미심쩍더라도 이게 원래 이렇게 밖에 할 수 없나보다 할 것 같은데, .NET 밖에 안 해본 상태에서 이런 구조를 갖고 있는 것은 일반적인건지, .NET의 설계미스인건지 판단이 어렵기 때문입니다.

뭐…그렇다고 고작 그거 하나 낭비라고 싫다고 안 쓸 수도 없는 노릇이고, 사실 그렇게 큰 문제도 아닐뿐더러, 제가 언어를 만들어서 쓸 처지도 아니기 때문입니다. 그래서 막연하게 궁금했던 부분입니다. 주신 의견만으로도 충분한 것 같습니다.

의견 감사합니다.

1개의 좋아요

“thread join” 으로 검색을 해보시면 아시겠지만, 일반적인 개념입니다.

Thread.Join() 은 닷넷의 구현이고, 다른 언어도 유사한 이름을 가진 구현을 제공합니다.

1개의 좋아요

아…Thread Join을 모르는 게 아닙니다. 그건 사실 Thread의 기초 중에 기초라서 이름조차 처음 들어보면 안 되는 수준이라고 생각하구요…

제가 말한 ‘이런 구조’ 라는 것은

거듭하여 다시 말씀드리지만 동작하는 OS가 뭐던, 프레임워크가 뭐던, 플랫폼이 무엇이던 간에 Worker Thread를 사용 후엔 원래 EntryPoint가 되었던 Thread로 회귀하는 부분을 말씀드리는 것입니다. → 이렇게 되면 원래의 Worker Thread는 Thread Pool에서 온 것이기 때문에 다시 Thread Pool로 반납을 할 수 있기에 Worker Thread가 낭비되지 않는 부분이라고 생각하기 때문입니다.

UI Thread가 있건 없건 간에, 콘솔이던 aspnetcore던 다시 회귀만 시켜주었다면, 사용자 입장에서는 하나의 원리만 알면 되는 입장이었던 것이라고 보여지기 때문이고 처음 배우는 사용자일 때 .NET의 이것저것의 프로젝트를 경험해보면 이 부분은 뇌절에 가까운 부분일 수 있다고 생각하기 때문입니다.

차라리 아예 그런 기능이 있거나 없거나 했으면 방식이 하나이기 때문에 고민할 여지조차 없는 것인 점인데, UI Thread를 고려했기에 원래 스레드로 돌아가는 부분이 필요했다면 그냥 UI Thread도, aspnetcore도 회귀하도록 시켜버리면 비기너들은 그냥

아, 닷넷에서는 원래 스레드에서 워커 스레드를 호출하면 워커스레드에서 돌다가 await이후 다시 원래 스레드로 복귀해서 이후 코드가 진행되는 구나

만 알면 되는 부분이었다는 것이지요.


지금 막 하나 스친 것인데, Rust에서도 Main 함수를 비동기로 만들 때 tokio라는 것이 이거처럼 기존 스레드를 정지하고 다른 스레드를 띄워서 하는 것으로 기억이 나네요.

아무래도 말씀하신대로 낭비가 아니라 원래 그래야 하나 봅니다.

UI 스레드로 돌아가야 하는 상황이 낭비입니다. asp.net core는 아키텍처상 이제 그래야 할 필요가 없다고 판단해서 동기화 컨텍스트를 사용하지 않게 된 것으로 보입니다. 스프링 쪽을 보면 특정 스레드에 묶인 인스턴스 저장공간 등이 있습니다. 그 스레드에서만 접근할 수 있게요. 그런 상황이면 비동기 코드를 하고 난 뒤 다시 원래 스레드로 돌아와야 할 필요가 있습니다.

콘솔 프로젝트는 진입할 때 쓴 쓰레드가 있을 뿐, 항상 그 스레드로 유지되어야 할 필요가 없습니다. 정확히 말하자면 콘솔 프로젝트에서 특정 스레드를 유지해야 하는 필요가 있다면 직접 해당 논리를 추가해야 합니다.

윈폼이나 WPF의 크로스스레드 문제는 프레임워크에서 만든 제한사항입니다. 근본적으로 화면에 뭘 표시하는데 그런 제한사항은 없습니다. 다만 한 스레드로 하면 자연스럽게 해결되는게 많습니다.

UI스레드의 경우 루프를 돌며 작은 작업들을 받아 처리하고 있으므로, await 이후 작업할 내용을 적절한 타이밍에 시작할 수 있습니다. 일반 스레드는 그런 내용이 없기 때문에 원래 스레드로 복귀할 수 없습니다. 그걸 가능하게 해주는게 동기화 컨텍스트이고, await는 호출한 스레드가 이걸 가지고 있는지 자동 확인하도록 배려되어 있을 뿐입니다.

1개의 좋아요