WebClient 타입의 ...Async 메서드 호출은 왜 await + 동기 호출 시 hang 현상이 발생할까요?
예전에 쓴 글에서,
async/await 사용 시 hang 문제가 발생하는 경우
; https://www.sysnet.pe.kr/2/0/1541
WebClient의 비동기 메서드 호출 시 hang 현상이 발생하는 경우를 다뤘었습니다.
// Windows Forms 응용 프로그램
private void Form1_Load(object sender, EventArgs e)
{
Uri uri = new Uri("https://www.sysnet.pe.kr");
Task<string> 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);
return result;
}
}
전에 설명한 대로 위의 현상은 SynchronizationContext의 영향이기 때문에 이해가 갑니다. 하지만, await 구문에서 다음과 같이 ConfigureAwait(false) 처리를 해도 마찬가지 결과가 나온다는 것은 좀 이상합니다.
string result = await client.DownloadStringTaskAsync(uri).ConfigureAwait(false);
분명히 ConfigureAwait(false) 호출은 SynchronizationContext와는 상관이 없는데,
비동기 메서드 내에서 await 시 ConfigureAwait 호출 의미
; https://www.sysnet.pe.kr/2/0/11418
Task.Result 호출에서 스레드가 dead-lock 걸리는 것이 말이 안 됩니다. 도대체 원인이 뭘까요? ^^
원인을 밝히기 위해 DownloadStringTaskAsync를 보겠습니다.
[ComVisible(false), HostProtection(SecurityAction.LinkDemand, ExternalThreading=true)]
public Task<string> DownloadStringTaskAsync(Uri address)
{
<>c__DisplayClass219_0 class_ = new <>c__DisplayClass219_0();
class_.<>4__this = this;
class_.tcs = new TaskCompletionSource<string>(address);
class_.handler = null;
class_.handler = new DownloadStringCompletedEventHandler(class_.<DownloadStringTaskAsync>b__0);
this.DownloadStringCompleted += class_.handler;
try
{
this.DownloadStringAsync(address, class_.tcs);
}
catch
{
this.DownloadStringCompleted -= class_.handler;
throw;
}
return class_.tcs.Task;
}
Task 객체를 담은 TaskCompletionSource를 생성하는 것외에는 딱히 별다른 것 없이 DownloadStringAsync를 호출합니다.
[HostProtection(SecurityAction.LinkDemand, ExternalThreading=true)]
public void DownloadStringAsync(Uri address, object userToken)
{
if (Logging.On)
{
Logging.Enter(Logging.Web, this, "DownloadStringAsync", address);
}
if (address == null)
{
throw new ArgumentNullException("address");
}
this.InitWebClientAsync();
this.ClearWebClientState();
AsyncOperation asyncOp = AsyncOperationManager.CreateOperation(userToken);
this.m_AsyncOp = asyncOp;
try
{
WebRequest request = this.m_WebRequest = this.GetWebRequest(this.GetUri(address));
this.DownloadBits(request, null, new CompletionDelegate(this.DownloadStringAsyncCallback), asyncOp);
}
catch (Exception exception)
{
if (((exception is ThreadAbortException) || (exception is StackOverflowException)) || (exception is OutOfMemoryException))
{
throw;
}
if (!(exception is WebException) && !(exception is SecurityException))
{
exception = new WebException(SR.GetString("net_webclient"), exception);
}
this.DownloadStringAsyncCallback(null, exception, asyncOp);
}
if (Logging.On)
{
Logging.Exit(Logging.Web, this, "DownloadStringAsync", "");
}
}
위의 코드에서 DownloadStringAsync 메서드 역시 별다르게 하는 일은 없고 대부분의 동작을 DownloadBits 메서드에 위임합니다. 하지만, 특이하게 AsyncOperationManager.CreateOperation으로 비동기 처리를 위한 옵션을 구성하는데요, 바로 여기에 답이 있습니다.
[__DynamicallyInvokable]
public static AsyncOperation CreateOperation(object userSuppliedState)
{
return AsyncOperation.CreateOperation(userSuppliedState, SynchronizationContext);
}
[EditorBrowsable(EditorBrowsableState.Advanced), __DynamicallyInvokable]
public static SynchronizationContext SynchronizationContext
{
[__DynamicallyInvokable]
get
{
if (SynchronizationContext.Current == null)
{
SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
}
return SynchronizationContext.Current; // Windows Forms의 SynchronizationContext 문맥 반환
}
[__DynamicallyInvokable, PermissionSet(SecurityAction.LinkDemand, Name="FullTrust")]
set
{
SynchronizationContext.SetSynchronizationContext(value);
}
}
보는 바와 같이 SynchronizationContext.Current로 비동기를 구성하고 있기 때문에 애당초 DownloadStringTaskAsync 메서드는 ConfigureAwait(false)에 상관없이 SynchronizationContext에 기반을 둬 비동기 처리를 하고 있었던 것입니다. 확인을 위해 DownloadBits에 전달된,
this.DownloadBits(request, null, new CompletionDelegate(this.DownloadStringAsyncCallback), asyncOp);
완료 callback 메서드인 DownloadStringAsyncCallback를 따라가 보면,
private void DownloadStringAsyncCallback(byte[] returnBytes, Exception exception, object state)
{
AsyncOperation asyncOp = (AsyncOperation) state;
string result = null;
try
{
if (returnBytes != null)
{
result = this.GetStringUsingEncoding(this.m_WebRequest, returnBytes);
}
}
catch (Exception exception2)
{
if (((exception2 is ThreadAbortException) || (exception2 is StackOverflowException)) || (exception2 is OutOfMemoryException))
{
throw;
}
exception = exception2;
}
DownloadStringCompletedEventArgs eventArgs = new DownloadStringCompletedEventArgs(result, exception, this.m_Cancelled, asyncOp.UserSuppliedState);
this.InvokeOperationCompleted(asyncOp, this.downloadStringOperationCompleted, eventArgs);
}
private void InvokeOperationCompleted(AsyncOperation asyncOp, SendOrPostCallback callback, AsyncCompletedEventArgs eventArgs)
{
if (Interlocked.CompareExchange<AsyncOperation>(ref this.m_AsyncOp, null, asyncOp) == asyncOp)
{
this.CompleteWebClientState();
asyncOp.PostOperationCompleted(callback, eventArgs);
}
}
[__DynamicallyInvokable]
public void PostOperationCompleted(SendOrPostCallback d, object arg)
{
this.Post(d, arg);
this.OperationCompletedCore();
}
[__DynamicallyInvokable]
public void Post(SendOrPostCallback d, object arg)
{
this.VerifyNotCompleted();
this.VerifyDelegateNotNull(d);
this.syncContext.Post(d, arg);
}
결국 콜백 메서드를
SynchronizationContext.Post를 이용해 호출하고 있습니다. 정리해 보면, DownloadStringTaskAsync뿐만 아니라 내부적으로 AsyncOperationManager.CreateOperation을 사용한 모든 ...Async 메서드들은 ConfigureAwait(true)를 한 것과 동일한 비동기 처리를 합니다.
참고로 다음과 같이 간단하게(?) DownloadStringTaskAsync와 유사한 재현 코드를 만드는 것도 가능합니다.
using System;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WindowsFormsApp1
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private void Form1_Load(object sender, EventArgs e)
{
Task<string> textTask = TestAsync();
this.Text = textTask.Result; // hang
}
public delegate string DummyMethodDelegate();
public class DummyAsyncState
{
public AsyncOperation AsyncOp;
public SendOrPostCallback TaskCompleted;
public string Result;
}
public static async Task<string> TestAsync()
{
TaskCompletionSource<string> tcs = new TaskCompletionSource<string>(null);
AsyncOperation asyncOp = null;
asyncOp = AsyncOperationManager.CreateOperation(tcs);
var state = new DummyAsyncState { AsyncOp = asyncOp, TaskCompleted = actionDummyCompleted };
DummyMethodDelegate dummy = new DummyMethodDelegate(dummyEndFunc);
var tuple = new Tuple<DummyMethodDelegate, DummyAsyncState>(dummy, state);
dummy.BeginInvoke(calcCompleted, tuple);
return await tcs.Task.ConfigureAwait(false);
}
public static void actionDummyCompleted(object arg)
{
DummyAsyncState state = arg as DummyAsyncState;
var tcs = state.AsyncOp.UserSuppliedState as TaskCompletionSource<string>;
tcs.TrySetResult(state.Result);
}
public static string dummyEndFunc()
{
return "TEST";
}
static void calcCompleted(IAsyncResult ar)
{
var tuple = ar.AsyncState as Tuple<DummyMethodDelegate, DummyAsyncState>;
if (ar.IsCompleted == true)
{
string result = tuple.Item1.EndInvoke(ar);
var state = tuple.Item2 as DummyAsyncState;
state.Result = result;
state.AsyncOp.PostOperationCompleted(state.TaskCompleted, state);
}
}
}
}
위의 코드는 ConfigureAwait(false)을 했지만, AsyncOperation에 의해 콜백 메서드가 SynchronizationContext 상에서 실행되었기 때문에 hang 현상이 발생하는 것입니다. 만약에 다음과 같이 AsyncOperation을 사용하지 않고 ThreadPool을 이용해 구현했다면,
// state.AsyncOp.PostOperationCompleted(state.TaskCompleted, state);
System.Threading.ThreadPool.QueueUserWorkItem(state.TaskCompleted, state);
ConfigureAwait(false) 옵션이 적용되어 hang 현상에 빠지지 않습니다. 물론 ConfigureAwait(true)로 하면 hang 현상에 빠지지만 이것은 ThreadPool.QueueUserWorkItem으로 처리한 것과 상관없이 await의 문제입니다.
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
정리해 보면, 여러분이 사용할 비동기 메서드에서 내부적으로 AsyncOperation을 사용하고 있는지 (이번 글에서처럼 분석하기 전에는) 외부에서 알 방법이 없습니다.
따라서 다음의 글에서 했던 2번째 조언이,
Async/Await - Best Practices in Asynchronous Programming
; https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming
가장 적절한 해결책입니다. "Async all the way"!
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]