Task를 포함하지 않는 async 메서드의 동작 방식
다음의 글을 보면,
C# 컴파일러 대신 직접 구현하는 비동기(async/await) 코드
; https://www.sysnet.pe.kr/2/0/11351
다음의 TaskMethod 메서드에 대해,
static void Main(string[] args)
{
Program pg = new Program();
pg.TaskMethod();
}
async Task TaskMethod()
{
Console.WriteLine("TaskMethod");
}
C#은 이런 StateMachine 클래스를 생성합니다.
private Task TaskMethod()
{
CallAsync_StateMachine stateMachine = new CallAsync_StateMachine
{
_this = this,
_builder = AsyncTaskMethodBuilder.Create(),
_state = -1,
};
stateMachine._builder.Start(ref stateMachine);
return stateMachine._builder.Task;
}
class CallAsync_StateMachine : IAsyncStateMachine
{
public int _state; // 1
public AsyncTaskMethodBuilder _builder;
public Program _this; // 4
void IAsyncStateMachine.MoveNext()
{
int num = this._state;
try
{
Console.WriteLien("VoidMethod");
}
catch (Exception e)
{
this._state = -2;
this._builder.SetException(e);
return;
}
this._state = -2;
this._builder.SetResult();
}
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
{
}
}
보는 바와 같이 Task를 반환합니다. 그런데 TaskMethod 자체에는 아무런 Task를 생성한 것이 없습니다. 그럼 도대체 어떤 Task를 반환하는 걸까요? 지난 글에서 이에 대해 설명했습니다.
AsyncTaskMethodBuilder.Create() 메서드 동작 방식
; https://www.sysnet.pe.kr/2/0/11416
즉 다음의 Task 인스턴스가 Create() 시점부터 상태 머신의 m_builder.Task가 됩니다.
Id = 1, Status = WaitingForActivation, Method = "{null}", Result = "{Not yet computed}"
그리고 저 Task 객체는 async 메서드의 상태 머신에 끝까지 남아 있게 됩니다. 단지, 달라지는 것은 Task의 Status와 (반환 값이 있다면) Result입니다. 달라지는 시점은 _builder.SetException, _builder.SetResult 메서드의 호출인데, 예외가 발생하지 않은 걸로 가정하고 _builder.SetResult() 호출을 보면,
[__DynamicallyInvokable]
public void SetResult()
{
this.m_builder.SetResult(s_cachedCompleted);
}
internal void SetResult(Task completedTask)
{
if (this.m_task == null)
{
this.m_task = completedTask;
}
else
{
// 반환값이 없는 async 메서드의 경우 TResult 타입은 System.Threading.Tasks.VoidTaskResult
this.SetResult(default(TResult));
}
}
this.m_task가 AsyncTaskMethodBuilder.Create()에 의해 할당된 상태이므로 단순히 (반환값이 없는 경우) VoidTaskResult의 기본값으로 호출됩니다. (VoidTaskResult는 구현이 없는 빈 struct 타입입니다.) 참고로, 위의 코드에서 s_cachedCompleted는 정적 필드로 AsyncTaskMethodBuilder 타입의 static 생성자에서 정의하고 있으며,
// System.Runtime.CompilerServices.AsyncTaskMethodBuilder
static AsyncTaskMethodBuilder()
{
s_cachedCompleted = AsyncTaskMethodBuilder<VoidTaskResult>.s_defaultResultTask;
}
// System.Runtime.CompilerServices.AsyncTaskMethodBuilder<TResult>
static AsyncTaskMethodBuilder()
{
AsyncTaskMethodBuilder<TResult>.s_defaultResultTask = AsyncTaskCache.CreateCacheableTask<TResult>(default(TResult));
}
// System.Runtime.CompilerServices.AsyncTaskCache
internal static Task<TResult> CreateCacheableTask<TResult>(TResult result) // result == default(TResult) == default(VoidTaskResult) == null
{
return new Task<TResult>(false, result, 0x4000, new CancellationToken());
}
// System.Threading.Tasks.Task<TResult>
internal Task(bool canceled, TResult result, TaskCreationOptions creationOptions, CancellationToken ct) : base(canceled, creationOptions, ct)
{
if (!canceled) // canceled == false
{
this.m_result = result; // result == null
}
}
// System.Threading.Tasks.Task
internal Task(bool canceled, TaskCreationOptions creationOptions, CancellationToken ct)
{
int num = (int) creationOptions; // creationOptions = 0x4000
if (canceled) // canceled == false
{
ContingentProperties properties;
this.m_stateFlags = 0x500000 | num;
this.m_contingentProperties = properties = new ContingentProperties();
properties.m_cancellationToken = ct;
properties.m_internalCancellationRequested = 1;
}
else
{
this.m_stateFlags = 0x1000000 | num; // m_stateFlags = 0x1000000 | 0x4000 = 0x1004000
}
}
m_stateFlags 상태 값이 0x1004000로 초기화된 Task입니다. 부가적으로 Task 타입의 IsCompleted 속성은 작업 완료를 다음과 같이 알아냅니다.
[__DynamicallyInvokable]
public bool get_IsCompleted()
{
return IsCompletedMethod(this.m_stateFlags);
}
private static bool IsCompletedMethod(int flags)
{
return ((flags & 0x1600000) > 0); // 0x1004000 & 0x1600000 > 0 == true
}
결국 s_cachedCompleted라는 Task는 "취소되지 않았으면서 이미 종료된 Task" 객체를 의미합니다.
다시 본래의 이야기로 돌아와서 SetResult는 다음의 메서드로 연결됩니다.
[__DynamicallyInvokable]
public void SetResult(TResult result)
{
Task<TResult> task = this.m_task;
if (task == null)
{
this.m_task = this.GetTaskForResult(result);
}
else
{
if (AsyncCausalityTracer.LoggingOn)
{
AsyncCausalityTracer.TraceOperationCompletion(CausalityTraceLevel.Required, task.Id, AsyncCausalityStatus.Completed);
}
if (Task.s_asyncDebuggingEnabled)
{
Task.RemoveFromActiveTasks(task.Id);
}
if (!task.TrySetResult(result))
{
throw new InvalidOperationException(Environment.GetResourceString("TaskT_TransitionToFinal_AlreadyCompleted"));
}
}
}
나머지는 디버깅이나 로깅을 위한 코드이므로 SetResult 메서드가 실제적으로 실행하는 코드는 task.TrySetResult입니다.
internal bool TrySetResult(TResult result)
{
if (base.IsCompleted) // 처음 TrySetResult 시에는 IsCompletedMethod(m_stateFlags) == false
// (flags & 0x1600000) > 0 == false
{
return false;
}
if (!base.AtomicStateUpdate(0x4000000, 0x5600000))
{
return false;
}
this.m_result = result;
Interlocked.Exchange(ref this.m_stateFlags, base.m_stateFlags | 0x1000000);
Task.ContingentProperties contingentProperties = base.m_contingentProperties;
if (contingentProperties != null)
{
contingentProperties.SetCompleted();
}
base.FinishStageThree();
return true;
}
그렇습니다. 바로 저 라인에서 base.m_stateFlags | 0x1000000 값이 설정되면서, 그리고 m_result가 할당되면서 AsyncTaskMethodBuilder.Create()로 생성되었던 Task는 상태가 이렇게 바뀝니다.
Id = 1, Status = RanToCompletion, Method = "{null}", Result = "System.Threading.Tasks.VoidTaskResult"
그럼 마지막으로 예외가 발생했을 때 호출하는 this._builder.SetException(exception)을 추적해 보겠습니다.
[__DynamicallyInvokable]
public void SetException(Exception exception)
{
this.m_builder.SetException(exception);
}
[__DynamicallyInvokable]
public void SetException(Exception exception)
{
if (exception == null)
{
throw new ArgumentNullException("exception");
}
Task<TResult> task = this.m_task;
if (task == null)
{
task = this.Task;
}
OperationCanceledException cancellationException = exception as OperationCanceledException;
if (!((cancellationException != null) ? task.TrySetCanceled(cancellationException.CancellationToken, cancellationException) : task.TrySetException(exception)))
{
throw new InvalidOperationException(Environment.GetResourceString("TaskT_TransitionToFinal_AlreadyCompleted"));
}
}
위의 코드에서 당연히 m_task는 AsyncTaskMethodBuilder.Create()로 생성되었던 그 Task입니다. 만약 CancellationToken의 취소 동작으로 인한 것이라면 TrySetCanceled을 호출하고, 그 외에 코드 예외라면 TrySetException을 호출합니다.
internal bool TrySetException(object exceptionObject)
{
bool flag = false;
base.EnsureContingentPropertiesInitialized(true);
if (base.AtomicStateUpdate(0x4000000, 0x5600000))
{
base.AddException(exceptionObject);
base.Finish(false);
flag = true;
}
return flag;
}
AtomicStateUpdate로 인해 기존 this.m_stateFlags == 0x2000400 값에 0x4000000 값이 OR 연산으로 합쳐져 this.m_stateFlags == 0x6000400 값이 되지만 역시 IsCompleted는 false입니다. 하지만, 그 이후 base.Finish 메서드를 거치면서 결국에는 IsCompleted == true인 상태로 진행합니다.
그렇다면 결국 우리가 작성하게 되는 다음의 코드는 어떤 처리를 하게 되는 걸까요?
private async void CallAsync()
{
await TaskMethod();
Console.WriteLine("CallAsync");
}
public async Task TaskMethod()
{
Console.WriteLine("TaskMethod");
}
CallAsync 내에서의 await TaskMethod()는 CallAsync 자체가 async 메서드이기 때문에 생성되는 상태 머신 클래스의 MoveNext에서 다음과 같이 처리됩니다.
void IAsyncStateMachine.MoveNext()
{
TaskAwaiter awaiter;
int num = this._state;
try
{
if (num != 0)
{
awaiter = _this.TaskMethod().GetAwaiter();
// TaskMethod가 반환한 Task 및 그와 연관된 TaskAwaiter는 항상 SetResult가 호출된 상태이므로 IsCompleted는 언제나 true를 반환
if (!awaiter.IsCompleted)
{
// 따라서 다음의 코드는 절대로 실행되지 않고,
this._state = num = 0;
this._awaiter = awaiter;
CallAsync_StateMachine stateMachine = this;
this._builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
return;
}
}
else
{
awaiter = this._awaiter;
this._awaiter = new TaskAwaiter();
this._state = -1;
}
// 이곳의 코드가 실행됨.
awaiter.GetResult();
Console.WriteLine("CallAsync");
}
catch (Exception e)
{
this._state = -2;
this._builder.SetException(e);
}
this._state = -2;
this._builder.SetResult(); // 그리고 이어서 이 코드도 실행됨
}
여기서 재미있는 것은 _this.TaskMethod()가 반환하는 Task는 async 메서드인 TaskMethod 내에 생성된 상태 머신의 AsyncTaskMethodBuilder.Create()로 생성된 객체라는 점입니다.
결국, 모든 async 메서드들이 await [TargetMethod](); 호출 시마다 그것의 [TargetMethod]내에 생성했던 상태 머신 스스로 생성한 Task로 연결되는 것에 불과합니다. 게다가 그것은 이 글에서도 살펴봤지만 SetResult(또는 SetException) 호출로 인해 이미 종료된 Task입니다. 따라서 그 코드들 어떤 것에도 별도의 스레드는 관여하지 않고 단일 스레드로 모두 처리합니다. 즉 다음과 같이 코드가 작성되었다고 해서,
async Task AsyncMethod1()
{
await AsyncMethod2();
Console.WriteLine("Run on thread pool or calling thread);
}
await 이후의 Console.WriteLine 메서드 실행이 호출과는 다른 스레드에서 실행될 거라고 가정해서는 안됩니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]