Microsoft MVP성태의 닷넷 이야기
.NET Framework: 394. async/await 사용 시 hang 문제가 발생하는 경우 [링크 복사], [링크+제목 복사]
조회: 19005
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

async/await 사용 시 hang 문제가 발생하는 경우

게임 코디 웹 사이트에 재미있는 이야기가 올라왔군요. ^^

서버프로그래머인데 데드락이 왜 걸리는지 모르겠어요 
; http://www.gamecodi.com/board/zboard-id-GAMECODI_Talkdev-no-2408-z-10.htm

덧글에 나오지만, 위의 질문은 다음의 글을 근간으로 하고 있습니다.

Don't Block on Async Code
; http://blog.stephencleary.com/2012/07/dont-block-on-async-code.html

자, 그럼 이 문제를 쉽게 풀어 볼까요? ^^




우선 위의 글에 따라 문제를 재현해 보겠습니다. ^^ 간단하게 .NET 4.5 대상의 Windows Forms 프로젝트를 만들고 아래와 같이 코딩을 해주면 됩니다.

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

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

        private void Form1_Load(object sender, EventArgs e)
        {
            var textTask = GetHtmlTextAsync();
            this.Text = textTask.Result;
        }

        public static async Task<string> GetHtmlTextAsync()
        {
            var client = new TestClass();
            string result = await client.GetTextAsync();
            return result;
        }

        public class TestClass
        {
            public Task<string> GetTextAsync()
            {
                return Task.Factory.StartNew(
                () =>
                {
                    Thread.Sleep(5000);
                    return "Hello World";
                });
            }
        }
    }
}

이렇게 만들고 실행하면 Form1_Load의 "this.Text = textTask.Result;" 라인에서 실행이 정지되어 버립니다. (BP 걸고 직접 확인해 보세요. ^^)

일단, 왜 블록킹 현상이 발생하는지 액면 그대로 놓고 보면 다음과 같이 설명될 수 있습니다.

1. UI Thread가 Form1_Load를 실행
    1.1. UI Thread가 GetHtmlTextAsync 메서드를 실행
        1.1.1 UI Thread가 TestClass.GetTextAsync 메서드를 비동기로 실행
    1.2. UI Thread는 textTask 인스턴스의 get_Result 프로퍼티를 호출.
         내부적으로 Task 타입의 Result는 Thread.Join을 동반하므로 비동기로 호출한 GetTextAsync를 실행한 스레드가 종료될 때까지 대기


2. ThreadPool의 자유 스레드는 TestClass.GetTextAsync 메서드를 실행
    2.1. 메서드 실행 후 텍스트를 반환.
    2.2. await 처리로 인해 분리된 "return result;" 코드를 UI Thread에서 실행하도록 동기적으로 디스패칭
    2.3. 동기식으로 디스패칭했기 때문에 UI Thread에서 처리를 해줄 때까지 현재 스레드는 블록됨

어떤 식인지 이해되시나요? "2.3" 단계에서 ThreadPool의 스레드가 UI Thread에서 처리할 "return result;" 코드를 delegate로 동기식으로 전달했는데, 그 순간의 UI Thread는 ThreadPool의 스레드가 완료될 때까지 기다리는 Thread.Join을 호출했기 때문에 서로 무한 대기 상태에 빠진 것입니다. (정확히 Thread.Join은 아닐 수 있습니다. 가령 EventWaitHandle로 처리했을 수도 있는데 그리 중요한 것은 아니니 그냥 개념만 그렇다고 이해해 주시면 되겠습니다.)




조금 더 자세하게 설명을 해볼까요? ^^ 그럼 이번에는 위의 async/await과 동등한 효과를 가진 코드를 Thread를 이용해 만들어 보겠습니다.

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

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

        private void Form1_Load(object sender, EventArgs e)
        {
            Thread thread = new Thread(GetHtmlTextFunc);
            thread.Start();

            thread.Join(); // 역시 hang 현상 발생
        }

        private void GetHtmlTextFunc(object obj)
        {
            var client = new TestClass();
            string result = client.GetText();

            this.Invoke(
                (Action)(() => { this.Text = result; })
            );
        }

        public class TestClass
        {
            public string GetText()
            {
                Thread.Sleep(5000);
                return "Hello World";
            }
        }
    }
}

역시 위의 코드가 실행되는 순서를 한번 보겠습니다.

1. UI Thread는 Form1_Load를 실행하고 스레드를 실행한 후 Thread.Join으로 해당 스레드가 종료할 때까지 대기

2. 새로운 스레드는 GetText 메서드를 실행하고, 
   "this.Text = result" 코드를 UI Thread 측에서 실행할 수 있도록 this.Invoke 동기 메서드를 호출.

보시면 당연히 블록킹이 될 수밖에 없는 구조를 가지고 있습니다.

그럼 여기서 한 가지 의문이 생깁니다. 왜 async/await은 자동적으로 this.Invoke와 동일한 효과를 갖는 코드로 처리하느냐입니다. 일반적인 비동기 호출이라면 async/await으로 분리된 "return result;" 코드는 그대로 ThreadPool 측의 스레드에서 마저 호출되어야만 하는 것이 정상적입니다. 그런데, async/awiat은 굳이 시키지도 않은 UI Thread에 해당 코드를 실어 보내는 처리를 하고 있는 것입니다.

이 문제를 정확히 이해하려면 약간의 역사적인 문제를 알아야 합니다.

일반적으로 UI 요소는 그것을 생성해 낸 스레드에서 접근해야 한다는 제약이 있습니다. (이것은 윈도우 운영체제만의 제약이 아닙니다. iOS도 그렇고 안드로이드까지 모두 이런 제약이 있습니다.) 문제는 닷넷이 나오기 전 C/C++ 프로그래머들이 개발했던 윈도우 프로그램에서는 그런 제약이 없었다는 점입니다. 사실 제약이 없었다기 보다는 문제는 있었지만 표면화되지 않았을 뿐입니다. (C/C++에서 다중 스레드를 이용해 ListBox에 아이템을 채우면 설령 이상하게 채워질지언정 못 채우는 것은 아닙니다.)

세월이 지나 닷넷 프레임워크가 나오면서 닷넷도 처음에는 이런 정책을 그대로 가져갔습니다. 초기 윈폼 프로젝트는 다중 스레드에서 ListBox에 값을 채우는 것이 가능했지만 Virtual Machine 성격의 닷넷은 C/C++보다 성능이 낮았기 때문에 항목이 이상하게 채워지는 문제가 두드러졌습니다. 그래서, 초기 닷넷 관련 포럼에 보면 그런 희한한 문제를 보고하는 게시물이 심심치 않게 등장했습니다.

그로 인한 문제가 많아지면서 마이크로소프트는 UI 요소를 생성하지 않은 다른 스레드에서 해당 UI 요소를 접근하는 경우 명시적인 예외를 발생하도록 변경했습니다. (제가 보기에는, 이로 인해 닷넷 포럼이 더 질문이 많아지지 않았나 생각이 됩니다. ^^)

어쨌든, UI 요소를 다른 스레드에서 건드리는 것은 심심치 않게 발생하면서도 여간 신경쓰이는 일이 아닐 수 없었던 것입니다.

이 문제를 좀 더 쉽게 해결할 수 있도록 마이크로소프트는 SynchronizationContext 타입을 만들었습니다. 이 타입은 WinForm의 경우 UI Thread를 대표하고 ASP.NET의 경우 각각의 요청을 처리하는 스레드를 대표합니다.

사실 SynchronizationContext가 없었다면 async/await은 굳이 UI Thread에 코드를 실어서 실행하려고 하지 않았을 수 있습니다. 혹은 사용될 때마다 await 이후의 코드에 UI 코드가 있을 때마다 그것을 안전하게 접근할 수 있는 스레드를 명시하기 위해 별도의 장치를 마련했어야 할 것입니다. 하지만 SynchronizationContext가 있기 때문에 UI 요소를 생성한 스레드를 전혀 모르고도 안전하게 UI 스레드에 태워서 자연스럽게 실행할 수 있게 된 것입니다.

이렇게 정책을 정한 데에는 약간의 계산이 있었을 것입니다. 사실 Windows Forms 응용 프로그램을 만든다면 대부분의 경우 UI 요소를 접근하게 될 것이므로 마이크로소프트는 별도의 스레드에서 UI를 접근하는 바람에 예외가 발생하는 문제로 초보자들이 당황하기 보다는 그냥 디폴트로 윈도우 폼에서 실행되는 모든 async/await의 호출은 완료 후의 코드를 UI 스레드에 실어서 보내도록 디자인했을 것입니다. 만약 그렇게 하지 않았다면 어떻게 되었을지 상상해 보는 것도 재미있을 텐데요. 대충 아래와 같이 코드가 될지도 모를 일입니다.

public static async Task<string> GetHtmlTextAsync()
{
    var client = new TestClass();
    string result = await client.GetTextAsync(); // await으로 만들었기 때문에,
                                                 // 이후의 코드는 UI Thread가 아닌 별도의 스레드에서 실행되므로

    this.Invoke(                                 // 이렇게 UI Thread에 태우는 코드로 변경해야 할까요?
        (Action)(() => { return result; }) 
    );
}

그냥 봐도 이건 더 말이 안되는 코드입니다.

결국, 마이크로소프트는 UI Thread에 태우기로 결정했기 때문에 이번 글의 내용과 같은 문제가 부수적으로 발생하는 것입니다.

그렇다면, UI 요소를 갖는 프로그램은 이해할 수 있겠는데 왜??? ASP.NET Web API에서 그런 현상이 있는지 궁금하실 텐데요. Web API의 실행 과정이 결국 전체적인 입장에서는 동기 방식으로 동작함을 인지한다면 그리 이상할 것도 없는 문제입니다.

웹 브라우저에서 ASP.NET에 요청을 했는데 그 요청을 받은 ThreadPool의 스레드가 내부적으로 (가령, DB로부터 데이터를 구하는) 비동기 호출을 하고는 이후의 처리를 계속 진행하면 결국 HTTP 응답은 원치 않는 결과를 반환할 수 있습니다. 즉, ThreadPool의 스레드는 비동기 호출을 했지만 그 결과를 어떤 식으로든지 반드시 받아서 그것을 포함하여 HTTP 응답을 해야하는 것입니다. 이 때문에 async/await은 현재 자신의 비동기 처리를 유발시킨 스레드를 알아야 하고 그 스레드에 결과를 전달해야 하기 때문에 유사한 문제가 발생하는 것입니다.

쉽게 설명한다고 하긴 했는데, 혹시 더 혼란스러워진 것은 아닌가 걱정이 되는군요. ^^




위의 글을 읽으면서 왜??? this.BeginInvoke가 아닌 this.Invoke 식으로 처리했을까 하는 의문을 가지신 분이 계신가요? ^^ 즉, SynchronizationContext 타입의 경우 Send가 아닌 Post 메서드로 처리했다면 아마 이런 식의 hang 문제는 없었을 것입니다.

사실, 저도 이 부분에 대해서는 왜 마이크로소프트가 Invoke 유를 고집했는지 궁금합니다. 어쨌든 마이크로소프트는 this.Invoke를 선택했고 이 때문에 지금의 문제가 발생한 것입니다. 이로써 마이크로소프트는 UI Thread가 아닌, 비동기 실행을 위해 사용되었던 ThreadPool의 스레드에서 그대로 await 분리 코드를 처리할 수 있는 옵션을 마련하게 되었고, 바로 그것이 Don't Block on Async Code 글에서 제시된 첫 번째 해결책입니다.

이 글의 예제에서라면 다음과 같이 ConfigureAwait을 호출해주면 됩니다.

public static async Task<string> GetHtmlTextAsync()
{
    var client = new TestClass();
    string result = await client.GetTextAsync().ConfigureAwait(false);
    return result;
}

Don't Block on Async Code 글에서 제시된 두 번째 해결책을 볼까요? 실제로 제 경험으로 비춰보면 GetHtmlTextAsync 메서드에서 Task를 반환하도록 했기 때문에 그에 대해서도 await을 지정하는 것이 더 일반적이었습니다. 그래서 개인적으로는 이 방법을 더 선호합니다.

private async void Form1_Load(object sender, EventArgs e)
{
    var textTask = await GetHtmlTextAsync();
    this.Text = textTask;
}

public static async Task<string> GetHtmlTextAsync()
{
    var client = new TestClass();
    string result = await client.GetTextAsync();
    return result;
}

제가 두 번째 방법을 선호하는 또 다른 이유가 있는데, 첫 번째 방법이 가끔 여전히 hang을 발생시키기 때문입니다. 가령 WebClient의 경우 ConfigureAwait(false) 처리를 해도 hang 현상이 발생하는 것은 마찬가지입니다.

using System;
using System.Net;
using System.Threading.Tasks;
using System.Windows.Forms;

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

        private void Form1_Load(object sender, EventArgs e)
        {
            Uri uri = new Uri("https://www.sysnet.pe.kr");

            var textTask = GetHtmlTextAsync(uri);

            this.Text = textTask.Result; // 여전히 hang 현상은 발생함.
        }

        public static async Task<string> GetHtmlTextAsync(Uri uri)
        {
            var client = new WebClient();
            {
                string result = await client.DownloadStringTaskAsync(uri).ConfigureAwait(false); // 처리를 했지만,
                return result;
            }
        }
    }
}

위의 코드를 실행하면 ConfigureAwait(false) 처리를 했음에도 불구하고 여전히 hang이 걸립니다. 이것이 버그인가... 하는 정확한 원인은 저도 아직 잘 모르겠습니다. ^^




위의 코드를 테스트한 예제를 첨부했고 각각의 프로젝트에 포함된 코드는 각각의 현상 별로 나눠놓은 것입니다.

WindowsFormsApplication3: 원래의 hang 현상을 발생시키는 코드
WindowsFormsApplication2: 스레드로 재현한 코드
WindowsFormsApplication1: WebClient를 사용한 hang 현상을 재현
WindowsFormsApplication4: (테스트 삼아 만들어 본 TaskCompletionSource 사용 예제)



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

[연관 글]


donaricano-btn



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

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

비밀번호

댓글 쓴 사람
 



2013-11-25 02시47분
[ryujh] 안녕하세요.

첫번째 링크가 '서버프로그래머인데 데드락이 왜 걸리는지 모르겠어요' 게임코디의 링크가 아닌데 수정된것입니까?

예제처럼 윈폼에서 form_load 이벤트 발생시 단 한번 실행한다면 async/await 사용하지 않고 QueueUserWorkItem 에 메소드 넘겨서 실행하는 것은 어떤지요?
또는 스레드로 바꾼 예제에서 join 하지 않고 GetHtmlTextFunc 가 끝날때 상태값을 변화시고 변화된 상태값으로 다른 메소드를 실행하는 것도 그렇습니다.

MSDN 에 따르면 비동기 처리가 여러가지 있는데
async/await 를 사용하기에 가장 좋은 경우는 어떤 것이 있을지요?

질문만 많습니다. 감사합니다.
[손님]
2013-11-25 11시08분
링크 수정했습니다.

^^ 물론, Thread나 ThreadPool을 사용해도 되겠지요. 위의 글에서 form_load 이벤트라는 상황은 사실 중요하지 않습니다. 이 글의 주제는 async/await 사용시 어떤 경우에 hang 현상이 발생할 수 있는지를 보는 것입니다.

MSDN의 비동기 처리가 여러가지가 있지만 결국 유사한 방식을 따릅니다. 따라서 그냥 async/await으로 통합시켜도 좋습니다.
정성태
2013-11-29 08시07분
[spowner] 전 async/await 키워드를 사랑합니다. ㅎㅎ 자칫 헤깔릴 수도 있는데. 저도 완벽히 이해한 것은 아니고 이해한 것만큼 사용하니 편하더만요. 저도 첫번째 방법을 사용합니다.
[손님]
2015-05-31 02시45분
이어서, 아래의 2번째 이야기를 꼭 읽어보세요. ^^

async/await 사용 시 hang 문제가 발생하는 경우 - 두 번째 이야기
; http://www.sysnet.pe.kr/2/0/10801
정성태
2015-10-20 06시17분
[김수영] 안녕하세요.
항상 정성태 MVP님의 깊이 있는 글 잘 보고 있습니다.
저도 async/await 관련 여러글 보다 여기까지 왔네요.ㅎ

본문 중에 ConfigureAwait(false)에도 여진히 hang 걸린다는 부분이 혹시 최초 실행시 발생하는 문제인가요?
저의 경우 이런저런 테스트 중 네트워크 처리 관련 처리시 유독 WebClient로 처리할 경우 최초 실행시 비동기로 작성을 해도 항상 hang이 걸리는 문제가 있었습니다.

그러다 찾은 해결책이 client.Proxy = null; 로 설정하는 방법 이었습니다.

감사합니다^^

참고 : http://stackoverflow.com/questions/4932541/c-sharp-webclient-acting-slow-the-first-time
[손님]
2015-10-20 12시50분
@김수영 님, 아쉽게도 ^^ 이글에 첨부한 WindowsFormsApplication1 프로젝트에서 확인해 보면 client.Proxy = null로 해도 여전히 발생합니다. Proxy를 null로 한 경우 hang 현상이 발생하는 것은 아마도 시스템에 proxy가 설정된 경우 해당 프록시가 정상적으로 동작하지 않는 경우가 아닐까 싶습니다. 혹시 아래의 글을 보시고,

마이크로소프트 웹 사이트 연결이 안될 때
; http://www.sysnet.pe.kr/0/0/334

IE에 Proxy server가 설정되어 있는지 확인해 주실 수 있을까요? ^^
정성태
2015-10-23 02시11분
[김수영] 안녕하세요^^

혹시 컴퓨터에 프록시 설정이 되어 있나 확인해 보니...별다른 설정은 없었습니다.

그리고, 위에 샘플 코드를 다시 보니 Task.Result로 결과를 받던데, 이 부분은 ConfigureAwait(false) 설정과 상관 없이 항상 UI가 블럭 되더라구요.
그래서 다음 참고 링크에서는 UI에서는 Task.Wait(), Task.Result는 사용하지 말라고 하더라구요.
결국은 async, await가 답인거 같습니다...

[참고]
Async/Await - Best Practices in Asynchronous Programming
https://msdn.microsoft.com/en-us/magazine/jj991977.aspx

Await, and UI, and deadlocks! Oh my!
http://blogs.msdn.com/b/pfxteam/archive/2011/01/13/await-and-ui-and-deadlocks-oh-my.aspx

감사합니다.
[손님]

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
12839정성태9/15/2021217.NET Framework: 1118. C# 10 - (17) 제네릭 타입의 특성 적용파일 다운로드1
12838정성태9/13/2021213.NET Framework: 1117. C# - Task에 전달한 Action, Func 유형에 따라 달라지는 async/await 비동기 처리 [2]파일 다운로드1
12837정성태9/11/2021117VC++: 151. Golang - fmt.Errorf, errors.Is, errors.As 설명
12836정성태9/10/202183Linux: 45. 리눅스 - 실행 중인 다른 프로그램의 출력을 확인하는 방법
12835정성태9/7/2021158.NET Framework: 1116. C# 10 - (16) CallerArgumentExpression 특성 추가파일 다운로드1
12834정성태9/7/202175오류 유형: 762. Visual Studio 2019 Build Tools - 'C:\Program' is not recognized as an internal or external command, operable program or batch file.
12833정성태9/6/2021121VC++: 150. Golang - TCP client/server echo 예제 코드파일 다운로드1
12832정성태9/6/202180VC++: 149. Golang - 인터페이스 포인터가 의미 있을까요?
12831정성태9/6/2021110VC++: 148. Golang - 채널에 따른 다중 작업 처리파일 다운로드1
12830정성태9/6/202171오류 유형: 761. Internet Explorer에서 파일 다운로드 시 "Your current security settings do not allow this file to be downloaded." 오류
12829정성태9/5/2021201.NET Framework: 1115. C# 10 - (15) 구조체 타입에 기본 생성자 정의 가능파일 다운로드1
12828정성태9/4/2021192.NET Framework: 1114. C# 10 - (14) 단일 파일 내에 적용되는 namespace 선언파일 다운로드1
12827정성태9/4/2021112스크립트: 27. 파이썬 - 웹 페이지 데이터 수집을 위한 scrapy Crawler 사용법 요약
12826정성태9/3/2021168.NET Framework: 1113. C# 10 - (13) 문자열 보간 성능 개선파일 다운로드1
12825정성태9/3/202169개발 환경 구성: 603. GoLand - WSL 환경과 연동
12824정성태9/2/2021140오류 유형: 760. 파이썬 tensorflow - Dst tensor is not initialized. 오류 메시지
12823정성태9/2/2021164스크립트: 26. 파이썬 - PyCharm을 이용한 fork 디버그 방법
12822정성태9/1/202197오류 유형: 759. 파이썬 tensorflow - ValueError: Shapes (...) and (...) are incompatible
12821정성태9/1/2021150.NET Framework: 1112. C# - .NET 6부터 공개된 ISpanFormattable 사용법
12820정성태9/1/202187VC++: 147. Golang - try/catch에 대응하는 panic/recover파일 다운로드1
12819정성태8/31/2021182.NET Framework: 1111. C# - FormattableString 타입
12818정성태8/31/2021118Windows: 198. 윈도우 - 작업 관리자에서 (tensorflow 등으로 인한) GPU 연산 부하 보는 방법
12817정성태8/31/202176스크립트: 25. 파이썬 - 윈도우 환경에서 directml을 이용한 tensorflow의 AMD GPU 사용 방법
12816정성태8/30/2021319스크립트: 24. 파이썬 - tensorflow 2.6 NVidia GPU 사용 방법 [2]
12815정성태8/30/2021199개발 환경 구성: 602. WSL 2 - docker-desktop-data, docker-desktop (%LOCALAPPDATA%\Docker\wsl\data\ext4.vhdx) 파일을 다른 디렉터리로 옮기는 방법
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...