using System;
using System.Threading.Tasks;
namespace ConsoleApp1
{
class Program
{
// C# 7.1 async Main
static async Task Main(string[] args)
{
Program pg = new Program();
await pg.CallAsync();
}
private async Task CallAsync()
{
string title = DateTime.Now.ToString();
string text = await GetFileContents();
Console.WriteLine(title + ": " + text);
}
private async Task<string> GetFileContents()
{
return await new TaskFactory().StartNew(() => { return "test"; });
}
}
}
간단한 C# 코드를 보자. 위의 코드를 실행하면 CallAsync()가 끝날 때 까지 프로그램이 대기한다. 여기서, Main에 async가 붙어 있는데, .Net 환경에서 main 함수 호출 시 main 내부의 모든 비동기 함수가 끝날 때 까지 기다려준다.
출력: 2017-11-07 오후 9:05:15: test
위 비동기 함수를 C# 컴파일러가 아닌 비동기 처리로 바꿔보자. async/await은 단순히 "async"가 정의된 함수를 "await"을 분기점으로 state machine으로 바꿔준다. 간단하게, .Net Reflector에서 제공하는 역 어셈블러를 통해 GetFileContents 비동기 함수가 어떻게 변화하는지를 확인해 볼 수 있다.
위 예제에서 비동기 함수 GetFileConents만 분리하면 아래와 같이 표현 가능하다.
위에 보이는 것 처럼, 컴파일러가 새로운 이름으로 클래스를 새롭게 정의한다. GetFileContents_StateMachine은 다음과 같은 내부 필드를 포함한다.
// [async 동작을 위한 필드 3개]
public int _state; // 현재 처리 중인 state (await을 분기점으로 하는)
public AsyncTaskMethodBuilder _builder;
TaskAwaiter<string> _awaiter; // (await을 만났을 때, state machine의 행동을 정의
// [async 메서드를 구현하고 있는 클래스의 this 보관 필드]
public Program _this;
// [async 메서드의 반환값을 임시 보관하는 필드]
string _result;
// [async 메서드의 반환값을 보관하는 필드]
string _urlContents;
// [Part A 코드의 변수들]
Task<string> _getStringTask;
기존의 GetFileContents() 함수의 내용은 위와 같은 class filed로 전환되고, 기존에 있던 함수는 state machine을 초기화하는 코드로 전환된다.
여기서, _builder.Start는 비동기 호출이 아니다. 현재 스레드에서 시작하는 동기 호출이며, AsyncTaskMethodBuilder 타입의 _builder 인스턴스는 Start 메서드 내에서 인자로 들어온 stateMachine의 MoveNext 메서드를 실행한다.
비동기 부분이 아닌 Part A 부분은 MoveNext 첫 호출 시 같이 호출된다.
void IAsyncStateMachine.MoveNext()
{
string str;
int num = this._state;
try
{
TaskAwaiter<string> awaiter;
// 원래는 if 문이지만 명확한 분리를 위해 switch로 바꿨습니다.
switch (num)
{
case 0:
// ...[생략]...
break;
default:
this._getStringTask = new TaskFactory().StartNew(() => { return "test"; });
awaiter = this._getStringTask.GetAwaiter();
if (awaiter.IsCompleted == false)
{
this._state = num = 0;
this._awaiter = awaiter;
GetFileContents_StateMachine stateMachine = this;
this._builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
return;
}
break;
}
// ...[생략]...
}
catch (Exception e)
{
// ...[생략]...
return;
}
// ...[생략]...
}
초기 state는 -1이므로, MoveNext 메서드에서 default 영역의 코드가 실행된다. Part A는 해당 메소드에서 "default"에 구현되어 있다.
위 코드를 분석했을 때, awaiter.IsCompleted == true 라면, 동기적으로 바로 처리된다. awaiter.IsCompleted == false 인 경우 _builder.AwaitUnsafeOnCompleted 메서드를 호출해 작업이 완료된 경우의 알림을 등록한다.
결국 알림이 왔을 때, stateMachine의 MoveNext를 다시 호출해주는 구조이다. 이는 promise pattern과 매우 유사하다!
결국 async/await은 간단하게 말하면 callback 함수 자동 생성기에 불과하다.
Task vs UniTask
위 state machine은 결국 어떤 callback 함수를 등록하고, callback 함수가 끝났을 때 MoveNext를 다시 호출하여 state machine의 state를 계속해서 변경시키는 식으로 동작한다. state의 수는 await 키워드의 수와 같다.
여기서 주요한 점은 async/await 자체는 awaitable 객체의 callback 구현에 의존한다는 점이다. 따라서 awaitable 객체의 구현 방식에 따라 해당 state machine의 동작 방식을 조정할 수 있다.
Task의 경우, awaitable object는 별도의 스레드에게 일을 맡겨 놓고 메인 스레드는 spin lock을 돌며 대기하는 방식으로 구현되어 있다. 따라서, multi-thread로 동작한다.
반면, Unitask는 async/await의 문법을 이용하여 state machine을 구축하는 것은 동일하지만, 별도의 스레드를 만들지 않는다!
C# async/await 컴파일러 대신 구현하기
참고: C# 컴파일러 대신 직접 구현하는 비동기(async/await) 코드
Main code
간단한 C# 코드를 보자. 위의 코드를 실행하면 CallAsync()가 끝날 때 까지 프로그램이 대기한다. 여기서, Main에 async가 붙어 있는데, .Net 환경에서 main 함수 호출 시 main 내부의 모든 비동기 함수가 끝날 때 까지 기다려준다.
위 비동기 함수를 C# 컴파일러가 아닌 비동기 처리로 바꿔보자. async/await은 단순히 "async"가 정의된 함수를 "await"을 분기점으로 state machine으로 바꿔준다. 간단하게, .Net Reflector에서 제공하는 역 어셈블러를 통해 GetFileContents 비동기 함수가 어떻게 변화하는지를 확인해 볼 수 있다.
위 예제에서 비동기 함수 GetFileConents만 분리하면 아래와 같이 표현 가능하다.
여기서, async/await은 해당 함수를 두 단계로 분리한다.
C# Task에서는 Part A를 메인 스레드에서 실행하고, Part B는 별도의 스레드에서 처리한다(비동기성). 다만 주의할 점은 async/await 자체는 multithreding을 지원해주지 않는다. 단지 Part A와 Part B로 나누어 줄 뿐이다.
즉, 비동기를 다루기 쉬운 프레임워크를 제공할 뿐, 실제 비동기 처리를 하지 않는다.
compiler는 async 명령어를 가진 함수를 만나면 위 두 파트를 나눠 담을 IAsyncStateMachine 인터페이스를 상속한 별도의 내부 클래스로 바꿔준다.
위에 보이는 것 처럼, 컴파일러가 새로운 이름으로 클래스를 새롭게 정의한다. GetFileContents_StateMachine은 다음과 같은 내부 필드를 포함한다.
기존의 GetFileContents() 함수의 내용은 위와 같은 class filed로 전환되고, 기존에 있던 함수는 state machine을 초기화하는 코드로 전환된다.
여기서, _builder.Start는 비동기 호출이 아니다. 현재 스레드에서 시작하는 동기 호출이며, AsyncTaskMethodBuilder 타입의 _builder 인스턴스는 Start 메서드 내에서 인자로 들어온 stateMachine의 MoveNext 메서드를 실행한다.
비동기 부분이 아닌 Part A 부분은 MoveNext 첫 호출 시 같이 호출된다.
초기 state는 -1이므로, MoveNext 메서드에서 default 영역의 코드가 실행된다. Part A는 해당 메소드에서 "default"에 구현되어 있다.
위 코드를 분석했을 때, awaiter.IsCompleted == true 라면, 동기적으로 바로 처리된다. awaiter.IsCompleted == false 인 경우 _builder.AwaitUnsafeOnCompleted 메서드를 호출해 작업이 완료된 경우의 알림을 등록한다.
결국 알림이 왔을 때, stateMachine의 MoveNext를 다시 호출해주는 구조이다. 이는 promise pattern과 매우 유사하다!
결국 async/await은 간단하게 말하면 callback 함수 자동 생성기에 불과하다.
Task vs UniTask
위 state machine은 결국 어떤 callback 함수를 등록하고, callback 함수가 끝났을 때 MoveNext를 다시 호출하여 state machine의 state를 계속해서 변경시키는 식으로 동작한다. state의 수는 await 키워드의 수와 같다.
여기서 주요한 점은 async/await 자체는 awaitable 객체의 callback 구현에 의존한다는 점이다. 따라서 awaitable 객체의 구현 방식에 따라 해당 state machine의 동작 방식을 조정할 수 있다.
Task의 경우, awaitable object는 별도의 스레드에게 일을 맡겨 놓고 메인 스레드는 spin lock을 돌며 대기하는 방식으로 구현되어 있다. 따라서, multi-thread로 동작한다.
반면, Unitask는 async/await의 문법을 이용하여 state machine을 구축하는 것은 동일하지만, 별도의 스레드를 만들지 않는다!
다음 시간에는 Unitask에 대해 더 자세하게 공부해본다.