Async/await 키워드에 대한 네 번째 글입니다. 이번 글에서는 async/await의 구체적인 작동 원리를 살펴보고자 합니다. 즉, C# 코드에서 async/await 키워드를 사용했을 때 컴파일러가 생성하는 코드를 살펴봄으로 써 그 원리를 이해하는 것이지요. 굳이 이러한 원리를 모르더라도 async/await 키워드를 사용할 수 있습니다. 하지만 원리를 알고 있다면 다양한 상황에서 발생하는 문제들을 해결하는데 도움이 되리라 믿습니다.

Async/await 키워드 작동 원리

시작하기 전에 살짝 경고를 해주고 싶다. 이 글의 내용은 쉽지 않다. 단순히 async/await를 사용하는 것만으로 충분한 독자라면 인터넷에서 async/await에 관련된 다른 예제 코드나 사용법을 찾아보는 것이 좋을 것이다.

기본적으로 async 키워드가 메서드(async 메서드)에 사용되면 C# 컴파일러는 메서드 내부를 표현하는 별도의 구조체 코드를 생성해 낸다. 이 구조체는 async 메서드의 로컬 변수, 매개변수, 반복문 제어 변수들을 필드로 가지며 async 메서드의 본문(body)를 나타내는 MoveNext 메서드를 가지고 있다. MoveNext 메서드 내부는 await 키워드에 의해 분리되는 코드 조각들이 비동기 작업 완료에 따라 순차적으로 수행되도록 유한 상태 기계(finite state machine)가 구현되어 있다. 무슨 말인지 전혀 이해가 안 갈 것이다. 지극히 정상적인 반응이므로 전혀 걱정할 것이 없다. 구체적으로 예를 들어 필자가 당췌 무슨 이야기를 한 것인지 살펴보도록 하자.

Async/await 다시 보기

[리스트 1]의 코드에서 await 키워드와 함께 사용된 ServerCallAsync 메서드는 다른 스레드(항상 그렇지는 않다!)에 의해 비동기적으로 수행된다. 비동기 작업을 수행함과 동시에 SimpleTest 메서드는 즉시 제어를 Main 메서드에게 반환하며 시간이 흘러 ServerCallAsync 메서드의 수행이 완료되면 await 키워드의 다음 라인(13번째 라인)이 수행된다. 비록 코드는 순차적으로 수행되는 것처럼 보이지만 실제 제어의 흐름은 전혀 순차적이지 않다.

   1: internal class Program
   2: {
   3:     static void Main(string[] args)
   4:     {
   5:         SimpleTest();
   6:         Console.ReadKey(true);
   7:     }
   8:  
   9:     static async void SimpleTest()
  10:     {
  11:         Console.WriteLine("Code Block #1");
  12:         await ServerCallAsync();
  13:         Console.WriteLine("Code Block #2");
  14:     }
  15:  
  16:     static Task ServerCallAsync()
  17:     {
  18:         // 내용 생략
  19:     }
  20: }

[리스트 1] 간단한 async/await 키워드 예제

컴파일러 트위스트

제어의 흐름이 순차적이지 않다면 await 키워드를 사용했을 당시의 로컬 변수들이나 기타 다른 수행 문맥들은 어떻게 되는 것일까? 또한, try~catch 문장과 같은 예외 처리는 어떻게 되는 것일까? 혹은 for 문장이나 while 문장 내부에서 await 키워드를 사용하면 어떻게 되는 것일까? 결론부터 말하자면 await 키워드 다음에 수행되는 코드들은 메서드의 매개 변수, 로컬 변수, 그리고 반복문의 제어 변수를 모두 사용할 수 있다.

어떻게 이런 것이 가능한가를 알아보려면 [리스트 1]의 코드에서 C# 컴파일러가 생성하는 코드를 살펴보면 된다. C# 컴파일러가 컴파일 하여 생성하는 코드는 소스 코드와 전혀 다른 코드를 생성한다. 컴파일러가 생성한 SimpleTest 코드는 [리스트 2]와 같다. [리스트 1]의 SimpleTest 메서드에는 2개의 Console.WriteLine 호출과 await를 이용한 비동기 메서드 호출이 존재하지만 [리스트 2]의 코드에는 딸랑 구조체 하나를 생성하고 이 구조체의 MoveNext 메서드를 호출하는 것이 전부이다. 그렇다. SimpleTest 메서드의 실제 구현 내용은 모두 이 구조체 내부에 포함되어 있으며 실제 코드는 이 구조체의 MoveNext 메서드 내부에 존재한다. Async 키워드가 사용된 메서드는 모두 이렇게 컴파일러에 의해 생성되는 구조체 내부로 스며들며 대신 이 메서드의 본문에는 구조체를 생성하고 이 구조체의 MoveNext 메서드를 호출하는 코드로 대체된다.

   1: internal class Program
   2: {
   3:     ......
   4:  
   5:     private static void SimpleTest()
   6:     {
   7:         SimpleTest_Runtime var;
   8:         var.MoveNext();
   9:     }
  10:  
  11:     private struct SimpleTest_Runtime
  12:     {
  13:         ......
  14:  
  15:         public void MoveNext()
  16:         {
  17:              ......
  18:         }
  19:     }
  20:  
  21:     ......
  22: }

[리스트 2] 컴파일러가 생성한 SimpleTest 메서드 코드

Async/await 키워드의 핵심은 컴파일러에 의해 생성되는 이 구조체가 핵심적인 역할을 하게 된다. 이 구조체는 async 키워드를 사용한 메서드를 포함하는 클래스(위의 경우 Program 클래스가 되겠다)의 내부에 항상 중첩 타입으로 선언되는데 그 이유는 async 메서드가 클래스의 멤버 필드, 멤버 메서드를 액세스 할 수 있도록 하기 위함이다. 그리고 이 구조체의 이름은 [리스트 2]와 같이 가독성이 있는 그런 이름이 아니다. 컴파일러에 의해 생성되는 이름은 중복을 피하기 위해 <SimpleTest>d__0 와 같은 괴상한 이름을 사용한다. [리스트 2]의 코드는 필자가 가독성을 높이고 독자의 이해를 돕기 위해 구조체의 이름을 변경한 것이다.

똥꼬 깊숙한 그 곳

이제 컴파일러가 생성하는 구조체에 대해 좀 더 상세히 살펴보자. 이 구조체는 [리스트 3]과 유사하다. 실제로 C# 컴파일러는 다수의 await 키워드가 사용되거나 try~finally 내에서 await 키워드가 사용되는 경우 등등 복잡한 상황을 처리하기 위한 코드들을 생성해 낸다. 하지만 여기서 우리는 async/await 키워드가 작동하는 원리를 파악하는 것이 주요 목적이므로 이해를 방해하는 불필요한 코드나 변수 이름 등을 수정하였다. 따라서 독자들이 직접 Reflector 를 이용하여 코드를 살펴보면 [리스트 3]과 딴판인 코드를 볼 수도 있다. 하지만 제어의 흐름은 동등하다. 다시 한번 강조하지만 [리스트 3]의 코드는 컴파일러가 생성하는 코드와 같지 않다. 다양한 상황을 대비하기 위해 컴파일러는 복잡한 코드를 생성하지만 필자가 독자들의 이해와 가독성을 위해 불필요한 코드를 제거하고 변수 이름 등을 변경한 것임을 명심하자. (나중에 시비를 걸면 곤란함… 앗싸 보험!)

   1: private struct SimpleTest_Run
   2: {
   3:     private int _state;
   4:     private TaskAwaiter _awaiterObject;
   5:  
   6:     public void MoveNext()
   7:     {
   8:         TaskAwaiter awaiter;
   9:         if (this._state == 0)
  10:         {
  11:             Console.WriteLine("Code Block #1");
  12:             awaiter = Program.ServerCallAsync().GetAwaiter();
  13:             if (awaiter.IsCompleted)
  14:             {
  15:                 goto AfterAsyncOperation;
  16:             }
  17:             this._state = 1;
  18:             this._awaiterObject = awaiter;
  19:             awaiter.OnCompleted(MoveNext);
  20:             return;
  21:         }
  22:         else if (this._state == 1)
  23:         {
  24:             awaiter = this._awaiterObject;
  25:             this._awaiterObject = null;
  26:             this._state = 0;
  27:         }
  28:         else
  29:         {
  30:             return;
  31:         }
  32:     AfterAsyncOperation:
  33:         awaiter.GetResult();
  34:         awaiter = new TaskAwaiter();
  35:         Console.WriteLine("Code Block #2");
  36:         this._state = -1;
  37:     }
  38: }

[리스트 3] 컴파일러가 생성하는 비동기 처리 구조체

[리스트 3]의 코드는 async 메서드에서 사용된 await 키워드를 기준으로 await 호출 이전과 이후로 나누어져 있다. 그리고 이렇게 분리된 코드에 상태 값을 지정하여 이 상태 값에 따라서 await 이전 코드와 이후 코드가 분리되어 수행되는 것이다. 앞서 언급한 유한 상태 기계란 바로 이러한 작동 방식을 말하는 것이다. [리스트 3]의 경우, await 키워드 이전 코드는 상태 값이 0 일 때 수행되는 코드이며 await 키워드 이후 코드는 상태 값이 1 일 때 수행되는 코드이다. 그리고 그 외의 상태 값, 특히 -1 의 값은 수행 종료 상황으로서 아무런 작업도 수행하지 않는다.

상태 값의 기본 값이 0 이므로 구조체 생성 이후 최초로 MoveNext 메서드를 호출하면 _state 상태 값이 0 이다. 따라서 await 키워드의 이전 코드(이 경우 Console.WriteLine 호출)가 수행되고 await 키워드를 통한 비동기 메서드 호출이 이루어진다. 비동기 메서드는 Task 혹은 Task<T> 타입을 반환할 것이고 이 타입은 GetAwaiter() 메서드를 제공하므로 TaskAwaiter 객체(실제로는 structure 이다)를 구할 수 있게 된다. TaskAwaiter 객체는 비동기 메서드에서 생성하고 시작 한 Task 객체의 종료 여부를 IsCompleted 속성으로 제공한다. 비동기 메서드는 작업을 비동기로 수행하지 않고 동기적으로 작업을 완료할 수도 있다. 이러한 경우는 캐시에 이미 존재하는 버퍼을 반환하는 등의 상황에서 충분히 발생할 수 있는 상황이다. 이러한 상황을 대비하여 TaskAwaiter.IsCompleted 속성을 확인하여 비동기 작업이 이미 완료되었는가를 확인한다. 만약 완료되었다면 곧바로 awaiter 키워드 다음 코드를 수행하도록 goto 문장이 사용된다. 일반적으로 goto 문장의 사용은 권장되지 않지만 중첩된 코드 블록을 한번에 빠져나가거나 다른 코드 블록 안으로 뛰어 들어가기 유용하기 때문에 종종 사용되곤 한다.

비동기 작업이 완료되지 않은 상황이라면 _state 상태 값을 1로 바꾸고, 비동기 작업(이 경우 ServerCallAsync)이 완료되면(OnComplete) 수행 할 메서드로써 자기 자신(MoveNext 메서드)을 지정한 후에 제어를 반환한다. SimpleTest 메서드와 같이 async 키워드가 사용된 메서드가 await 키워드를 만나면 곧바로 제어를 반환하는 이유가 바로 여기에 있다. 이런 이유에서 [리스트 1]의 main 메서드 내에 ReadKey 호출이 없으면 프로그램이 곧바로 종료해 버린다. 이 시리즈의 지난 글들에서 UI를 가진 어플리케이션에서 UI 업데이트 루틴(메시지 펌프 혹은 디스패처)이 지속적으로 수행할 기회를 주어야 한다고 한 적이 있었다. Async 키워드를 사용하면 비동기 작업을 시작하고 이와 같이 제어가 곧바로 반환되기 때문에 UI 업데이트 루틴이 지속적으로 수행될 수 있게 되는 것이다.

이제, 비동기 작업이 완료되면 다시 MoveNext 메서드가 호출될 것이다. 이 경우, _state 상태 값이 1 이기 때문에 await 키워드의 다음 코드 부분, 즉 Console.WriteLine 호출이 수행되게 된다. 이제 원본 SimpleTest 메서드의 수행을 모두 완료했기 때문에 상태 값을 -1로 리셋하고 수행을 완료하게 된다. 오케?

마치며…

Async/await 키워드의 작동 원리는 이러하다. 개발자는 [리스트 1]과 같이 순차적으로 보이는 코드를 작성하지만 C# 컴파일러가 [리스트 3]과 같은 코드를 생성하고 관리함으로써 비동기 코드를 손쉽게 작성할 수 있는 것이다.

[리스트 1]의 코드는 await 키워드가 한 번만 사용된 매우 간단한 코드이다. Await 키워드가 2회 이상 등장하거나 for 루프 문장 내부에 await 키워드가 사용되거나 혹은 try~catch 블록 내부에 await 키워드가 사용되면 [리스트 3] 보다 복잡한 코드가 생성된다. 이러한 복잡한 상황에서 C# 컴파일러가 어떤 코드를 생성하는지에 대해서는 이 시리즈의 마지막 포스트인 다음 포스트에서 살펴보도록 하겠다.


경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.