• [.NET / C#] async & await 1 - async와 await 키워드

    2021. 6. 2.

    by. xxx123

    async & await은 그 동작이 매우 오묘하다.

    쉽게 비동기 처리를 할 수 있게 지원해주는 것은 분명하지만, 그 동작이 가끔 예상과 다를 때가 있다.

    그래서 async & await를 제대로 사용하기 위해선 이 키워드가 어떻게 변환되고 어떻게 동작하는지에 대해 숙지하는 것이 좋다.

     

     

     

    1. async 키워드

    async 키워드가 붙은 함수는 C# 컴파일러에 의해서 특정 코드로 변경이 된다.

    아주 심플한 예로 다음 코드를 보자.

    using System;
    using System.Threading;
    using System.Threading.Tasks;
    
    class Test
    {
        static async Task TestAsync()
        {
            Console.WriteLine("TestAsync : block 1");      // 3. 
    
            Console.WriteLine("TestAsync : block 2");      // 4.
        }
    
    
        static void Main()
        {
        	Console.WriteLine("main : block 1");      // 1.
            TestAsync();                              // 2. Main()의 TestAsync() 호출
            Console.WriteLine("main : block 2");      // 5.
            
            Thread.Sleep(Timeout.Infinite);
        }
    }

     

    수행 순서는 위에 코드에 적힌 주석의 번호와 같을 것이며, 딱히 일반 함수와 다를것이 없다.

    하지만 위 코드를 Decompile하면 다음과 같은 결과를 볼 수 있다.

    using System;
    using System.Runtime.CompilerServices;
    using System.Threading;
    using System.Threading.Tasks;
    
    internal class Test
    {
        private sealed class TestAsyncStateMachine : IAsyncStateMachine
        {
            public int state;
    
            public AsyncTaskMethodBuilder builder;
    
            private void MoveNext()
            {
                int state = this.state;
    
                try
                {
                    Console.WriteLine("TestAsync : block 1");
    
                    Console.WriteLine("TestAsync : block 2");
                }
                catch (Exception exception)
                {
                    this.state = -2;
                    this.builder.SetException(exception);
    
                    return;
                }
    
                this.state = -2;
                this.builder.SetResult();
            }
    
            void IAsyncStateMachine.MoveNext()
            {
                this.MoveNext();
            }
    
            private void SetStateMachine(IAsyncStateMachine stateMachine)
            {
    
            }
    
            void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
            {
                this.SetStateMachine(stateMachine);
            }
        }
    
        private static Task TestAsync()
        {
            TestAsyncStateMachine stateMachine = new TestAsyncStateMachine();
            stateMachine.builder = AsyncTaskMethodBuilder.Create();
            stateMachine.state = -1;
            stateMachine.builder.Start(ref stateMachine);
            return stateMachine.builder.Task;
        }
    
        private static void Main()
        {
            Console.WriteLine("Main : block 1");
            TestAsync();
            Console.WriteLine("Main : block 2");
    
            Thread.Sleep(-1);
        }
    }
    

     

    일단 코드가 뭔가 많이 생겼다.

    실제로 만들어지는 코드는 위 코드보다 좀 더 투박하지만 알아보기 좋게 조금 정리해 보았다.

    내용이 비교해가며 하나 하나 보도록 하자.

     

     

    일단 바뀐 코드에서는 async 키워드가 사라진 일반 함수 TestAsync()가 되어 있다.

    TestAsync()는 우선 StateMachine을 하나 만드는데, StateMatchine은 말 그대로 상태 머신을 뜻하며 상태값에 따라서 분기를 나눠 코드를 수행하도록 구현되어 있다.

    상태 머신의 내용을 보자.

    private sealed class TestAsyncStateMachine : IAsyncStateMachine
    {
        public int state;   // 상태값
    
        public AsyncTaskMethodBuilder builder;   // 비동기 Task 함수 Builder
    
        private void MoveNext()
        {
            int state = this.state;
    
            try
            {
                Console.WriteLine("TestAsync : block 1");
    
                Console.WriteLine("TestAsync : block 2");
            }
            catch (Exception exception)
            {
                this.state = -2;
                this.builder.SetException(exception);
    
                return;
            }
    
            this.state = -2;
            this.builder.SetResult();
        }
        
    .....생략
    
    }

    StateMachine은 자신의 상태를 나타내는 state와 비동기 함수를 실행시켜 줄 builder를 멤버로 가지고 있으며, MoveNext()라는 함수에서는 원래 TestAsync() 함수의 본문이었던 "Console.WriteLine("TestAsync : block1");"과 "Console.WriteLine("TestAsync : block2");"를 호출 해주고 있다.

    이 MoveNext()함수는 stateMachine.builder.Start(ref stateMachine);에 의해 바로 동기적으로 호출되기 때문에 TestAsync() 함수가 호출되면 몇가지 초기화 작업을 거치고 바로 MoveNext() 함수가 호출될 것이다.

    이렇게 호출된 MoveNext()는 결과적으로 특별히 비동기라고 할만한 동작 없이 종료되고 TestAsync() 함수는 종료될 것이다.

     

    위 코드만 보면 async를 붙이나 안붙이나 똑같은 시퀀스로 함수는 동작할것 같은데 왜 async를 붙이는지에 대한 의문을 가질 수 있다.

    async 키워드는 '비동기로 동작할 수 있는 코드 형태'로 함수를 새롭게 구성하라는 뜻이지 반드시 비동기로 동작한다는 것은 아니다.

    앞으로 소개할 await 키워드와 함께 쓰이면서 왜 저런식으로 코드가 변경되는지에 대해 알수 있게 된다.

     

     

     

    2. await 키워드

    그렇다면 이번에는 await 키워드를 붙여서 코드를 만들어 보자.

    await키워드는 async 함수 안에서만 사용 가능하며 다음과 같이 사용 가능하다.

    using System;
    using System.Threading;
    using System.Threading.Tasks;
    
    class Test
    {
        static async Task TestAsync()
        {
            Console.WriteLine("TestAsync : block 1");
            await Task.Delay(1000); // await 키워드를 걸어서 Task.Delay() 호출
            Console.WriteLine("TestAsync : block 2");
        }
    
    
        static void Main()
        {
            Console.WriteLine("Main : block 1");
            TestAsync();
            Console.WriteLine("Main : block 2");
    
            Thread.Sleep(Timeout.Infinite);
        }
    }

    그러고 다시 Decompile하여 코드를 확인해보자.

    이전과 변경된 코드는 StateMachine뿐이므로 StateMachine코드만 확인해 보도록 하자.

    private sealed class TestAsyncStateMatchine : IAsyncStateMachine
    {
        public int state;
    
        public AsyncTaskMethodBuilder builder;
    
        private TaskAwaiter awaiter; // 아까는 없던 awaiter라는 변수가 추가.
    
        private void MoveNext()
        {
            int state = this.state;
    
            try
            {
                TaskAwaiter awaiter;
                if (state != 0) // 초기값은 -1이기 때문에 반드시 진입한다.
                {
                    Console.WriteLine("TestAsync : block 1");
                    awaiter = Task.Delay(1000).GetAwaiter(); 
    
                    if (!awaiter.IsCompleted) // awaiter로 결과를 확인.
                    {
                        state = this.state = 0;
                        this.awaiter = awaiter;
                        TestAsyncStateMatchine stateMachine = this;
                        this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
                        return; // 완료되지 않았다면 바로 리턴.
                    }
                }
                else
                {
                    awaiter = this.awaiter;
                    this.awaiter = default(TaskAwaiter);
                    state = this.state = -1;
                }
    
                awaiter.GetResult();
                Console.WriteLine("TestAsync : block 2");
            }
            catch (Exception exception)
            {
                this.state = -2;
                this.builder.SetException(exception);
                return;
            }
    
            this.state = -2;
            this.builder.SetResult();
        }
        
    .....생략
    
    }
    

    아까는 없었던 awaiter라는 멤버 변수가 추가되었고,  MoveNext() 함수의 내용에도 awaiter의 역할이 추가되었다.

     

    builder에 의해 MoveNext()가 호출되게 되면 초기 상태값이 -1로 셋팅되었기에 if (state != 0)의 내부로 진입하게 된다.

    Console.WriteLine("TestAsync : block 1")이 호출될 것이고, Task.Delay(1000)이 이어서 호출될 것이다.

    Task.Delay(1000)은 다른 스레드에 의해 처리되며 처리되는데 까지 1초를 기다려야 하는 함수기에 awaiter의 결과는 바로 완료되지 않을 것이다. (물론 상황에 따라 다르다)

                if (state != 0) // 초기값은 -1이기 때문에 반드시 진입한다.
                {
                    Console.WriteLine("TestAsync : block 1");
                    awaiter = Task.Delay(1000).GetAwaiter(); 
    
    .....생략
                }

     

    그로 인해 if (!awaiter.IsCompleted) 내부로 진입하게 되고, 함수는 바로 return 되어 main() 함수로 돌아가게 될것이다.

    그리고 다른 스레드에 의해 Task.Delay(1000)의 처리가 완료되면, 그 스레드에서 Console.WriteLine("TestAsync : block 2")를 호출할 것이다.

    .....생략           
                    if (!awaiter.IsCompleted) // awaiter로 결과를 확인.
                    {
                        state = this.state = 0;
                        this.awaiter = awaiter;
                        TestAsyncStateMatchine stateMachine = this;
                        this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
                        return; // 완료되지 않았다면 바로 리턴.
                    }
    .....생략 

     

    최종적으로 await 키워드를 기준으로 TestAsync 함수의 본문이 다음과 같이 크게 두 블럭으로 나뉘어져 실행된다는 것을 알 수 있다.

    // 1. 첫 번째 블럭
    Console.WriteLine("TestAsync : block 1");
    await Task.Delay(1000);
    // 2. 두 번째 블럭
    Console.WriteLine("TestAsync : block 2");

     

    결국 작업자가 볼때 TestAsync() 함수는 await 키워드를 만나는 순간 바로 main() 함수로 되돌아 가게 되는 것이다. (나중에 설명하겠지만 항상 그런것 만은 아니다)

    그리고 다른 스레드에 의해 Console.WriteLine("TestAsync : block 2")가 호출되고 모든 작업은 끝이 난다.

     

     

     

    3. 정리

    async 키워드

    • C# 컴파일러에 의해 함수를 비동기 처리가 가능한 함수 형태로 변환한다.

     

    await 키워드

    • await 키워드를 기준으로 코드 블럭이 나뉘게 된다.
    • await 키워드에 의해 나눠진 블럭은 await 키워드를 만나는 순간 바로 리턴되어 서로 다른 스레드에 의해 실행될 수도 있고 현재 스레드에 의해 이어서 바로 실행될 수도 있다.
      • 이는 await에 걸린 함수의 동작 방식과 수행 완료 시간에 따라 차이가 있다. (이는 다른 포스팅에서 따로 다루도록 한다.)

     

     

     

     

    참고

    http://www.simpleisbest.net/post/2013/02/28/About_Async_Await_Keyword_Part_4.aspx
    https://www.sysnet.pe.kr/2/0/11351

     

     

     

     
     
     
     
     
     
     
     
     
     
     
     
     

    댓글