Await으로 Pause and Play 하기

- 이번 글은 Mads Torgersen의 MSDN 글을 번역/요약한 것입니다.

매드가 쓴 글은 await의 의미가 무엇이며 내부적으로 어떻게 작동하는지, 개념적 부분 부터 실제 작동 원리까지 설명한 글입니다. 일단 글은 VB나 C# 처럼 imperative programming language의 특징을 설명하는것으로 부터 시작합니다.

 

Sequential Composition

Imperative programming language는 일단 프로그램을 순차적인 스텝의 나열로 짜는 것을 그 근본 방법으로 사용합니다 . 그렇기 때문에 코드의 대부분은 control을 조절하는데 쓰이게 되죠. 이럴테면, if, switch, loop, continue, break, goto 등등의 구문들로 어떤 부분의 코드가 실행 될지 하나씩 지정하게 되는거죠. 따라서 보통 이쪽 언어를 사용하는 개발자들은 흐름이 명확하고 간결하게 되어 있기를 원하고 그렇기 때문에 이런 종류의 control structure를 많이 지원하게 됩니다.

 

Continuous Execution

imperative programming 언어의 또하나의 특징은 연속성입니다. 일단 어떤 한 method가 thread에서 실행이 시작되면 그 method가 끝나기 전까진 control이 시스템으로 돌아가지 않습니다. 따라서 만약 코드 중에 blocking call이 들어 있다면 그 thread는 unblock 될때 까지 아무것도 안하고 가만히 있게 되는것이죠. blocking API의 예는 많습니다. 이를테면 인터넷에서 파일을 다운로드 받는걸수도 있고, 하드에서 파일을 읽어들이는걸수도 있고요. 특히 UI Thread의 경우는 이 연속성이 UI의 반응에 문제가 되는 경우가 많이 있습니다. block된 백그라운드 thread 역시 UI thread 만큼 문제입니다. 잘못하면 엄청난 수의 백그라운드 Thread들 때문에 되려 성능저하가 일어날수도 있으니까요.

static byte[] TryFetch(string url) {   var client = new WebClient();   try   {     return client.DownloadData(url);   }   catch (WebException) { }   return null; }

Asynchronous Programming

이런 문제의 해결 방법으로 나타난게 비동기식 프로그램밍입니다. 기존 언어가 가지고 있는 연속성의 특성을 부수는 것이죠. 이 연속성을 부수는 방법은 혹은 부수는것과 같은 효과를 얻을수 있는 방법은 여러가지가 있을수 있습니다. 기존의 event 바탕의 비동기식을 사용할수도 있고, TPL을 이용해서 프로그램머 스스로 callback으로 코드 흐름을 control 할수도 있고, 그 외 여러가지 방법들 (https://en.wikipedia.org/wiki/Coroutine, https://en.wikipedia.org/wiki/Fiber_(computer_science)) 이 있을수 있습니다. 그러나 기존 방법들은 밑에 코드 예에서 보시듯, imperative programming 언어가 가지고 있는 순차적 스텝의 나열이라는 특징을 잃게 됩니다. 더 이상 콘트롤 구문들을 가지고 코드의 흐름을 간결하게 표현할수 없게 되는것이죠.

static void TryFetchAsync(string url, Action<byte[], Exception> callback) {     var client = new WebClient();     client.DownloadDataCompleted += (_, args) =>     {         if (args.Error == null) callback(args.Result, null);         else if (args.Error is WebException) callback(null, null);         else callback(null, args.Error);     };     client.DownloadDataAsync(new Uri(url)); }

C# 5.0 Async는 이런 여러가지 비동기식 프로그램밍 방식 중 언어 자체가 가지고 있는 특징을 유지하면서 비동기식 프로그램밍을 지원하기 위해 C#/VB 언어 자체에 들어간 새로운 기능입니다.

 

Asynchronous Methods

다음 코드 예를 보시면 C# 5.0의 async가 어떻게 기존 언어의 특징을 그대로 유지하면서 비동기식 프로그램밍을 지원하는지 직관적으로 아실수 있으실 겁니다.

static async Task<byte[]> TryFetchAsync(string url) {   var client = new WebClient();   try   {     return await client.DownloadDataTaskAsync(url);   }   catch (WebException) { }   return null; }

위에서 보듯이 기존 코드와 동일한 방식으로 코드의 흐름, exception 처리등을 비동기식으로 할 수 있게 해 주는것이죠.

 

Compiler Rewriting

그럼 어떻게 이것을 가능하게 하느냐. 기존의 yield 처럼, compiler가 코드를 분석해서 code를 rewrite 함으로써 가능하게 되는것입니다. 다시 말해 async/await이 사용된 method를 compiler가 일종의 state machine으로 만들어서, await 부분을 만날때 마다, 현재 thread의 context를 다 저장했다가 await 했던게 리턴하면 다시 context 를 restore 시키고, await 다음 부터 다시 실행 시키는것이죠. 그래서 제목이 pause and play 입니다. 그럼 좀 더 자세히 이 pause and play가 어떻게 구현 됐는지 알아보겠습니다.

 

Task Builders

C#의 async를 사용한 비동기식 method는 .NET 4.0 에 들어간 Task/Task<T>를 리턴하게 됩니다. 이 리턴되는 Task는 이번에 새로 들어간 Task Builder라는 타입을 이용하여 compiler가 만들게 됩니다. 간단히 가상으로 컴파일러가 위의 예제를 TaskBuilder를 사용하도록 바꾼 코드를 보도록 하겠습니다.

static Task<byte[]> TryFetchAsync(string url) {   var __builder = new AsyncTaskMethodBuilder<byte[]>();   ...   Action __moveNext = delegate   {     try     {       ...       return;       ...       __builder.SetResult(…);       ...     }     catch (Exception exception)     {       __builder.SetException(exception);     }   };   __moveNext();   return __builder.Task; }

위의 코드는 위의 예제 코드가 대충 어떤 모습으로 바뀌는지만 있을 뿐 return 되고 나서 어떻게 resume 될지, 이 return 한 task가 어떤 식으로 await 되는지는 나와 있지 않습니다. 그 부분은 다음 섹션에서 다룰것입니다.

 

Awaitables and Awaiters

위에서 C#의 async를 이용한 비동기식  method는 task를 이용한다고 했습니다. 하지만 이말이 await이 task만을 기다릴수 있다는 말은 아닙니다. C#의 await은 awaitable 한 타입은 모두 await 할수 있습니다. 이게 winRT에 Task 타입이 없음에도 C# await으로 winRT의 IAsyncOperation을 사용할수 있거나 F# 처럼 다른 방식의 비동기를 쓰는 언어와 함께 사용할수 있는 이유입니다. 물론 다른 비동기식 방법을 Task로 감싸는 방법도 있습니다. 정리하면, aysnc method로 부터  awaitable 할수 있는 형태의 awaiter를 task로 부터 가져와서, 그 awaiter가 이미 실행이 종료 됐나 체크 하고 만약 되지 않았으면, context와 함께 save하고 control을 시스템으로 리턴, 기다렸던 method의 실행이 끝나면 다시 원 위치에 다 restore하고 resume 하게 되는겁니다.

__awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();   if (!__awaiter1.IsCompleted) {     ... // Prepare for resumption at Resume1     __awaiter1.OnCompleted(__moveNext);     return; // Hit the "pause" button   } Resume1:   ... __awaiter1.GetResult()) ...

대충 이런 종류의 코드가 generate 되는것이죠.

 

The State machine

위의 여러가지 단계들을 다 거치게 되면, 대략 이런 형태의 코드가 마지막에 생성되게 됩니다.

static Task<byte[]> TryFetchAsync(string url) {   var __builder = new AsyncTaskMethodBuilder<byte[]>();   int __state = 0;   Action __moveNext = null;   TaskAwaiter<byte[]> __awaiter1;     WebClient client = null;     __moveNext = delegate   {     try     {       if (__state == 1) goto Resume1;       client = new WebClient();       try       {         __awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();         if (!__awaiter1.IsCompleted) {           __state = 1;           __awaiter1.OnCompleted(__moveNext);           return;         }         Resume1:         __builder.SetResult(__awaiter1.GetResult());       }       catch (WebException) { }       __builder.SetResult(null);     }     catch (Exception exception)     {       __builder.SetException(exception);     }   };     __moveNext();   return __builder.Task; }

이런 난해한 코드가 생성되는 이유는 성능 때문입니다. 이를테면 await이  아주 간단한 method를 기다릴 경우, 비동기식 없이 바로 실행 할수 있게 하거나, allocation을 한번만 하고 재 사용한다거나 등등의 이유죠. 하지만, 보시면 알겠지만, 생각보다 원래 코드를 완전 뒤죽 박죽 만들진 않습니다. 대충 보면 아 이런식으로 하는구나 알수 있죠. 물론 그렇다고 보이는것 만큼 간단하지도 않습니다.

 

The Fine Print

위의 최종적으로 생성된 코드 예제는 사실 약간 심플화된 코드 입니다. 사실 저대로는 작동하지 않죠. 위에서 처럼 goto statement을 사용할수는 없습니다. 또 finally block 처럼 method에서 exit 할때 자동으로 실행 되는 코드는 약간의 특별한 관리를 요구하죠. 또 코드의 execution order도 resume 할때 유지 시켜 주어야 합니다. 예를 들어, method 중간에 await 때문에 현 method를 exit 한다면, 현 위치에 있는 finally block을 실행 해서는 안되고, 실행 해야 한다는 정보만 유지했다 나중에 resume 한 후에 실행 시켜야 한다던지, 다른 method 콜을 위해 method argument를 stack에 올리고 있었다면 그 정보도 save했다 restore 해줘야 하고, local들중 필요한건 lift해야 하고, 등등. 나중에 resume 될때 thread context도 유지 시켜줘야 하는 문제도 있죠. 이렇게 단순하지 않기 때문에 특정 부분 예를 들어 catch나 finally 구문 안에서는 await를 사용 할수 없습니다.

 

The Task Awaiter

.NET에서 기본으로 제공하는 Task Awaiter를 본인이 직접 만든걸 쓰면, 위에 쓴것 보다 많은 자유도를 가질수 있게 됩니다만, Awaiter를 제대로 만드는것은 쉽지 않기 때문에, 추천하지 않습니다. 일반적으로 Task Awaiter를 직접 만들려는 이유는 thread context 때문인데, 이 경우, TPL에서 제공하는 Task Scheduling Context를 이용하는 방법이 훨씬 간단합니다. 보다 자세한 내용은 TPL을 참고하는게 좋을꺼 같습니다. 간단히 말해, 어떤 thread에서 pause되고 어떤 thread에서 resume 될것인가? 둘 사이에 marshaling은 어찌 할 것인가. await이 작동하는 thread들은 어떤식으로 관리될것인가 이 모두를 TPL의 task scheduler나 .NET의 synchronization context를 이용하여 조절할수 있습니다.

 

Go Forth and Async’ify

자 이제 마지막, 이제까지 C#의 async가 어찌 작동하는지 알아보았답니다. 가장 중요한 포인트 3가지는

  1. 컴파일러가 유저 코드의 흐름을 비동기 임에도 불구하고 그대로 유지해 준다
  2. 비동기는 멀티 쓰레드가 아니다. 하나의 쓰레드를 여러개로 쪼개쓸수 있도록 해준다.
  3. await 됐던 코드가 다시 실행 될때, 이 코드는 await 되기 이전의 상태와 거의 동일한 context에서 다시 실행된다.

이상입니다.!

 

….

다 좋은데, 이 글은 C# async가 내부적으로 어찌 돌아가는지는 나와 있는데, 일반적으로 어떻게 사용하는지에 대한 내용은 없네요. 그런 내용은 이미 오래전부터 많이 다뤄져서 없나 봅니다. 그런 글 찾으면 나중에 또 올리도록 하겠습니다.

그럼 수고!

- 희제