본문 바로가기
C#/Threading

async/await 비동기 작업

by PlaneK 2021. 8. 4.

async/await 비동기 작업

async/await 키워드는 멀티 스레드로 비동기 작업을 수행한다.

우선 yield return을 간단하게 살펴보자.

yield return

using System;
using System.Collections;
using System.Threading;

public class Program
{
    public static void Main()
    {
        var enumerator = DoTaskAsync();
        
        enumerator.MoveNext();  
        // [Main  ] Thread ID: 1
        Console.WriteLine($"[Main  ] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        
        if ((int)enumerator.Current % 2 == 0)
        {
            enumerator.MoveNext();
        }
    }

    private static IEnumerator DoTaskAsync()
    {
    	// [Task 1] Thread ID: 1
        Console.WriteLine($"[Task 1] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        var rand = new Random();
        yield return rand.Next();
        // [Task 2] Thread ID: 1
        Console.WriteLine($"[Task 2] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
    }
}

yield return 구문 전까지 쭈욱 실행하다가 Main으로 돌아간다.
Main에서는 난수 값,
 Current를 체크하고 2의배수이면 나머지 코드도 실행한다.

하나의 테스크를 둘로 나누고 뒷 구문을 나중에 처리하는 지연 처리 방식이다.

yield return이 사용된 DoTaskAsync 함수는 IL로 컴파일되면 IEnumerator 클래스의 개체로 전환된다.
컴파일하여 생성된 클래스, <DoTaskAsync>D__1MoveNext() 함수를 들여다보자.
<>1__state를 기준으로 [Task 1]과 [Task 2]가 분기된 모습을 볼 수 있다.

yield return으로 Main스레드 하나로 구현했기 때문에 실행 시점과 완료 시점의 설계가 가능하다.

 

이번엔 async/await을 살펴보자.

using System;
using System.Threading;
using System.Threading.Tasks;

public class AsyncStudy
{
    public static void Main()
    {
        Task.Factory.StartNew(() =>
        {
            DoTaskAsync();
        });
        Console.WriteLine($"[Main  ] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        Task.Delay(30000).Wait();	// main 스레드 대기
    }

    private async static void DoTaskAsync()
    {
        Console.WriteLine($"[Task 1] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        await Task.Delay(1000);	// worker 스레드 대기
        Console.WriteLine($"[Task 2] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        await Task.Delay(2000);	// worker 스레드 대기
        Console.WriteLine($"[Task 3] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
    }
}

스레드를 생성해서 해당 스레드에 비동기 함수, DoTaskAsync를 위임했다.
해당 함수는 await으로 코드를 3분할 했다.

Task.Factory.StarNew(delegate) ㆍ스레드를 생성하고 바로 시작한다.
Task.Delay(int) ㆍ제공된 ms만큼 현재 스레드를 대기시킨다.
async ㆍ비동기 함수임을 명시하고 await을 사용할 수 있게 한다.
await ㆍ호출자(caller)에게 return 하고, 비동기 작업과 이후 코드 구문을 스레드풀에 위임한다.

4번 worker 스레드가 비동기 함수를 맡아 처리한다.

async/await가 생성한 IL코드도 yield return과 아주 유사하다.
마찬가지로, 함수, DoTaskAsync<DoTaskAsync>d__1라는 IAsyncStateMachine 클래스의 개체가 된다.

✏️ State Machine
<>1__state 값을 갱신하고 이 값을 통해 나뉘어진 파트를 분기한다.
yield return을 사용했을 때도 State Machine 패턴과 비슷하게 IEnumerator가 생성됐다.

MoveNext()함수를 주목하자.
우리가 작성한 비동기 함수의 코드(초록색박스)가 num 값으로 분기됐음을 볼 수 있다.
그리고 각 분기가 실행될 때마다 <>t__builder.AwaitUnsafeOnCompleted() (빨간색박스) 콜백을 등록한다.

즉, Task.Delay(int)와 같은 awaiter가 완료되면 MoveNext()를 호출하도록 되어있다.
스레드풀은 사용가능한 스레드에게 MoveNext()를 위임한다.

비동기 함수 플로우

4번 스레드가 첫번째 MoveNext()를 호출하면 [Task 1]을 실행한다.
그리고 AwaitUnsafeOnCompleted 콜백에 awaiter를 등록한다.

awaiter가 completed되면 다시 MoveNext()를 호출한다.
[Task 2]가 실행되면 다음 awaiter가 콜백에 등록된다.

다음 awaiter가 completed되면 마지막 MoveNext()를 호출한다.
[Task 3]을 실행하고 비동기 함수를 종료한다.

 

중첩 테스크 기다리기(동기화)

비동기 쓰레드가 함수를 종료하면 "Completed..."를 출력하는 상황을 가정해보자.

using System;
using System.Threading;
using System.Threading.Tasks;

public class AsyncStudy
{
    public static void Main()
    {
        Task.Factory.StartNew(() =>
        {
            DoTaskAsync();
            Console.WriteLine($"Completed Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        });
        Console.WriteLine($"[Main  ] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        Task.Delay(30000).Wait();
    }

    private async static void DoTaskAsync()
    {
        Console.WriteLine($"[Task 1] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        await Task.Delay(1000);
        Console.WriteLine($"[Task 2] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        await Task.Delay(2000);
        Console.WriteLine($"[Task 3] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
    }
}

위와 같이 완료되지 않았는데도 "Completed .."를 출력한다.
InnerTask를 실행하는 OuterTask만 await의 대상이 된 것이다.

using System;
using System.Threading;
using System.Threading.Tasks;

public class AsyncStudy
{
    public static void Main()
    {
        Task.Factory.StartNew(async () =>
        {
            await DoTaskAsync();
            Console.WriteLine($"Completed Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        });
        Task.Factory.StartNew(async () =>
        {
            await DoTaskAsync();
            Console.WriteLine($"Completed Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        });
        Console.WriteLine($"[Main  ] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        Task.Delay(30000).Wait();
    }

    private async static Task DoTaskAsync()
    {
        Console.WriteLine($"[Task 1] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        await Task.Delay(1000);
        Console.WriteLine($"[Task 2] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
        await Task.Delay(2000);
        Console.WriteLine($"[Task 3] Thread ID: {Thread.CurrentThread.ManagedThreadId}");
    }
}

InnerTask도 Task를 반환한하면 await을 사용할 수 있다.

await을 만난 OuterTask 쓰레드는 나머지 코드를 바로 실행하지 않고 그 동안 다른 일을 진행한다.

✏️ 논블록 vs 블록 / 동기 vs 비동기
블록   : 스레드가 비동기 함수가 종료될 때 까지 머무르며 대기
논블록: 바로 리턴하여 다른 일을 처리
동기   : 같은 시점에 해당 테스크를 순차 처리
비동기: 같지 않은 시점에 해당 테스크를 병렬 처리

위 예제들은 논블록 / 비동기 처리 방식이다.
비동기 vs 동기

 

참고

Exploring the async/await State Machine – Concrete Implementation           
C# Async/Await/Task Explained (Deep Dive)

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

비동기 vs 동기  (0) 2021.09.08

댓글