Programming/C# & Unity

C# Async Await 원리

장형이 2022. 11. 16. 23:10

개요

전에는 그냥 대충 [Async 함수를 호출하면 ThreadPool에서 Task를 하나 꺼내서 함수를 던져주겠지~]라고 생각했었다.

하지만 현실은 물론 그렇지 않았으며, 내부에서 신기하고 재미있는 일이 일어나고 있어서 포스팅을 남기려 한다.

 

예제

static class DBExecutor
{
    static public async Task<string> ExecuteDB(string spName, long accountId)
    {
        string result;

        Console.WriteLine($"DB 연결 시작");
        await Task.Delay(2000);
        Console.WriteLine($"{spName} 호출 시작.");
        await Task.Delay(2000);
        Console.WriteLine($"{spName} 호출 종료.");

        result = "장형이";

        return result;
    }
}

static class DataCenter
{
    static public async Task<string> GetAccountInfo(long accountId)
    {
        return await DBExecutor.ExecuteDB("GetNickName", accountId);
    }
}

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine(DataCenter.GetAccountInfo(5392554).Result);
    }
}

위와 같은 로직이 있다고 가정하자.

특정 accountId를 가지고 DB 프로시저를 콜 하여서 닉네임을 받아오는 로직이며, DB call 부분은 async-await로 구성되어 있다.

과연 이 코드를 실행하면 내부적으로 어떤 일이 일어날까?

 

흐름

1. Main 스레드에서 GetAccountInfo이 호출된다.

 

2. Main ThreadGetAccountInfo를 직접 실행한다. (다른 스레드에서 실행되지 않고 직접 실행한다.)

 

3. Main Thread는 ExecuteDB도 직접 실행한다.

 

4. 결국 Main Thread는 Task.Delay까지 직접 실행하게 된다. (Task.Delay 함수는 핵심만 두고 모두 생략하였다.)

 

5. Main Thread에서 실행 중인 Task.Delay는 Timer를 사용하여 다른 스레드에서 계속해서 처리가 되게 되고,

Main Thread의 코드 흐름은 Task만 받아서 리턴하게 된다.

 

 

6. 이후 DBExecutor는 await를 만나게 되며 다음 코드 흐름은 Task가 끝날 때 이어지게 된다.

 

7.  이렇게 되면 ExecuteDB에서는 내부적으로 State가 넘어가며, Timer에게 State1의 코드부터 실행하라고 콜백을 넘겨주게 된다.

(이때, Timer가 이미 끝나있다면 Main이 State1 부분의 코드를 직접 수행한다.)

 

8. 동일한 GetAccountInfo도 await 이후 시점인 [State 1]를 콜백으로 연결하고 리턴한다.

 

9. 이후 Main은 DataCenter.GetAccountInfo Task가 완료되기를 스핀 락을 돌며 기다리게 된다.

 

10. Timer에서 코드 흐름이 깨어나면, 다른 스레드에서 콜백 로직이 이어진다. (타이머 로직에 따라 같은 스레드 일 수도 있다.)
이후 DBExecutor.ExecuteDB의 State1~State2 사이의 로직을 타며,

Task.Delay를 만나면 끝나고 State2를 실행하라고 콜백을 넘기고 코드 흐름이 넘어간다.

 

11. 마지막으로 Timer가 깨어나며 다른 스레드에서 DBExecutor.ExecuteDb State2의 콜백이 호출 되게 되고, 더 이상 State가 넘어가는 로직이 없이(await 없이) 마지막 콜백까지 끝나게 된다.

 

12. 이후 모든 Main에서 물린 Task가 종료됨에 따라 Main은 Spinlock을 그만두고 결과를 리턴하게 된다. 이때의 스레드는 처음과 같다.

 

실제로 ExecuteDB 함수의 현재 ThreadId를 찍어보면, 최초의 Id는 Main과 같고, 그 이후에는 다르거나 같다.

 

 디컴파일 코드

실제로 DBExecutor 함수의 코드를 디컴파일하면 아래의 코드가 나온다. (디컴파일 코드를 적당히 읽기 좋게 정리하였다.)
async-await를 사용하는 함수마다 하나의 class가 생기고 해당 class에서 state처리가 switch로 이루어지는 형태로 되어있다.

자세한 코드는 이해하기 크게 어렵지 않고, 중요하지도 않으니 대충 이렇게 되구나~ 보기만하고 이 글에서는 이만 넘어가도록 하겠다.

internal static class DBExecutor
{
    public static Task<string> ExecuteDB(string spName, long accountId)
    {
        ExecuteDBState stateMachine = new ExecuteDBState();
        stateMachine.builder = AsyncTaskMethodBuilder<string>.Create();
        stateMachine.spName = spName;
        stateMachine.accountId = accountId;
        stateMachine.state = -1;
        stateMachine.builder.Start(ref stateMachine);
        return stateMachine.builder.Task;
    }

    private sealed class ExecuteDBState : IAsyncStateMachine
    {
        public int state;
        public AsyncTaskMethodBuilder<string> builder;
        public string spName;
        public long accountId;
        private string result;
        private TaskAwaiter awaiter;

        void IAsyncStateMachine.MoveNext()
        {
            int currentState = this.state;
            string result;
            try
            {
                TaskAwaiter awaiter1;
                int nextState;
                TaskAwaiter awaiter2;
                switch (currentState)
                {
                    case 0:
                        awaiter1 = this.awaiter;
                        this.awaiter = new TaskAwaiter();
                        this.state = nextState = -1;
                        break;
                    case 1:
                        awaiter2 = this.awaiter;
                        this.awaiter = new TaskAwaiter();
                        this.state = nextState = -1;
                        goto EndProc;
                    default:
                        Console.WriteLine("DB 연결 시작");
                        awaiter1 = Task.Delay(2000).GetAwaiter();
                        if (!awaiter1.IsCompleted)
                        {
                            this.state = nextState = 0;
                            this.awaiter = awaiter1;
                            ExecuteDBState stateMachine = this;
                            this.builder.AwaitUnsafeOnCompleted(ref awaiter1, ref stateMachine);
                            return;
                        }
                        break;
                }
                awaiter1.GetResult();
                Console.WriteLine(this.spName + " 호출 시작.");
                awaiter2 = Task.Delay(2000).GetAwaiter();
                if (!awaiter2.IsCompleted)
                {
                    this.state = nextState = 1;
                    this.awaiter = awaiter2;
                    ExecuteDBState stateMachine = this;
                    this.builder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine);
                    return;
                }
            EndProc:
                awaiter2.GetResult();
                Console.WriteLine(this.spName + " 호출 종료.");
                this.result = "장형이";
                result = this.result;
            }
            catch (Exception ex)
            {
                this.state = -2;
                this.result = (string)null;
                this.builder.SetException(ex);
                return;
            }
            this.state = -2;
            this.result = (string)null;
            this.builder.SetResult(result);
        }
    }
}

 

 

결론

async await가 쓰기 편하고 별거 아닌 거 같아서 자세히 본 적이 없었는데, 이런 마법같은 일들이 벌어지고 있었을 줄이야...

aync 함수는 어떤 스레드가 해당 함수를 실행할지 알 수 없다는 점은 꼭 기억하고 있어야 할 것 같다.