dev-writeup-2024 / march

개발 1일 1글 스터디
2 stars 0 forks source link

[03-07] C# async/await 직접 구현하기 (Task 편) #19

Open Longseabear opened 3 months ago

Longseabear commented 3 months ago

C# async/await 컴파일러 대신 구현하기

참고: C# 컴파일러 대신 직접 구현하는 비동기(async/await) 코드

Main code

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만 분리하면 아래와 같이 표현 가능하다.

private async Task<string> GetFileContents()
{
    Task<string> getStringTask = new TaskFactory().StartNew(() => { return "test"; });

    string urlContents = await getStringTask;
    return urlContents;
}

여기서, async/await은 해당 함수를 두 단계로 분리한다.

[Part A]
Task<string> getStringTask = new TaskFactory().StartNew(() => { return "test"; });
[Part B]
  string urlContents = [getStringTask 작업의 반환값];
  return urlContents;

C# Task에서는 Part A를 메인 스레드에서 실행하고, Part B는 별도의 스레드에서 처리한다(비동기성). 다만 주의할 점은 async/await 자체는 multithreding을 지원해주지 않는다. 단지 Part A와 Part B로 나누어 줄 뿐이다.

즉, 비동기를 다루기 쉬운 프레임워크를 제공할 뿐, 실제 비동기 처리를 하지 않는다.

compiler는 async 명령어를 가진 함수를 만나면 위 두 파트를 나눠 담을 IAsyncStateMachine 인터페이스를 상속한 별도의 내부 클래스로 바꿔준다.

/*

public interface IAsyncStateMachine
{
    void MoveNext();
    void SetStateMachine(IAsyncStateMachine stateMachine);
}

*/

class GetFileContents_StateMachine : IAsyncStateMachine
{
    // ... [생략]...
}

위에 보이는 것 처럼, 컴파일러가 새로운 이름으로 클래스를 새롭게 정의한다. 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을 초기화하는 코드로 전환된다.

private Task<string> GetFileContents()
{
    GetFileContents_StateMachine stateMachine = new GetFileContents_StateMachine
    {
        _this = this,
        _builder = AsyncTaskMethodBuilder<string>.Create(),
        _state = -1,
    };

    stateMachine._builder.Start(ref stateMachine);
    return stateMachine._builder.Task as Task<string>;
}

여기서, _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을 구축하는 것은 동일하지만, 별도의 스레드를 만들지 않는다!

다음 시간에는 Unitask에 대해 더 자세하게 공부해본다.

snaag commented 3 months ago

JS 에서도 동일한 문법으로 비동기 코드를 작성하고 있습니다. 신기해서 찾아보니 C# 의 Task 는 JS 의 Promise 와 대응되는군요.