C#/Advanced C#

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

로파이 2021. 10. 23. 22:21

지난 정리에서 Task를 사용하는 async와 await의 특징은 다음과 같았다.

  • async 키워드는 해당 메서드, 람다, 이벤트 처리기 등을 수식하여 비동기 동작을 하나라도 포함하는 것을 말한다.
  • async 키워드를 사용한 함수에서 반드시 await를 사용해야한다. 그렇지 않으면 컴파일러는 해당 함수를 동기(Synchronous) 작업으로 취급한다.
  • async 키워드를 사용한 함수는 비동기 메서드의 결과와 연관된 Task를 반환해야한다.
  • Task를 직접 생성하여 둘 이상의 비동기 작업을 동시에 시작하는 것을 고려하고 await의 순서에 따라 성능이 달라질 수 있다.
  • Task.Wait(), Task.WhenAll() 혹은 Task.WhenAll()과 같은 함수로 비동기 메서드가 완료될 때까지 기다릴 수 있다.

 

Task<TResult>의 의미

Task<TResult>는 어떤 작업의 결과가 Task 개체에 저장되어 전달될 것이라는 것을 암시한다. Task 클래스는 직접 메서드나 람다 함수와 같은 바탕(Body) 작업을 생성자 매개변수로 전달하여 생성하면 그런 함수와 인자를 사용하는 아직 시작되지 않은 작업을 의미한다.

 

1) 명시적 Task 생성

Task<int> myTask = new Task(CalculateIntResult);

이와 같은 Task의 상태는 아직 작업이 시작되지 않았고 그러한 작업이 int 결과를 전달해줄 것이라는 것만 암시한다.

해당 작업을 시작하기 위해서는 Start()를 호출해야한다.

 

2) 암시적 Task 생성

또 다른 한편으로 async로 수식된 비동기 메서드는 반드시 Task 혹은 Task<TResult>를 반환해야한다. 다음과 같은 JustIdleEvent() 비동기 메서드가 있다 하자.

static async Task JustIdleEvent()
{
	await Task.Delay(3000);
	Console.WriteLine("Works just fine");
}

해당 비동기 메서드를 호출하면 Task 객체를 얻을 수 있고 이 때 작업은 이미 시작된 상태이다. Task 객체를 직접 생성하지는 않았지만 JustIdleEvent()는 async-await를 포함하여 내부적으로 Task 객체를 생성하여 반환한다.

Task를 반환하는 이유는 해당 메서드가 Task.Delay()라는 작업이 완료되어야만 끝나는 작업이기 때문이다. 

static void Main(string[] args)
{
	Task idleTask = JustIdleEvent();

	Console.WriteLine("Wait For Event...");

	idleTask.Wait();
}

예시의 비동기 메서드는 void 형으로 아무것도 반환하지 않지만 Task<TResult>의 경우 Result라는 속성을 접근해서 결과를 얻을 수 있다. Result 속성을 접근하면 해당 작업 결과가 끝날때 까지 Wait()하는 것과 같다.

        //
        // 요약:
        //     Gets the result value of this System.Threading.Tasks.Task`1.
        //
        // 반환 값:
        //     The result value of this System.Threading.Tasks.Task`1, which is of the same
        //     type as the task's type parameter.
        //
        // 예외:
        //   T:System.AggregateException:
        //     The task was canceled. The System.AggregateException.InnerExceptions collection
        //     contains a System.Threading.Tasks.TaskCanceledException object. -or- An exception
        //     was thrown during the execution of the task. The System.AggregateException.InnerExceptions
        //     collection contains information about the exception or exceptions.
        public TResult Result { get; }

 

await

await는 연산자이다. await의 피연산자는 Task 객체 혹은 Task 객체를 반환하는 메서드가 온다.

 

async 함수 내에서 await 이후로의 모든 연산은 비동기로 취급된다. 이때, 해당 Task가 끝나지 않은 경우 비동기 메서드를 호출한 Caller는 바로 메서드를 빠져나와 다른 작업을 할 수 있다.

 

var result = await (Task<TResult>)

 

await의 피연산자가 해당 작업이 반환값이 있는 Task<TResult>인 경우 Task에 저장된 결과를 꺼내어(unpack) 지역 변수에 저장한다.

 

다음 예시는 Task.Run()의 결과를 기다려 int형 지역변수 result에 담아 호출 함수의 결과, Task<int>에 전달한다. int 변수를 return 하면 컴파일러는 새로 Task 개체를 생성하여 int 결과를 담는 코드를 만든다.

static async Task<int> GetOnePlusTwo_FuncWrapper()
{
	int result = await Task.Run(() => 1 + 2);

	Console.WriteLine("It was so hard to calculate...");

	return result;
}

static void Main(string[] args)
{
	Task<int> myResult = GetOnePlusTwo_FuncWrapper();

	myResult.Wait();

	Console.WriteLine($"1+2 = {myResult.Result}");
}

 

await이 대기하는 작업은 주로 스레드 풀에 제출되어 처리 된다. 스레드 풀에 속한 스레드가 Task<TResult>에 해당하는 작업을 처리하면 이후에 result를 반환받아 process_first~와 같은 사용자 영역 코드를 계속 실행한다.

 

var result = await (Task<TResult>)

process_first(result);

process_second(result);

 

※ Winform과 같은 어플리케이션에서 동기화 컨텍스트를 가지는 GUI 스레드가 기본적으로 사용자 영역 코드를 처리하므로 작업을 처리하던 스레드가 하지 않을 수있다.

 

비동기 프로그래밍 Asynchronous Programming

이제 async와 await을 적극적으로 사용하는 실제 예시를 보도록 한다. 비동기 프로그래밍은 Task 개체를 사용하게 되는데 Task 개체에 해당하는 작업은 크게 2가지 분류로 나뉜다.

  • I/O 작업을 동반하는 비동기 코드, Task나 Task<TResult>를 반환하는 함수, 주로 비동기 API를 호출한다.
  • CPU를 직접 사용하는 비동기 코드, 사용자가 비동기로 처리하고 싶은 함수를 Task.Run을 통해 백그라운드에서 실행하는 것이다.

이전에서 await 이후로 Caller가 바로 함수를 빠져나와 다른 작업을 할 수 있다하였는데, 이는 정확히 말하면 CPU 제어권을 함수를 호출한 쪽으로 양보하는 것이다. 함수의 반환이 실제로 이루어진 것은 아니며 Caller에 CPU 제어권을 넘긴다는 것은 함수를 호출 직전의 상태로 CPU가 세팅되고 비동기 메서드 이후의 내용을 계속 실행하는 것을 의미한다.

 

I/O Bound Task

I/O 작업을 동반하는 비동기 메서드는 주로 네트워크 소켓 입출력, 파일 입출력, 파이프 통신 등 커널 모드로 전환하는 시스템 호출을 포함한다. 그런 I/O 작업은 외부 하드웨어로 I/O 요청을 동반하기 때문에 OS에 의해 커널 모드로의 전환이 필요하다. OS는 I/O 요청이 발생하면 I/O Request Packet(IRP)이라는 메세지를 디바이스 드라이버의 IRP 큐에 보낸다. 디바이스 드라이버는 IRP 큐를 확인하여 먼저 처리해야하는 IRP를 확인한후 자체 연산 장치가 외부 버퍼로부터 내부 메모리에 데이터를 복사하는 등의 I/O를 수행한다. I/O 요청을 한 스레드는 IRP를 요청한 뒤 비동기 메서드를 사용하지 않는다면 블록된다. 이 모든 과정에서 CPU가 할 일은 없기 때문에 비동기 프로그래밍을 적극 사용한다. CPU는 커널이 등록한 I/O(IRP)의 완료를 인터럽트를 통해 알게 되고 결과를 전달받을 것이다. 

 

더 자세한 과정에 대한 설명 참고:

https://blog.stephencleary.com/2013/11/there-is-no-thread.html

 

다음과 같은 일련의 작업을 유추할 수 있다.

await -> 비동기 I/O 입출력 시동 -> 커널 API -> I/O 완료 -> Task의 상태 변경 및 결과 저장

 

※ C#에서는 이러한 I/O완료를 윈도우 IOCP 리소스를 사용하여 구현하고 있다.

 

예시)

Text 파일의 모든 라인을 읽어드려 관련 단어가 포함된 라인만 반환하는 비동기 메서드

        static async Task<string[]> GetRelativeLinesFile(string filePath, string query)
        {
            string[] lines = await File.ReadAllLinesAsync(filePath);

            List<string> filtered = new List<string>();
            foreach(string line in lines)
            {
                if(line.Contains(query))
                {
                    filtered.Add(line);
                }
            }

            return filtered.ToArray();
        }

 

CPU Bound Task

사용자가 정의하는 다양한 비동기 메서드를 생각할 수 있다. 대체로 계산이 오래 걸려 그 결과를 바로 쓰지 않을 것이라면 다른 작업을 할 수 있기에 비동기 프로그래밍을 활용한다. 초기의 예에서 GetOnePlusTwo_FuncWrapper() 함수도 하나의 CPU Bound Task라고 볼 수 있다. 주로 Task.Run을 사용하여 ThreadPool의 스레드를 사용하여 실행한다. 경우에 따라 System.Threading.Tasks.Parallel의 병렬 처리를 고려할 수도 있다. 

 

I/O bound task vs CPU bound task

 

I/O 작업을 동반하는 비동기 프로그래밍은 당연하게도 I/O 작업을 CPU가 할 필요가 없기 때문에 블로킹 되어 기다리는 시간에 CPU를 다른 작업에 사용한다. I/O에 비동기 프로그래밍을 사용하는 것은 CPU 유휴 시간을 없애고 작업 처리량을 늘리는 데 있다. 

 

한편, Task.Run()을 사용하는 CPU를 사용하는 비동기 프로그래밍은 스레드 풀에 있는 작업자 스레드를 이용하여 작업을 처리한다. 이때 작업은 스레드 풀의 작업자 스레드를 이용하기 때문에 백그라운드로 진행된다. 멀티 코어를 사용할 확률이 높으며 원래의 실행 흐름과 추가한 작업 흐름이 동시에 진행된다.

 

async 함수 내에서 await 이후의 작업 처리

await 이후 블록되지 않은 스레드는 제어권이 Caller 쪽으로 넘어간다는 것은 이해하였다. 그렇다면 실제로 비동기 메서드의 연산이 완료된 이후 나머지 동기 작업들은 어떤 스레드에 의해 어떻게 처리되는 것일까.

 

- Configure Context

ASP.NET 버전 혹은 GUI 어플리케이션의 UI Thread는 동기 컨텍스트(SynchronizationContext)라는 것이 있어서 await 시점의 Context를 Catch하는 기능이 있다. 해당 시점에서 실행되고 있던 Context는 await 이후에 다시 선점되어 실행된다는 특징이 있다. 

콘솔 어플리케이션이나 .NET Core 버전에는 동기 컨텍스트가 없기 때문에 Task를 처리하던 스레드가 계속 처리한다.

 

- 동기 컨텍스트로 인한 데드락

문제는 다음 코드 등으로 동기 컨텍스트에 의해 Deadlock이 발생할 수 있다는 점이다.

public static class DeadlockDemo
{
  private static async Task DelayAsync()
  {
    await Task.Delay(1000);
  }
  // This method causes a deadlock when called in a GUI or ASP.NET context.
  public static void Test()
  {
    // Start the delay.
    var delayTask = DelayAsync();
    // Wait for the delay to complete.
    delayTask.Wait();
  }
}

 

DelayAsnyc()를 호출한 주 스레드는 await를 만나자마자 빠져나와 delayTask.Wait()를 호출한다. 해당 비동기 메서드 DelayAsync() 함수가 종료될 때까지 주 스레드는 블록된다. 한편 Task.Delay()에 의해 시간이 경과되어 비동기 작업을 마친 다른 스레드는 동기 컨텍스를 불러오려 하고 동기 컨텍스트를 담당했던 스레드(주 스레드)가 이미 다른 작업을 하고 있다면 마칠때까지 기다린다. 주 스레드는 거꾸로 해당 스레드를 기다리고 있으니 데드락 조건을 만족하여 영원히 블록된다.

 

이러한 위험은 ASP.NET 이나 GUI 어플리케이션에서 동기 컨텍스트를 사용하는 경우 발생하기 때문에Task.ConfigureAwait(false)를 사용하여 작업을 처리했던 스레드 풀의 스레드가 계속 진행하게 한다.

public ConfiguredTaskAwaitable ConfigureAwait(bool continueOnCapturedContext);

콘솔 어플리케이션이나 .NET Core에서는 동기 컨텍스가 존재하지 않기 때문에 항상 처리하던 스레드가 await 이후의 작업을 처리하며 스레드 스위칭이 일어나지 않기 때문에 성능적으로도 더 낫다.

 

간단하게 테스트를 해보면 async 비동기 작업 중의 상황과 await 이후의 상황을 파악할 수 있다.

        static void PrintStatus(ConsoleColor foreground, string Region, int ThreadId, bool IsSingle, int ProcesserId, bool IsPool)
        {
            string ss = string.Format($"[{Region,18}] | Thread ID : {ThreadId,-2:D} | Single {IsSingle, -5} | ProccesorId : {ProcesserId,-2:D} | ThreadPool : {IsPool, -5}");
            Console.ForegroundColor = foreground;
            Console.WriteLine(ss);
        }
        static async Task LoopTaskAsync()
        {
            await Task.Run(() =>
            {
                for (long k = 0; k < 100000000; ++k)
                {
                    if (k % 1000000 == 0)
                    {
                        PrintStatus(ConsoleColor.Yellow, "AsyncTask", Thread.CurrentThread.ManagedThreadId,
                                                    IsSingleThreadProgramming(),
                                                    Thread.GetCurrentProcessorId(),
                                                    Thread.CurrentThread.IsThreadPoolThread);
                    }
                }
            });

            for (long k = 0; k < 100000000; ++k)
            {
                if (k % 1000000 == 0)
                {
                    PrintStatus(ConsoleColor.Blue, "SyncTask in Async", Thread.CurrentThread.ManagedThreadId,
                                    IsSingleThreadProgramming(),
                                    Thread.GetCurrentProcessorId(),
                                    Thread.CurrentThread.IsThreadPoolThread);
                }
            }
        }
        static void Main(string[] args)
        {
            Console.WriteLine("Program Starts");
            Console.WriteLine($"Main Thread ID : {Thread.CurrentThread.ManagedThreadId}");

            var myTask = LoopTaskAsync();

            for (long k = 0; k < 200000000; ++k)
            {
                if (k % 1000000 == 0)
                {
                    PrintStatus(ConsoleColor.White, "Main", Thread.CurrentThread.ManagedThreadId,
                                      IsSingleThreadProgramming(),
                                      Thread.GetCurrentProcessorId(),
                                      Thread.CurrentThread.IsThreadPoolThread);
                }
            }

            myTask.Wait();
        }

 

- 결과

해당 코드는 주 스레드 작업 사이클에 비동기 코드에서 비동기 작업 영역과 await 이후 작업영역을 모두 포함하도록 했다. Main 주 스레드는 계속 1번에서 실행 중이며, 시동된 이후 노란색으로 표시된 부분을 보면 비동기 작업(AsyncTask)이 4번 작업자 스레드(ThreadPool)에서 실행되다가 파란 색 부분 await 이후의 작업(SyncTask in Async)을 계속 처리한다는 것을 알 수 있다.

 

 

- await 이후의 나머지 작업이 반드시 작업자 스레드에 의해 처리되는 것은 아니다.

I/O Bound Task를 통한 await이후의 작업이 어떻게 처리되는 지 살펴본다.

텍스트 파일을 연 다음 StreamReader의 ReadLineAsync()를 사용해서 한 줄 읽기 작업을 비동기로 처리해본다.

코드는 다음과 같다.

        static async Task<int> GetNumLinesOfFile(string filePath)
        {
            int LineCount = 0;
            try
            {
                using (StreamReader sr = new StreamReader(new FileStream(filePath, FileMode.Open)))
                {
                    while (!sr.EndOfStream)
                    {
                        var line = await sr.ReadLineAsync();

                        Console.WriteLine($"{line}");
                        ++LineCount;
                    }
                }
            }
            catch (FileNotFoundException)
            {
                return 0;
            }

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

            var result = GetNumLinesOfFile("data.txt");

            Console.WriteLine("===================================");
            Console.WriteLine("Do Other things in Main Thread");
            Console.WriteLine("===================================");

            Console.WriteLine("Waiting...");

            result.Wait();

            Console.WriteLine($"Get Result {result.Result}");
        }

총 361줄의 data.txt를 읽었을 때 코드를 실행한 결과는 다음과 같다. Thread.CurrentThread.ManagedThreadId 는 현재 실행중 인 스레드의 고유 번호를 나타낸다.

 

Program Starts

Main Thread ID : 1

-- 텍스트 파일의 첫 줄

-- 출력 중

=============

Do Other Things in Main Thread

=============

Waiting...

-- 다시 출력중

-- 텍스트 파일의 마지막 줄

Get Result 361

 

Caller로 제어가 돌아가는 지점은 await을 호출한 GetNumLinesOfFile의 함수의 ReadLineAsync()일 것이다.

await에 의해 바로 Caller 쪽으로 전환되는 것이 아니라 I/O가 빨리 끝났으므로 주 스레드에 의해 약 50줄 정도 출력한 뒤 Caller의 Main 함수를 실행하고 Wait()의 호출로 다시 await 이후 읽기 결과를 출력하고 있다.

 

비동기 작업이 시동될 때 OS를 거쳐서 I/O 완료 알림을 등록하는 시간이 필요하다. 이전에 작업의 종류나 네트워크 상황에 따라 비동기 I/O 완료가 알림을 등록하는 것보다 더 빨리 끝날 수 있고 그럴 경우 Caller의 스레드 (주 스레드)에 의해 처리되는 것을 볼 수 있다. 

 

참고

https://docs.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming

https://blog.stephencleary.com/2017/03/aspnetcore-synchronization-context.html

https://stackoverflow.com/questions/13489065/best-practice-to-call-configureawait-for-all-server-side-code