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__1의 MoveNext() 함수를 들여다보자.
<>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)
댓글