Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일
 

(시리즈 글이 11개 있습니다.)
.NET Framework: 612. UWP(유니버설 윈도우 플랫폼) 앱에서 콜백 함수 내에서의 UI 요소 접근 방법
; https://www.sysnet.pe.kr/2/0/11071

.NET Framework: 680. C# - 작업자(Worker) 스레드와 UI 스레드
; https://www.sysnet.pe.kr/2/0/11287

.NET Framework: 777. UI 요소의 접근은 반드시 그 UI를 만든 스레드에서!
; https://www.sysnet.pe.kr/2/0/11561

.NET Framework: 805. 두 개의 윈도우를 각각 실행하는 방법(Windows Forms, WPF)
; https://www.sysnet.pe.kr/2/0/11802

.NET Framework: 886. C# - Console 응용 프로그램에서 UI 스레드 구현 방법
; https://www.sysnet.pe.kr/2/0/12139

.NET Framework: 911. Console/Service Application을 위한 SynchronizationContext - AsyncContext
; https://www.sysnet.pe.kr/2/0/12231

.NET Framework: 1022. UI 요소의 접근은 반드시 그 UI를 만든 스레드에서! - 두 번째 이야기
; https://www.sysnet.pe.kr/2/0/12537

.NET Framework: 2076. C# - SynchronizationContext 기본 사용법
; https://www.sysnet.pe.kr/2/0/13190

.NET Framework: 2077. C# - 직접 만들어 보는 SynchronizationContext
; https://www.sysnet.pe.kr/2/0/13191

닷넷: 2278. WPF - 스레드에 종속되는 DependencyObject
; https://www.sysnet.pe.kr/2/0/13682

닷넷: 2298. C# - Console 프로젝트에서의 await 대상으로 Main 스레드 활용하는 방법
; https://www.sysnet.pe.kr/2/0/13743




C# - Console 프로젝트에서의 await 대상으로 Main 스레드 활용하는 방법

이런 질문이 있었군요,

.NET Console 프로젝트에서의 ThreadPool 낭비?
; https://forum.dotnetdev.kr/t/net-console-threadpool/11660

답글도 많이 달리고 좀 어지러워졌지만, 그래도 대충 이해 차원에서 제 나름대로 설명을 해보겠습니다.




예제를 곁들여 설명해 볼까요? ^^

internal class Program
{
    // C# 7.1부터 추가된 async Main
    static async Task Main(string[] args)
    {
        await Task.Delay(1000);
        Console.WriteLine("Hello World!");
    }
}

위와 같은 코드를 수행하면, Main을 실행했던 최초 스레드(이하 편의상 Main 스레드)는 await 호출 이후 "응용 프로그램의 종료"를 막기 위해 대기 상태로 전환합니다. 사실, 저 코드는 C# 컴파일러에 의해 다음과 같이 변환되는데요,

internal class Program
{
    static void Main(string[] args)
    {
        TaskMain(args).GetAwaiter().GetResult();
    }

    static async Task TaskMain(string[] args) // 원래 Main의 코드를 빼서 정의
    { 
        await Task.Delay(1000);
        Console.WriteLine("Hello World!");
    }
}

결국, Main이 Task를 반환했던 것은 C# 컴파일러의 syntactic sugar에 불과한 것으로, 실제로는 async가 아닙니다. 따라서 호출상으로는, Main 스레드는 무조건 대기 상태로 머물고 개발자가 작성한 코드들은 내부의 await 호출을 타고 흐르면서 ThreadPool의 스레드에서 (아래에서 설명하겠지만, 마치 점프하듯이) 실행됩니다.

질문자의 의견처럼, Main 스레드가 그저 놀고 있는 것이 맞습니다.




그런데, 질문 자체에서 혼란스러운 것이 하나 있으므로 그것을 먼저 정리해야 합니다. 그것은 "async/await" 대상이 1) 비동기 I/O 작업인지, 2) CPU 바운드 작업인지에 따라서 다르게 동작한다는 것입니다.

위에서 작성한 예제 코드에서 Task.Delay(1000)는 비동기 I/O 작업과 유사합니다. 반면 CPU 바운드 작업인 경우에는... 음... 간단하게 소수를 체크하는 코드를 들 수 있습니다.

namespace ConsoleApp1;

internal class Program
{
    static async Task Main(string[] args)
    {
        await Task.Delay(1000);
        Console.WriteLine("Hello World!");

        bool result = await IsPrime(100000000003);
        Console.WriteLine(result);
    }

    // http://www.dotnetperls.com/prime
    public static Task<bool> IsPrime(long candidate)
    {
        Task<bool> task = Task.Run(() =>
        {
            if ((candidate & 1) == 0)
            {
                return (candidate == 2) ? true : false;
            }

            for (int i = 3; (i * i) <= candidate; i += 2)
            {
                if ((candidate % i) == 0)
                {
                    return false;
                }
            }

            return candidate != 1;
        });

        return task;
    }
}

자, 그럼 위의 2가지 상황을 두고 질문에 있던 "2번 상황"을 다시 보면,

2. await를 1번 사용
    Main Thread 1개 노는 중
    B,C,D Thread 대기 중
    A Thread 사용 중

"A Thread 사용 중"이라는 표현이 2가지 상황에서 나뉩니다.

1) 비동기 I/O 작업에 준하는 Task.Delay에 await을 걸었을 때는 "A Thread 사용 중"이라고 표현한 것은 맞지 않습니다. 왜냐하면 Main 스레드는 Task.Delay 내부의 타이머를 호출한 후 그에 대한 타이머 완료 이벤트가 발생할 때까지 기다리지 않고 바로 Delay 메서드를 벗어나 GetAwaiter().GetResult()를 호출하게 됩니다. 즉, 1초 동안은 Main 스레드만 대기 상태로 빠져 있을 뿐 ThreadPool의 어떠한 스레드도 사용 중이지 않습니다.

그러다, 1초 후 타이머 완료 이벤트가 발생하면 이후의 작업, 즉 "Console.WriteLine(...);" 코드를 수행하기 위해 ThreadPool의 여유 스레드를 하나 빌려와 코드 수행을 계속합니다. (참고: "C# - async/await 그리고 스레드 (3) Task.Delay 재현")

2) 반면 CPU 바운드 작업을 수행하는 await IsPrime의 호출에서는 그 작업을 Task.Run으로 맡겼기 때문에 그것이 "A Thread"로 표현될 수 있습니다. 이런 경우라면, 최초 호출된 await의 Task.Run으로 인한 스레드가 사실상 Main 스레드를 대신해 "사용 중"인 효과를 가집니다.

대충 이해가 되셨을까요? 위의 설명을 이해했다면, "async/await 호출"의 장점이라는 것이 "비동기 I/O 작업"에 대해서만 효과적이라는 것을 알 수 있습니다. CPU 바운드 작업에 대해서는, CPU 코어가 남는 경우에는 효과적일 수 있지만 웹 사이트와 같은 상황이라면, 가령 16개의 코어를 가진 머신이라고 해도 16번의 요청만 오면 CPU가 100% 사용 중이기 되기 때문에 "await 호출"의 장점이 전혀 없습니다.

그런 의미에서 "Thread Pool의 자원인 Thread를 계속해서 하나를 점유"하는 상황은 "비동기 I/O 작업"에서는 발생하지 않습니다. await 호출을 한 ThreadPool의 스레드는 곧바로 ThreadPool에 반환되고, 일정 시간 후 비동기 I/O의 완료 이벤트를 받으면 다시 ThreadPool의 스레드를 빌려와 await 이후의 코드를 수행하는 식입니다. (위에서 이런 상황을 "마치 점프하듯이"라고 언급한 이유입니다.) 물론, 비동기 I/O가 빠르게 작업을 끝낸다면 I/O 대기로 인한 끊김이 작아 "거의 1개의 스레드"가 점유된다고 볼 수도 있습니다. 또한, File I/O의 ReadAsync를 최초 호출한 경우 비동기 I/O가 수행될 수 있지만, cache로 인해 이후의 ReadAsync 호출을 하는 경우에는 커널에서 곧바로 데이터를 반환하는 상황도 발생하는데 그럴 때는 await 호출이 그냥 동기 호출 방식처럼 동작하므로 이후의 코드 수행을 ReadAsync를 호출했던 그 스레드가 계속 이어서 진행합니다. (이처럼 종종 await 호출이 동기로 실행하는 경우가 있습니다.)

하지만 현실적으로 봤을 때, 여러분의 컴퓨터에 실행 중인 프로그램들 중에서 CPU 100%를 치고 있는 것이 얼마나 될까요? 즉, 다른 수많은 프로그램들도 Main 스레드는 할 일 없이 장시간 대기하며 낭비(?)하고 있었던 것입니다.

일단 설명의 집중을 위해, 이후로는 async/await 호출을 "비동기 I/O 작업"에 대해 호출한다는 것을 가정으로 설명을 계속하겠습니다.




그런데, 이와 함께 "SynchronizationContext"가 언급이 되고 있는데요, 재미있게도 "SynchronizationContext는 dotnet의 디자인 결함"이라는 의견이 나왔습니다. 하지만 이것은 디자인 결함이 아닌, 마이크로소프트가 애써서 구현을 더 추가한 유형이라고 봐야 합니다.

단적으로, SynchronizationContext가 없었다면 아래의 코드조차도,

namespace WinFormsApp1;

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    private async void Form1_Load(object sender, EventArgs e)
    {
        await Task.Delay(1000); // Delay 완료 후 ThreadPool의 스레드가 이후 작업을 수행
        this.Text = "Hello World!"; // 스레드가 달라지므로 (디버깅 시) 예외 발생 - System.InvalidOperationException: 'Cross-thread operation not valid: Control 'Form1' accessed from a thread other than the thread it was created on.'
    }
}

예외가 발생하게 됩니다. 되려 이것을 막기 위해 개발자가 일일이 모든 코드에 Invoke 코드를 심어야 하는데요,

private async void Form1_Load(object sender, EventArgs e)
{
    await Task.Delay(1000);

    // SynchronizationContext가 없었다면 await 이후의 모든 UI 접근 코드를 이렇게 바꿔야 함
    this.Invoke(new Action(() =>
    {
        this.Text = "Hello World!";
    }));
}

엄밀히 말해서 저게 정말 디자인 결함이라고 생각한다면, 지금 당장이라도 SynchronizationContext를 사용하지 않도록 다음과 같이 코드를 추가하면 됩니다.

public partial class Form1 : Form
{
    public Form1()
    {
        // SynchronizationContext를 사용하지 않도록 설정
        SynchronizationContext.SetSynchronizationContext(null);
        InitializeComponent();
    }

    private async void Form1_Load(object sender, EventArgs e)
    {
        // ...[생략]...
    }
}

그럼, 그동안 누렸던 SynchronizationContext의 고마움을 (await 호출을 할 때마다) 비로소 느끼게 될 것입니다.




SynchronizationContext가 주로 UI Framework에 구현된 이유는, Windows 운영체제 자체가 UI 자원에 대해 thread-safe하게 구현하지 않았기 때문입니다.

그렇다면 오히려 SynchronizationContext가 아닌, UI 자원을 thread-safe하게 구현하지 않은 Windows의 디자인 결함일까요?

사실, 이 문제를 너무 어렵게 생각할 필요가 없습니다. 보통 코딩을 할 때 상태 유지를 위해 전역 변수를 두거나 할 텐데요, 바로 그것이 thread-safe와 직접적인 연관이 되는 것입니다. 혹시 여러분은 일상적으로 만드는 클래스를 thread-safe하게 만드시나요? 일례로, DTO 성격으로 만들어진 클래스들의 멤버를 접근하는데 lock/critical section을 걸어 모두 보호하진 않을 것입니다.

다시 말해, 어떤 식으로든 상태 유지가 필요한 경우에는 결국 thread-safe 문제와 엮이게 되는데요, 대표적으로 화면에 항상 떠 있는 UI 관련 요소들이 상태를 가져야 하는 전형적인 사례들입니다. 물론, 그 요소들의 상태 변경 코드에 일일이 lock/critical section을 거는 코드를 사용해도 되겠지만, 성능 및 lock의 남용으로 인한 dead-lock 등의 이유로 그렇게 하지 않는 것이 일반적입니다.

유사한 사례로, .NET BCL에 포함된 숱한 타입들의 instance 메서드는 모두 thread-safe하지 않다는 것을 알 것입니다. (반면, static 메서드는 일반적으로 thread-safe하게 만듭니다.)

그러니까, thread-safe하지 않은 것이 Windows의 디자인 결함이라고 볼 수는 없습니다. 실제로 이게 꼭 윈도우만 그런 것도 아닌데요, 가령 Android(일례로, Java.Lang.IllegalStateException)도, iOS도 UI 요소를 다른 스레드에서 건드리는 경우 예외가 발생합니다.

정리해 보면, UI 요소는 다른 스레드에서 접근하는 경우 상태가 불안정해질 수 있고, 그렇다면 await과 같은 호출 이후에 ThreadPool의 여유 스레드가 작업을 계속 이어나가는 상황에서 개발자는 일일이 Control.Invoke 등의 코드를 넣어야 합니다. 바로 그런 상황에서 나온 SynchronizationContext의 도입은 개발자에게 축복과도 같은 것입니다. (물론, 이것이 뒤단에서 자동으로 처리되기 때문에 오히려 혼란으로 이어지기도 합니다.)




그렇다면, ASP.NET (.NET Framework)은 왜 SynchronizationContext를 구현했을까요?

ASP.NET의 경우, 초기 모델부터 (WinForms와 유사하게) WebForms 프레임워크를 도입했습니다. 재미있게도 HTML로 렌더링하는 "서버 컨트롤"들을 제공했는데요, 이러한 서버 컨트롤을 바탕으로 화면 하나의 요청에 대해 Page를 대응시켜 처리합니다.

그리고, 그 페이지를 처리하는 동안 Thread가 바뀌는 일이 없습니다. 즉, 요청을 받은 그 스레드가 Page의 렌더링까지 모두 담당하는 것입니다.

그렇다면, 그런 상황에서 Page 및 서버 컨트롤이 굳이 thread-safe한 코드로 작성해야 했을까요? 당연히 그렇지 않을 것입니다. 또한, 스레드 하나가 요청을 전담하기에, 그 스레드를 SynchronizationContext의 대상 스레드로 쉽게 정할 수 있다는 특징이 있습니다.

하지만 애써 SynchronizationContext를 ASP.NET에 도입한 노력에도 불구하고, 여러분 중에 그것을 사용해 보신 분은 거의 없었을 거라고 봅니다. 즉, 제공은 했지만 현실적으로는 잘 사용되지 않았습니다. (아마도 마이크로소프트 스스로, 아니면 당시에 특수한 3rd-party Web Control을 제공하는 업체들의 소수 개발자들만 사용했을 것입니다.)

그러던 것이, ASP.NET Core에 오면서는 SynchronizationContext가 사라졌습니다. 왜 그럴까요? 우선, Web Forms 프레임워크 자체가 ASP.NET Core에는 없습니다. 그리고 결정적으로 ASP.NET Core는 그 자체가 비동기입니다. 즉, 요청을 받은 스레드가 응답까지 책임지지 않고 중간에 얼마든지 바뀔 수 있는 구조입니다. 따라서, SynchronizationContext의 대상으로 결정할 수 있는 마땅한 스레드 후보가 없습니다.




이제 다시 원래의 주제로 돌아가 볼까요? ^^

그럼 Main 스레드를 낭비하지 않는 방법이 무엇일까요? 쉽게는, 비동기 호출 후 다른 할 일이 있다면 그것에 Main 스레드를 활용하면 됩니다. 코드도 매우 간단한데요,

static void Main(string[] args)
{
    Task task = TaskMain(args);

    while (task.IsCompleted == false)
    {            
        // do something
        Thread.Sleep(1000);
    }
}

static async Task TaskMain(string[] args)
{
    await Task.Delay(1000);
    Console.WriteLine("Hello World!");
}

Main 스레드에서 await 호출이 아닌, 그냥 Task로 받은 후 부가 작업을 처리하도록 만들면 됩니다. 물론, 이 방법으로는 억지로 뭔가를 해야 하도록 만들어야 하기 때문에 어떤 식으로든 Main 스레드가 낭비되는 것은 사실입니다.

다른 방법으로는, Console의 Main 스레드를 활용하도록 SynchronizationContext를 만들어주면 됩니다. 해당 설계는 확장이 가능하도록 만들어졌기 때문에,

.NET Framework: 2077. C# - 직접 만들어 보는 SynchronizationContext
; https://www.sysnet.pe.kr/2/0/13191

직접 만들어도 되고, 아니면 누군가가 이미 그 용도로 만들어 준 타입을 사용해도 됩니다.

Console/Service Application을 위한 SynchronizationContext - AsyncContext
; https://www.sysnet.pe.kr/2/0/12231

위의 글에 소개한 Nito.AsyncEx 라이브러리를 사용해 다음과 같이 코딩하면,

using Nito.AsyncEx;

namespace ConsoleApp2;

// Install-Package Nito.AsyncEx
internal class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        AsyncContext.Run(AsyncMain);
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    }

    static async Task<int> AsyncMain()
    {
        await Task.Delay(1000);
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        
        return 0;
    }
}

화면에는 ManagedThreadId의 출력으로 모두 "1"이 나오는 것을 확인할 수 있습니다.




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







[최초 등록일: ]
[최종 수정일: 9/27/2024]

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

비밀번호

댓글 작성자
 



2024-09-27 10시58분
안녕하세요 정성태님!
링크하신 글의 원문을 적은 사람입니다.
이렇게 해설해주셔서 정말 감사드립니다!!
이상준

1  2  [3]  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13789정성태10/27/20241441Linux: 97. menuconfig에 CONFIG_DEBUG_INFO_BTF, CONFIG_DEBUG_INFO_BTF_MODULES 옵션이 없는 경우
13788정성태10/26/20241489Linux: 96. eBPF (bpf2go) - fentry, fexit를 이용한 트레이스
13787정성태10/26/20241412개발 환경 구성: 730. github - Linux 커널 repo를 윈도우 환경에서 git clone하는 방법 [1]
13786정성태10/26/20241583Windows: 266. Windows - 대소문자 구분이 가능한 파일 시스템
13785정성태10/23/20241614C/C++: 182. 윈도우가 운영하는 2개의 Code Page파일 다운로드1
13784정성태10/23/20241644Linux: 95. eBPF - kprobe를 이용한 트레이스
13783정성태10/23/20241498Linux: 94. eBPF - vmlinux.h 헤더 포함하는 방법 (bpf2go에서 사용)
13782정성태10/23/20241436Linux: 93. Ubuntu 22.04 - 커널 이미지로부터 커널 함수 역어셈블
13781정성태10/22/20241421오류 유형: 930. WSL + eBPF: modprobe: FATAL: Module kheaders not found in directory
13780정성태10/22/20241530Linux: 92. WSL 2 - 커널 이미지로부터 커널 함수 역어셈블
13779정성태10/22/20241486개발 환경 구성: 729. WSL 2 - Mariner VM 커널 이미지 업데이트 방법
13778정성태10/21/20241675C/C++: 181. C/C++ - 소스코드 파일의 인코딩, 바이너리 모듈 상태의 인코딩
13777정성태10/20/20241577Windows: 265. Win32 API의 W(유니코드) 버전은 UCS-2일까요? UTF-16 인코딩일까요?
13776정성태10/19/20241588C/C++: 180. C++ - 고수준 FILE I/O 함수에서의 Unicode stream 모드(_O_WTEXT, _O_U16TEXT, _O_U8TEXT)파일 다운로드1
13775정성태10/19/20241508개발 환경 구성: 728. 윈도우 환경의 개발자를 위한 UTF-8 환경 설정
13774정성태10/18/20241474Linux: 91. Container 환경에서 출력하는 eBPF bpf_get_current_pid_tgid의 pid가 존재하지 않는 이유
13773정성태10/18/20241801Linux: 90. pid 네임스페이스 구성으로 본 WSL 2 + docker-desktop
13772정성태10/17/20241670Linux: 89. pid 네임스페이스 구성으로 본 WSL 2 배포본의 계층 관계
13771정성태10/17/20241674Linux: 88. WSL 2 리눅스 배포본 내에서의 pid 네임스페이스 구성
13770정성태10/17/20241479Linux: 87. ps + grep 조합에서 grep 명령어를 사용한 프로세스를 출력에서 제거하는 방법
13769정성태10/15/20241981Linux: 86. Golang + bpf2go를 사용한 eBPF 기본 예제파일 다운로드1
13768정성태10/15/20241633C/C++: 179. C++ - _O_WTEXT, _O_U16TEXT, _O_U8TEXT의 Unicode stream 모드파일 다운로드2
13767정성태10/14/20241602오류 유형: 929. bpftrace 수행 시 "ERROR: Could not resolve symbol: /proc/self/exe:BEGIN_trigger"
13766정성태10/14/20241699C/C++: 178. C++ - 파일에 대한 Text 모드의 "translated" 동작파일 다운로드1
13765정성태10/12/20241478오류 유형: 928. go build 시 "package maps is not in GOROOT" 오류
13764정성태10/11/20241392Linux: 85. Ubuntu - 원하는 golang 버전 설치
1  2  [3]  4  5  6  7  8  9  10  11  12  13  14  15  ...