async/await 사용 시 hang 문제가 발생하는 경우 - 두 번째 이야기
오호~~~ 재미있는 글이 쓰여졌군요. ^^
SynchronizationContext와 async/await - 1. What the?
; http://blog.naver.com/vactorman/220371851151
SynchronizationContext와 async/await - 2. 얉은 구조
; http://blog.naver.com/vactorman/220371861151
SynchronizationContext와 async/await - 3. Custom SynchronizationContext 구현
; http://blog.naver.com/vactorman/220371881666
SynchronizationContext와 async/await - 4. async/await 궁합
; http://blog.naver.com/vactorman/220371896727
4번째 "
SynchronizationContext와 async/await - 4. async/await 궁합 " 글에 보면 제가 쓴 다음의 글도 소개하고 있습니다.
async/await 사용 시 hang 문제가 발생하는 경우
; https://www.sysnet.pe.kr/2/0/1541
그런데, 4번째 글에서 언급하고 있는 내용이 제 글의 내용과 충돌합니다. 제 글에서는 스레드 블록킹 현상의 원인이 "SynchronizationContext"의 "Post" 처리를 하지 않고 "Send" 처리를 했기 때문이라고 했는데 "
SynchronizationContext와 async/await - 4. async/await 궁합 " 글에서는 "async/await 사용 시 await 이하 구문은 현재 SynchronizationContext의 Post()로 재진입하여 수행된다"라고 언급합니다.
분명히 누구 하나는 틀린 이야기를 하고 있습니다. ^^
그래서 천천히 검증을 한번 해봤습니다. ^^ 우선, 소스 코드 디버깅을 하기 위해 cmd.exe 창을 띄우고 아래의 글에 따라,
COMPLUS_ZapDisable - JIT 최적화 코드 생성 제어
; https://www.sysnet.pe.kr/2/0/646
다음과 같이 최적화 옵션을 끄고 개발 환경을 실행했습니다. (F5 디버깅을 위해!)
C:\Program Files (x86)\Microsoft Visual Studio 12.0>set COMPLUS_ZapDisable=1
C:\Program Files (x86)\Microsoft Visual Studio 12.0>devenv
그런 다음 ".NET Reflector"의 소스 코드 디버깅 옵션을 mscorlib.dll, System.dll, System.Core.dll System.Windows.Forms.dll 들에 대해 "Generate PDB"를 적용해 두었습니다.
자... 그래서 "
async/await 사용 시 hang 문제가 발생하는 경우"의 글에 첨부한 예제 프로젝트를 실행시켜 hang이 걸리게 실행한 다음 콜 스택을 통해 실행이 멈춘 지점을 확인해 보았습니다. "this.Text = textTask.Result" 코드의 실행 부분의 콜 스택에 다음의 InternalWait 호출을 확인할 수 있습니다.
[System.Threading.Tasks.Task<TResult>]
internal TResult GetResultCore(bool waitCompletionNotification)
{
if (!base.IsCompleted)
{
base.InternalWait(-1, new CancellationToken());
}
if (waitCompletionNotification)
{
base.NotifyDebuggerOfWaitCompletionIfNecessary();
}
if (!base.IsRanToCompletion)
{
base.ThrowIfExceptional(true);
}
return this.m_result;
}
InternalWait 함수를 내려가 보면,
[System.Threading.Tasks.Task]
[MethodImpl(MethodImplOptions.NoOptimization)]
internal bool InternalWait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken)
{
TplEtwProvider log = TplEtwProvider.Log;
bool flag = log.IsEnabled();
if (flag)
{
Task internalCurrent = InternalCurrent;
log.TaskWaitBegin((internalCurrent != null) ? internalCurrent.m_taskScheduler.Id : TaskScheduler.Default.Id, (internalCurrent != null) ? internalCurrent.Id : 0, this.Id, TplEtwProvider.TaskWaitBehavior.Synchronous);
}
bool isCompleted = this.IsCompleted;
if (!isCompleted)
{
Debugger.NotifyOfCrossThreadDependency();
if (((millisecondsTimeout == -1) && !cancellationToken.CanBeCanceled) && (this.WrappedTryRunInline() && this.IsCompleted))
{
isCompleted = true;
}
else
{
isCompleted = this.SpinThenBlockingWait(millisecondsTimeout, cancellationToken);
}
}
if (flag)
{
Task task2 = InternalCurrent;
if (task2 != null)
{
log.TaskWaitEnd(task2.m_taskScheduler.Id, task2.Id, this.Id);
return isCompleted;
}
log.TaskWaitEnd(TaskScheduler.Default.Id, 0, this.Id);
}
return isCompleted;
}
this.SpinThenBlockingWait 호출로 이어지고,
[System.Threading.Tasks.Task]
private bool SpinThenBlockingWait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken)
{
bool flag = millisecondsTimeout == -1;
uint num = flag ? 0 : ((uint) Environment.TickCount);
bool flag2 = this.SpinWait(millisecondsTimeout);
if (!flag2)
{
SetOnInvokeMres action = new SetOnInvokeMres();
try
{
this.AddCompletionAction(action, true);
if (flag)
{
return action.Wait(-1, cancellationToken);
}
uint num2 = ((uint) Environment.TickCount) - num;
if (num2 < millisecondsTimeout)
{
flag2 = action.Wait((int) (millisecondsTimeout - ((int) num2)), cancellationToken);
}
}
finally
{
if (!this.IsCompleted)
{
this.RemoveContinuation(action);
}
}
}
return flag2;
}
보는 바와 같이 SpinThenBlockingWait 내에서는 SetOnInvokeMres 타입의 인스턴스를 생성한 후 AddCompletionAction을 호출 한후 action.Wait 대기 상태로 빠집니다. 그럼, SetOnInvokeMres 타입과 AddCompletionAction으로 들어가 볼까요?
[System.Threading.Tasks.Task]
private sealed class SetOnInvokeMres : ManualResetEventSlim, ITaskCompletionAction
{
internal SetOnInvokeMres() : base(false, 0)
{
}
public void Invoke(Task completingTask)
{
base.Set();
}
}
private void AddCompletionAction(ITaskCompletionAction action, bool addBeforeOthers) // addBeforeOthers == true
{
if (!this.AddTaskContinuation(action, addBeforeOthers)) // AddTaskContinuation 호출이 return == true 반환해서 메서드를 빠져나감.
{
action.Invoke(this); // 호출되지 않았음.
}
}
만약 위의 코드에서 AddTaskContinuation 메서드가 false를 반환했다면 action.Invoke가 불렸을 테고, SetOnInvokeMres.Invoke 메서드에서는 "base.Set()"의 호출로 이벤트를 Signal하기 때문에 이후의 action.Wait 대기가 곧바로 해제되어 hang 현상은 없었을 것입니다. 그럼, 다시 AddTaskContinuation을 들어가 보면,
[System.Threading.Tasks.Task]
private bool AddTaskContinuation(object tc, bool addBeforeOthers)
{
if (this.IsCompleted)
{
return false;
}
return (((this.m_continuationObject == null) && (Interlocked.CompareExchange(ref this.m_continuationObject, tc, null) == null)) || this.AddTaskContinuationComplex(tc, addBeforeOthers));
}
hang 현상이 발생했을 때의 코드 실행은 this.m_continuationObject == null인 상태로, Interlocked.CompareExchange 코드에 의해 tc (즉, SetOnInvokeMres 인스턴스) 객체를 this.m_continuationObject 에 할당하는 것으로 마무리를 지은 후 true를 반환합니다.
최종적으로는 결국, this.m_continuationObject 객체의 Invoke 메서드가 호출되지 않았기 때문에 발생하는 것입니다. 그렇다면 해당 객체의 Invoke 메서드는 언제 발생하는 것일까요? 추적해 보면 특별한 예외 상황이 발생하지 않는 경우를 제외한다면,
[System.Threading.Tasks.Task]
[SecuritySafeCritical]
internal void FinishContinuations()
{
object obj2 = Interlocked.Exchange(ref this.m_continuationObject, s_taskCompletionSentinel);
TplEtwProvider.Log.RunningContinuation(this.Id, obj2);
if (obj2 != null)
{
if (AsyncCausalityTracer.LoggingOn)
{
AsyncCausalityTracer.TraceSynchronousWorkStart(CausalityTraceLevel.Required, this.Id, CausalitySynchronousWork.CompletionNotification);
}
bool allowInlining = (((this.m_stateFlags & 0x8000000) == 0) && (Thread.CurrentThread.ThreadState != ThreadState.AbortRequested)) && ((this.m_stateFlags & 0x40) == 0);
Action action = obj2 as Action;
if (action != null)
{
AwaitTaskContinuation.RunOrScheduleAction(action, allowInlining, ref t_currentTask);
this.LogFinishCompletionNotification();
}
else
{
ITaskCompletionAction action2 = obj2 as ITaskCompletionAction;
if (action2 != null)
{
action2.Invoke(this);
this.LogFinishCompletionNotification();
}
else
{
// ...[생략]...
}
}
}
}
FinishContinuations 메서드가 나오고, 그것을 호출하는 메서드들은 FinishStageThree, FinishStageTwo를 이어 Finish 메서드까지 이어갑니다.
[System.Threading.Tasks.Task]
internal void FinishStageThree()
{
this.m_action = null;
if (((this.m_parent != null) && ((this.m_parent.CreationOptions & TaskCreationOptions.DenyChildAttach) == TaskCreationOptions.None)) && (((this.m_stateFlags & 0xffff) & 4) != 0))
{
this.m_parent.ProcessChildCompletion(this);
}
this.FinishContinuations();
}
internal void FinishStageTwo()
{
int num;
this.AddExceptionsFromChildren();
// ...[생략]...
Interlocked.Exchange(ref this.m_stateFlags, this.m_stateFlags | num);
ContingentProperties contingentProperties = this.m_contingentProperties;
if (contingentProperties != null)
{
contingentProperties.SetCompleted();
contingentProperties.DeregisterCancellationCallback();
}
this.FinishStageThree();
}
internal void Finish(bool bUserDelegateExecuted)
{
if (!bUserDelegateExecuted)
{
this.FinishStageTwo();
}
else
{
ContingentProperties contingentProperties = this.m_contingentProperties;
if (((contingentProperties == null) || ((contingentProperties.m_completionCountdown == 1) && !this.IsSelfReplicatingRoot)) || (Interlocked.Decrement(ref contingentProperties.m_completionCountdown) == 0))
{
this.FinishStageTwo();
}
else
{
this.AtomicStateUpdate(0x800000, 0x1600000);
}
// ...[생략]...
}
}
마지막 최상위 호출은 ExecuteWithThreadLocal에서 이뤄집니다.
[System.Threading.Tasks.Task]
[SecurityCritical]
private void ExecuteWithThreadLocal(ref Task currentTaskSlot)
{
Task task = currentTaskSlot;
TplEtwProvider log = TplEtwProvider.Log;
Guid oldActivityThatWillContinue = new Guid();
bool flag = log.IsEnabled();
if (flag)
{
if (log.TasksSetActivityIds)
{
EventSource.SetCurrentThreadActivityId(TplEtwProvider.CreateGuidForTaskID(this.Id), out oldActivityThatWillContinue);
}
if (task != null)
{
log.TaskStarted(task.m_taskScheduler.Id, task.Id, this.Id);
}
else
{
log.TaskStarted(TaskScheduler.Current.Id, 0, this.Id);
}
}
if (AsyncCausalityTracer.LoggingOn)
{
AsyncCausalityTracer.TraceSynchronousWorkStart(CausalityTraceLevel.Required, this.Id, CausalitySynchronousWork.Execution);
}
try
{
currentTaskSlot = this;
ExecutionContext capturedContext = this.CapturedContext;
if (capturedContext == null)
{
this.Execute();
}
else
{
if (this.IsSelfReplicatingRoot || this.IsChildReplica)
{
this.CapturedContext = CopyExecutionContext(capturedContext);
}
ContextCallback callback = s_ecCallback;
if (callback == null)
{
s_ecCallback = callback = new ContextCallback(Task.ExecutionContextCallback);
}
ExecutionContext.Run(capturedContext, callback, this, true);
}
if (AsyncCausalityTracer.LoggingOn)
{
AsyncCausalityTracer.TraceSynchronousWorkCompletion(CausalityTraceLevel.Required, CausalitySynchronousWork.Execution);
}
this.Finish(true);
}
finally
{
currentTaskSlot = task;
if (flag)
{
if (task != null)
{
log.TaskCompleted(task.m_taskScheduler.Id, task.Id, this.Id, this.IsFaulted);
}
else
{
log.TaskCompleted(TaskScheduler.Current.Id, 0, this.Id, this.IsFaulted);
}
if (log.TasksSetActivityIds)
{
EventSource.SetCurrentThreadActivityId(oldActivityThatWillContinue);
}
}
}
}
ExecuteWithThreadLocal이 실행될 때의 callstack을 보면,
mscorlib.dll!System.Threading.Tasks.Task.ExecuteWithThreadLocal(ref System.Threading.Tasks.Task currentTaskSlot) Line 1003
mscorlib.dll!System.Threading.Tasks.Task.ExecuteEntry(bool bPreventDoubleExecution) Line 929
mscorlib.dll!System.Threading.Tasks.Task.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem() Line 2217
mscorlib.dll!System.Threading.ThreadPoolWorkQueue.Dispatch() Line 119
mscorlib.dll!System.Threading._ThreadPoolWaitCallback.PerformWaitCallback() Line 12
_ThreadPoolWaitCallback.PerformWaitCallback이 루트인데, 결국 ThreadPool의 작업자 스레드의 하나로 실행된 것입니다. 이 스레드는 await로 인해 최초 비동기 호출을 했을 때의 바로 그 스레드입니다.
public async Task<string> GetHtmlTextAsync()
{
string result = await GetTextAsync();
return result;
}
여기까지 정리한 것을 우리가 만들었던 C# 소스코드에 적용해 설명해 보면, 대충 이렇습니다.
private void Form1_Load(object sender, EventArgs e)
{
var textTask = GetHtmlTextAsync(); // testTask는 Task 인스턴스이고,
// testTask 인스턴스에 할당된 스레드는 _ThreadPoolWaitCallback.PerformWaitCallback ~ ExecuteWithThreadLocal 호출의 작업자 스레드임
this.Text = textTask.Result;
// 이곳에서 action.Wait의 호출로 Task 작업자 스레드가 모든 작업이 완료될때까지 대기.
}
public async Task<string> GetHtmlTextAsync()
{
string result = await GetTextAsync(); // _ThreadPoolWaitCallback.PerformWaitCallback 작업자 스레드에 의해
// Task.Factory.StartNew에 전달된 익명 함수를 실행하고,
// 나머지 다음의 작업을,
// string result = ...
// return result;
// _ThreadPoolWaitCallback.PerformWaitCallback을 실행하던 작업자 스레드가 마저 실행해야 함.
// 그런데, 윈도우 폼 응용 프로그램에는 미리 설정된 SynchronizationContext가 있으므로,
// _ThreadPoolWaitCallback.PerformWaitCallback을 실행하던 작업자 스레드가 나머지 작업을 직접 실행하지 못하고,
// WindowsFormsSynchronizationContext.Post로 전달하고,
// 이 단계에서 Task의 작업자 스레드는 모든 처리를 완료했으므로 스레드 풀에 반환됨.
return result;
}
public Task<string> GetTextAsync()
{
return Task.Factory.StartNew(
() =>
{
Thread.Sleep(1000);
return "Hello World";
});
}
자, 여기가 문제입니다. 저는 WindowsFormsSynchronizationContext의 Post가 아닌 Send로 했기 때문에 블록킹이 발생했을 거라고 예상했는데요. 반면, "
SynchronizationContext와 async/await - 4. async/await 궁합" 글에서는 await은 Post로 전달한다고 쓰여 있는 것입니다. 확인은 WindowsFormsSynchronizationContext.Post 메서드에 BP를 거는 것으로 금방 알 수 있습니다. 그리고, 결과는 실제로 ^^; Send가 아닌 Post가 실행되었습니다.
그렇습니다. Send가 아닌 Post가 맞습니다.
아니, 그럼 아래의 글에서 설명한 hang 현상의 원인은 도대체 무엇입니까?
async/await 사용 시 hang 문제가 발생하는 경우
; https://www.sysnet.pe.kr/2/0/1541
결론먼저 말하면 이 원인은, Send/Post의 문제가 아니고 어찌되었든 "this.Text = textTask.Result;" 코드의 호출로 인해 UI 스레드가 blocking이 되면서 WindowsFormsSynchronizationContext에서 Post로 전달해 준 await 이후의 분리된 코드를 호출하지 못해서 발생하는 것입니다. 이는 WindowsFormsSynchronizationContext.Post의 호출에 전달된 SendOrPostCallback 델리게이트 타입인 d 인자의 값을 보면 어느 정도 추측할 수 있습니다. 위의 그림에서 보면 "d.Target" 인자의 값이 "System.Threading.Tasks.SynchronizationContextAwaitTaskContinuation" 임을 알 수 있는데, 이때의 코드가 바로 await 이후로 분리된 코드인 것입니다. (정확히는, 분리된 코드는 MoveNext의 switch 코드의 하나이고, 그 MoveNext를 호출하고 Task.Finish를 호출해줄 코드가 담긴 "Void <_cctor>b__3(System.Object)" 메서드입니다.)
문제의 시나리오를 정리해 보면 이렇게 됩니다.
1. UI Thread가 Form1_Load를 실행
1.1. UI Thread가 GetHtmlTextAsync 메서드를 실행
1.1.1 UI Thread가 TestClass.GetTextAsync 메서드를 비동기로 실행
1.2. UI Thread는 TestClass.GetTextAsync 메서드에 반환된 Task 인스턴스인 textTask의 get_Result 프로퍼티를 호출.
내부적으로 Task 타입의 Result는 해당 Task가 담당한 모든 작업이 끝날 때 까지 대기
여기서 Task가 담당한 작업은 == await 대상 코드 + await 이후의 코드
2. ThreadPool의 자유 스레드가 TestClass.GetTextAsync 메서드에서 반환한 Task에 할당됨.
2.1. Task.Factory.StartNew에 넘겨진 익명 메서드를 실행
2.2. 메서드 실행 후 결과 값 반환
2.3. await 처리로 인해 분리된 "return result;" 코드를 SynchronizationContextAwaitTaskContinuation 델리게이트로 WindowsFormsSynchronizationContext.Post에 전달
2.4 전달된 SynchronizationContextAwaitTaskContinuation 델리게이트 코드까지 실행되어야만 Task의 모든 작업이 완료되는데,
UI Thread는 현재 Task 타입의 Result 호출로 인해 블록 상태이므로, WindowsFormsSynchronizationContext가 전달한 델리게이트를 실행하지 못함.
휴~~~ 이렇게 정리하고 나니, 속이 시원하군요. ^^ 암튼, "
SynchronizationContext와 async/await - 4. async/await 궁합 " 글 덕분에 문제를 좀 더 바로 보게 되어 다행입니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]