C#/Advanced C#

[C#] Task (3) async / await 동작 더 알아보기

로파이 2021. 11. 18. 01:12

async-await 그리고 Task에 대하여는 다음 포스트를 먼저 참고하면 좋습니다.

- Overview

2021.10.23 - [C#/Advanced C#] - [C#] Task (1) Async / Await overview

 

- 비동기 프로그래밍

2021.10.23 - [C#/Advanced C#] - [C#] Task (2) 비동기 프로그래밍 Asynchronous Programming

Task Asynchronous Programming (TAP)를 위한 async-await를 포함하는 비동기 메서드는 다음과 같이 작성할 수 있다.

public async Task AsyncPrint()
{
	await Task.Delay(50).ConfigureAwait(false); // 해당 작업은 작업자 스레드에서 진행된다
    
	// 이후의 영역도 같은 Id를 갖는 작업자 스레드에서 진행된다.
 }

위 코드가 내포하는 의미

  • Task란 해당 함수의 결과를 전달하기 위한 객체로 위 비동기 호출을 포함하는 비동기 함수 AsyncPrint()를 호출하면 해당 결과가 Task 객체에 전달될 것을 약속한다.
  • async 키워드는 await 키워드와 맞물려 CLR이 두 키워드를 기준으로 비동기 코드를 생성한다. await는 Task 객체를 기다려야한다. 따라서 Task 객체를 반환하는 비동기 메서드가 올 수 있다.
  • Task 타입을 반환하는 함수는 System.Threading.Task 네임스페이스에 정의된 Task 클래스의 설명에서도 볼 수 있듯이 비동기 작업을 갖고 있는 객체이다. Task 객체는 생성된 작업을 혹은 실행중인 비동기 작업을 갖고 있다.
  • ConfigureAwait(continueOnCapturedContext: false) : 캡쳐된 컨텍스트에서 진행하지 않고 기존 Task를 담당하던 스레드가 await 이후 부분을 작업하게 둔다.

Task.Delay()는 반드시 새로운 스레드를 사용하여 해당 스레드에서 Sleep()을 진행하는 함수이다. 따라서 위 AsyncPrint()를 Thread와 Task 클래스를 이용하면 다음과 같이 작성할 수 있다.

public async Task AsyncPrint()
{
    // await Task.Delay(50).ConfigureAwait(false); // 해당 작업은 작업자 스레드에서 진행된다
    
    Task task = new Task(() =>
    {
    	Thread.Sleep(50);
    });

    task.Start();
    await task;
 }

혹은 Task.Run(Action action)으로 표현이 가능하다.

public async Task AsyncPrint()
{
	await Task.Run(() => Thread.Sleep(50)).ConfigureWait(false);
}

 

async-await로 작성된 비동기 메서드가 반드시 새로운 스레드를 만드는 멀티 스레드 프로그래밍을 의미하는 것은 아니다. 비동기 프로그래밍은 비동기 작업을 포함하는 의미이지 멀티 스레드 프로그래밍과 동의어는 아니다.

 

하지만 await가 기다리는 Task의 결과에 따라 실행이 달리지는 비동기 프로그래밍은 await 이후의 동작 방식을 주의 깊게 볼 필요가 있다.

 

await는 기본적으로 해당 Task를 기다려서 이후의 작업을 Task가 끝난 이후에 실행한다는 의미이다.

 

- await가 만나는 Task가 이미 끝난 경우

public static async Task MethodAsnyc()
{
	Console.WriteLine($"Method begins on {Thread.CurrentThread.ManagedThreadId}");

	await Task.Delay(500).ConfigureAwait(false);

	Console.WriteLine($"Method ends on {Thread.CurrentThread.ManagedThreadId}");
}

public static async Task AwaitAlreadyFinishedTask()
{
	Console.WriteLine($"{nameof(AwaitAlreadyFinishedTask)} before async {Thread.CurrentThread.ManagedThreadId}");

	Task task = MethodAsnyc();

	Thread.Sleep(1000);

	Console.WriteLine($"{nameof(AwaitAlreadyFinishedTask)} after async {Thread.CurrentThread.ManagedThreadId}");

	await task.ConfigureAwait(false);

	Console.WriteLine($"{nameof(AwaitAlreadyFinishedTask)} end async {Thread.CurrentThread.ManagedThreadId}");
}

public static void Main(string[] args)
{
	Console.WriteLine($"Program Starts {Thread.CurrentThread.ManagedThreadId}");

	Task t = AwaitAlreadyFinishedTask();

	t.Wait();
}

await 이후의 영역도 실행중인 스레드에서 모두 동기화되어 진행된다.

1. 첫번째 비동기 메서드를 호출한다.

2. 비동기 작업을 포함하는 두번째 비동기 메서드(MethodAsync)를 먼저 실행한다.

3. 두번째 비동기 메서드의 await은 Task.Delay(500)을 만나고 작업이 끝나지 않았으므로 상위 호출자로 돌아간다.

4. 첫번째 비동기 메서드에서 바쁜 작업을 수행한다. (Thread.Sleep(1000))

5. 두번째 비동기 메서드의 결과를 await로 기다린다.

6. 해당 Task 작업은 이미 1초 내로 끝난 상태이므로 await 이후의 작업은 실행중이던 1번 스레드에서 동기화되어 수행한다.

 

- await가 만나는 Task가 아직 완료되지 않은 경우

이번에는 똑같은 코드에서 두번째 비동기 메서드(MethodAsync)가 더 늦게 종료되도록 만들어본다. 

Task.Delay(500) -> Task.Delay(2000)로 변경하였다.

5. 두번째 비동기 메서드의 결과를 await로 기다린다.

6. 해당 Task 작업은 끝나지 않았으므로 await를 만난 실행 중인 1번 스레드는 Main() 함수로 돌아가 AwaitAlreadyFinishedTask() 함수 이후의 실행 라인을 계속 실행한다.

7. 두번째 비동기 메서드를 처리하던 4번 스레드가 첫번째 비동기 메서드의 결과를 처리한다.

 

- ConfigureAwait(bool continueOnCapturedContext)

해당 인자가 false로 설정되었다면, await 이후의 영역은 현재 컨텍스트가 아닌 다른 스레드에서 작업을 수행하므로공유 변수에 대한 경쟁 조건 등의 스레드 안전에 유의해야한다. .NET Framework 혹은 GUI 어플리케이션의 경우 SynchronizationContext (동기 컨텍스트)가 존재하여 단일 스레드로 유지하려하기 때문에 동작 방식이 다르다. 콘솔 어플리케이션의 경우 동기 컨텍스트가 존재하지 않아 항상 다른 작업자 스레드가 처리한다.

 

Loop 문에서 await의 사용

private static async Task AsyncLoop()
{
	Console.WriteLine("Loop Begins");

	for(int i = 0; i < 100; ++i)
	{
		await AsyncPrint(i).ConfigureAwait(false);

		// 스레드 안전한가?
		PrintThreadUse();
	}

	Console.WriteLine("Loop Ends");
}

Loop 내에서 await를 사용하면 다음 루프 (i -> i+1)는 해당 작업이 끝날때 까지 진행되지 않는다.

따라서 AsyncLoop()는 모든 Loop 내 작업이 완료되어야 최종 완료된다.

 

await의 배치와 실행 흐름

만약 다음과 같이 코드를 작성한다면 전체 흐름은 하나의 스레드만 담당해서 실행될 것이다. 담당하는 스레드는 비동기호출로 달라질 수 있지만 전체적인 흐름은 하나의 스레드가 처리한다.

static async Task Main(string[] args)
{
      Console.WriteLine("Hello World!");

      await AsyncLoop(); // 비동기 실행 시작

      await AsyncLoop(); // 위 AsyncLoop()가 끝나야 시작

      PrintThreadUse(); // 마지막 AsyncLoop()가 끝나면 시작

      Console.WriteLine("All Tasks Are Finished");

      Console.ReadLine();
}

스레드 흐름 개괄도

전체 시점에서 실행 흐름이 2개 이상 되는 시점은 발생하지 않는다.

 

await 배치에 따른 멀티 스레드 프로그래밍 가능성

하지만 다음과 같이 작업을 미리 실행 시켜놓고 await로 기다리는 것은 실행 흐름이 2개이상 겹치는 구간이 생긴다.

static async Task Main(string[] args)
{
      Console.WriteLine("Hello World!");

      Task t1 = AsyncLoop();

      Task t2 = AsyncLoop();

      await t1;

      await t2;

      PrintThreadUse();

      Console.WriteLine("All Tasks Are Finished");

      Console.ReadLine();
}

첫번째 비동기 메서드의 결과 Task를 기다리지 않고 두번째 비동기 메서드를 실행하였다. 두 메서드에서 만나는 await는 작업이 끝나지 않았기 때문에 바로 Main()으로 빠져나온다. 그리고 첫번째 비동기 메서드를 기다리게 된다.

 

루프 문을 실행하는 두 작업은 서로 다른 두 개의 작업자 스레드에서 진행된다. 

결과적으로 Main 스레드 그리고 두 개의 작업자 스레드가 살아있으므로 멀티 스레드 프로그래밍을 포함한다. 혹시나 await 이후의 영역에서 공유 변수를 사용하거나 하는 등 상황이 있다면 반드시 동기화를 생각해야한다.