언어/C#

C# async/await

tsyang 2022. 6. 12. 02:13

개념

 

async/await 메서드는 비동기 함수라고도 불린다. 기존에 존재하던 Task의 장점을 살려 개발자가 좀 더 쉽게 비동기 작업을 수행할 수 있는 프로그래밍 만들고자 하여 탄생한 것이 바로 async/await 되시겠다.

 

await 연산자는 피연산자가 나타내는 비동기 작업이 완료될 때까지 수행을 중지한다. 그리고 await연산자를 포함한 메서드에 async를 붙여 컴파일러가 해당 함수가 비동기 함수임을 알 수 있게 한다.

static async void SomeMethod()
{
    await Task.Run(() =>
    {
        SomeHeavyWork();    //시간이 좀 걸리는 메서드
    });

    //Task가 끝난 뒤에야 아래 코드가 시작된다.

    Console.WriteLine("SomeHeavyWork Ended");
}

 

참고로 async/await는 코루틴처럼 하나의 쓰레드가 비동기적으로 작업을 하는게 아니라 여러 쓰레드를 활용한다. (Task를 사용하므로)

 

public class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("Main Thread Id : " + Thread.CurrentThread.ManagedThreadId);

        //Method A,B,C를 각각 비동기적으로 실행한다.
        var taskA = MethodA();
        var taskB = MethodB();
        var taskC = MethodC();

        int numTasks = 3;

        var allTasks = new List<Task<int>>() {taskA, taskB, taskC};

        while (allTasks.Count > 0)
        {
            //task A, B, C 중 하나가 끝날 때까지 기다림. 끝난 태스크는 finishedTask에 저장
            var finishedTask = await Task.WhenAny(allTasks);
            if (finishedTask == taskA)
            {
                Console.WriteLine("A done by thread : " + finishedTask.Result);
            }
            else if (finishedTask == taskB)
            {
                Console.WriteLine("B done by thread : " + finishedTask.Result);
            }
            else if (finishedTask == taskC)
            {
                Console.WriteLine("C done by thread : " + finishedTask.Result);
            }

            allTasks.Remove(finishedTask);
        }

        Console.WriteLine("End Program by thread : " + Thread.CurrentThread.ManagedThreadId);
    }

    static async Task<int> MethodA()
    {
        await Task.Delay(2000);
        SomeHeavyWork();
        return Thread.CurrentThread.ManagedThreadId;
    }

    static async Task<int> MethodB()
    {
        await Task.Delay(1000);
        SomeHeavyWork();
        return Thread.CurrentThread.ManagedThreadId;
    }

    static async Task<int> MethodC()
    {
        await Task.Delay(500);
        SomeHeavyWork();
        return Thread.CurrentThread.ManagedThreadId;
    }

    //여러 쓰레드를 동시에 사용하기 위해 실제 작업을 하는 코드
    //없으면 한 가지 쓰레드가 A,B,C 다 처리할수도
    static void SomeHeavyWork()
    {
        int what = 1;
        for (int i = 0; i < 987654321; ++i)
            what ^= i;
    }
}

위의 코드를 보면 A,B,C 순으로 태스크를 수행하였지만 C,B,A 순으로 완료 될 것을 예측할 수 있다. (각각 2초,1초,0.5초를 기다리므로)

 

A,B,C는 각각 시간이 오래 걸리는 작업을 수행한 뒤에 자신을 실행시킨 스레드의 ID를 반환한다.

 

그 후,

var finishedTask = await Task.WhenAny(allTasks);

위 코드에서 지정한 태스크 중 하나가 마무리될 때까지 기다린다(await).

 

그러면 완료된 태스크는 finishedTask에 지정이 되고, 이를 이용해 반환 값을 얻어오고, 완료한 태스크가 어떤 태스크인지도 알아낼 수 있다.

 

위 코드의 실행 결과는 다음과 같다.

 

 

 

 

비동기 함수는 특히나 Task.WhenAny같은 조합기(combinatory)와 같이 쓰이면 유용하다. 참고로 WhenAny 말고도 WhenAll도 있다.

//모든 테스크가 끝날 때 까지 기다리고 그 결과를 threadIDs에 저장
var threadIDs = await Task.WhenAll(taskA, taskB, taskC);

 

작동원리

 

async로 지정된 메서드를 만나면 컴파일러는 메서드를 상태 기기(State Machine)를 구현하고 있는 타입으로 변환한다. 즉, 람다 메서드처럼 메서드 하나를 하나의 타입으로 만든다는 것. 내부 상태머신은 대~충 다음과 같은 방식으로 구현하여 코드를 부분부분 수행할 수 있도록 한다. (주의, 정말 대충 묘사한 것으로 원래는 awaiter라는 개념이 추가되어야 함.)

 

struct StateMachine
{
    private int _state;

    public void MoveNext()
    {
        switch (_state)
        {
            case -1:
                //만약 대기중인 Task가 완료되지 않았다면 리턴
                //return;

                //완료되었다면
                //_state = 0;
                break;

            case 0:
                //다음 코드 수행
                break;

            //case N : ...
            //이런식으로 await들을 하나하나 처리
        }
    }
}

 

 

 

 

기타

 

비동기 함수는 몇 가지 제한이 있는데

 

  • out이나 ref 매개변수를 사용할 수 없고 (IEnumerator 처럼)
  • catch, finally, unsafe 블록 내에서 await를 사용할 수 없으며
  • await 연산자 이전에 락을 획득하고 await연산자 이후에 락을 해제할 수 없다. 왜냐하면 await 전/후로 코드를 수행하는 스레드가 같다는 보장이 없기 때문 (위의 예제 코드의 결과 참고)

 

 

또 일부 비동기 작업은 매우 빨리 수행될 수 있는데, 이런 경우 상태 기기를 일시 중단했다가 다시 다른 스레드를 이용하여 수행을 이어가는 방법은 비효율적일 수 있다. 컴파일러는 이런 상황을 고려하여 스레드가 반환되기 이전에 요청된 비동기 작업이 이미 완료된 경우 비동기 함수를 빠져 나오지 않고 비동기 함수 호출 코드의 다음 행을 바로 수행한다. (즉, 같은 스레드로 이어서 처리함)

 

실제로 위의 예제 코드에서 MethodA, B, C에서 다음과 같이 Delay와 SomeHeavyWork() 메서드를 제거하면

static async Task<int> MethodA()
{
    return Thread.CurrentThread.ManagedThreadId;
}

 

아래와 같이 한 가지 스레드가 모든 작업을 처리하게 된다.

 

그러나 이런 방법은 GUI 스레드의 경우라면 응답성이 구려질 수 있다는 문제가 있다. 

 

그런 경우에는 Task.Run 매서드를 이용하여 다른 스레드를 사용하도록 만들 수 있다.

public class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("Main Thread Id : " + Thread.CurrentThread.ManagedThreadId);
        
        var taskA = Task.Run(async () =>
        {
            await MethodA();
        });
        var taskB = MethodB();
        var taskC = MethodC();
    }

    static async Task<int> MethodA()
    {
        Console.WriteLine("A done by thread : " + Thread.CurrentThread.ManagedThreadId);
        return Thread.CurrentThread.ManagedThreadId;
    }

    static async Task<int> MethodB()
    {
        Console.WriteLine("B done by thread : " + Thread.CurrentThread.ManagedThreadId);
        return Thread.CurrentThread.ManagedThreadId;
    }

    static async Task<int> MethodC()
    {
        Console.WriteLine("C done by thread : " + Thread.CurrentThread.ManagedThreadId);
        return Thread.CurrentThread.ManagedThreadId;
    }
}

 

 

 아 그리고 GUI 코드에서 await를 호출한 경우 await 호출 전/후로 수행중인 스레드가 바뀔 수 있는데 GUI스레드가 아니라 다른 스레드가 UI 요소에 접근하면 예외가 발생된다. (이는 유니티에서 유니티 컴포넌트에 메인 스레드 말고 다른 스레드가 접근할 수 없는 것과 비슷한듯) 이런 문제는 동기화 컨텍스트 (SynchronizationContext) 객체를 이용해 해결하나.. 데드락을 유발하는 등의 잠정적인 문제 소지가 있다. 그래서 Task와 Task<TResult>는 ConfigureAwait라는 메서드를 제공한다. 일단 그냥 알아만 두자

 

 

"비동기 함수의 유일한 목적은 블로킹되지 말아야 하는 코드를 작성해야할 때 좀 더 단순하게 코딩을 할 수 있도록 하기 위함이다." - 제프리 리처 , CLR via C# 4판

 


참고 : 제프리 리처, CLR via C# 4판 , 비제이퍼블릭, 28장. I/O 중심의 비동기 작업  [853~876p]

'언어 > C#' 카테고리의 다른 글

beforefieldinit  (0) 2023.04.23
(C# 7.2) Span<T>  (0) 2022.08.07
스레드 동기화 - 기타 (이중 확인 락, 조건 변수, 컬렉션)  (1) 2022.06.05
복합 스레드 동기화 요소  (0) 2022.05.29
단순동기화3 - 커널 모드 동기화  (0) 2022.05.09