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