C#/Advanced C#

[More Effective C#] 3장 태스크 기반 비동기 프로그래밍

로파이 2021. 12. 13. 22:19

아이템 27. 비동기 작업에는 비동기 메서드를 사용하라

public static async Task SomeMethodAsnyc()
{
    Console.WriteLine("SomeMethod Begins...");
    
    Task assignedTask = AssignedTaskBegin();
    
    var result = await assignedTask;

    Console.WriteLine("SomeMethod Ends");
}

비동기 메서드는 비동기 작업을 포함하는 메서드를 의미한다.

  • Task assignedTask = AssignedTaskBegin(); 라인에서 비동기 작업이 시작되고 해당 결과를 전달받을 수 있는 Task 객체를 반환한다.
  • 비동기 메서드의 async-await 구문은 기본적으로는 해당 메서드가 동기 작업으로 진행하되, await 시점에서 기다리는 Task 객체가 작업이 이미 완료되었다면 다음 실행도 동기적으로계속 실행된다.

만약 기다리는 Task 객체가 작업이 완료되지 않았다면, 해당 비동기 메서드는 작업 결과를 기다리지 않고 메서드를 빠져나간다. 이제 await 이후의 영역은 해당 Task가 끝나면 처리된다.

 

컴파일러는 await 구문 이후의 코드를 나중에 수행할 목적으로 델리게이트로 감싸고 필수적인 자료구조를 생성한다. 자료구조 내에는 await를 호출한 메서드 내의 모든 지역 변수가 저장된다. 또한 기다리는 작업이 끝나고 돌아갈 지점을 구성한다.

 

비동기 작업이 끝나면 이벤트를 발생시켜 요청한 Task가 완료되었음을 알린다. 그런 이후 자료 구조 정보를 이용해 상태를 복원 시키고 동기적 실행 상황과 동일한 상태에서 중단 지점부터 계속 실행하게 된다.

 

SynchronizationContext(동기 컨텍스트)라는 클래스는 작업이 완료되고 비동기 메서드의 남아있는 처리 부분으로 돌아가게만드는 역할을 한다. 컴파일러는 동기 컨텍스트를 저장해두고 작업이 완료되면 남아있는 부분을 델리게이트화 하여 동기 컨텍스에전달한다. 동기 컨텍스트는 실행 환경에 따라 전달받은 재개 코드를 스케줄링한다.

 

ASP.NET Framework / GUI 어플리케이션은 동기 컨텍스트가 존재하며 콘솔 어플리케이션은 동기 컨텍스트가 없기 때문에 작업에 사용하던 스레드를 이용한다.

 

Task를 반환하는 비동기 메서드에도 예외가 발생할 수 있다. 비동기 메서드내에서 발생한 예외는 컴파일러가 생성한 try-catch 구문으로 잡아내어 반환되는 Task 객체의 AggregateException에 저장된다. 만약 오류 상태의 Task를 await하게 되면 해당 Task의 AggregateException 객체에 담긴 첫번째 예외를 던진다. 여러 Task의 포함으로 예외가 여러 개가 발생했다면 모든 예외를 일일이 확인해야할 수도 있다.

 

아이템 28. async void 메서드는 절대 작성하지 말라

 

작성하지 말라는 이유의 결론은 async void로 작성된 비동기 메서드는 호출쪽에서 예외를 절대 잡을 수 없기 때문이다.

 

또 다른 문제는 Task를 반환하지 않기 때문에 해당 작업을 대기할 방법이 없다. 따라서 async void로 작성된 메서드는 보통 메서드를 실행하고 잊어라('Fire And Forget')의 원리를 갖는다.

 

async void를 사용하는 메서드는 void를 반환하는 이벤트성 메서드가 비동기 작업을 포함할 때 사용된다. 그러함에도 해당 메서드가 예외를 발생시키면 알 길이 없기 때문에 반드시 예외를 던지지 않도록 작성해야한다.

private async void OnButton_Click(object sender, RoutedEventArgs e)
{
    try
    {
    	var dataToDisplay = GetContents();
        
        await view.Update(dataToDisplay);
    }
    catch (Exception e)
    {
    	MessageBox(e.Message);
    }
}

 

사용자는 예외 발생에 따라 전체 프로그램을 종료시키는 것을 원할 수도 있고 단순히 예외 처리만하고 지나가게 둘 수 있다. 예외처리에 대하여 일반화된 FireAndForget 메서드는 다음과 같이 작성할 수 있다.

public static async void FireAndForget<TException>(this Task task, Action<TException> recovery, Func<Exception, bool> onError)
            where TException : Exception
        {
            try
            {
                await task;
            }
            catch (Exception ex) when (onError(ex)) // 에러를 기록한다.
            { 
            }
            catch (TException ex2) // 복구할 수 있는 예외를 처리한다.
            {
                recovery(ex2);
            }
        }

 

아이템 29. 동기, 비동기 메서드를 함께 사용해서는 안 된다.

 

async 한정자를 사용한 메서드는 작업이 마치기전에 해당 메서드가 반환될 수 있다는 것을 의미한다. 주로 해당 비동기 메서드는 Task를 반환하여 해당 작업의 상태를 알 수 있다.

 

동기, 비동기 메서드를 혼합하여 사용할 경우 교착 상태와 같은 버그를 만들어낼 수 있다. 따라서 이러한 가능성을 피하려면 다음과 같은 두 가지 중요한 규칙을 따라야한다. 첫째, 비동기 작업이 완료될 때까지 기다리는 동기 메서드를 만들지 않는다. 둘째, 수행 시간이 오래걸리는 CPU 중심 작업을 비동기로 수행하지 않는다. 

 

비동기 코드를 감싼 동기 코드가 문제를 일으키는 원인은 3가지이다. 1) 서로 다른 예외처리 방식, 2) 잠재적 교착상태, 3) 리소스 낭비

 

1) 동기 메서드에서 예외 처리 vs 비동기 메서드에서 예외 처리

 

비동기 메서드내에서 await가 예외가 발생한 작업을 기다리게 되면 해당 Task의 AggregateException의 첫번째 예외가 던져진다.

이와 비슷하게 동기 메서드내에서 예외가 발생한 Task의 Wait()을 호출하거나 Result 속성을 읽으려하면 모든 예외가 담긴 AggregateException이 던져진다. 따라서 해당 AggregateException을 먼저 catch한 이후 내부 모든 예외들 InnerExceptions을 들여다보아야 원하는 예외를 처리할 수 있다.

public static async Task<int> ComputeAsync()
{
    try
    {
        var result1 = await GetResultFirst();
        var result2 = await GetResultSecond();

        return result1 + result2;
    }
    catch (KeyNotFoundException e)
    {
        return 0;
    }
}

public static int Compute()
{
    try
    {
        var result1 = await GetResultFirst().Result;
        var result2 = await GetResultSecond().Result;

        return result1 + result2;
    }
    catch (AggregateException e) when (e.InnerExceptions.FirstOrDefault().GetType() == typeof(KeyNotFoundException))
    {
        return 0;
    }
}

 

2) 잠재적 교착상태

 

SynchronizationContext를 사용하는 어플리케이션에서 다음과 같은 코드는 데드락을 유발한다.

public static async Task NormalMethodAsync()
{
	await Task.Delay(1000);
}

public static void MayPoseDeadlock()
{
	var delayTask = NormalMethodAsync();

	delayTask.Wait();
}

MayPoseDeadlock() 동기 메서드는 비동기 메서드를 호출하고 해당 Task를 기다린다. SynchronizationContext가 있는 경우 다음과 같은 순환 고리가 생기는 스레드 대기가 발생하고 이는 교착생태이다.

  • 동기 메서드 : delayTask가 끝날때 까지 기다린다. 주 스레드를 block 시킨다.
  • 비동기 메서드 : Task.Delay() 작업이 끝난뒤 동기 컨텍스트에 이후 영역을 처리할 주 스레드를 요청한다. 동기 컨텍스트는 해당 스레드가 사용가능할 때까지 기다린다. 하지만 주 스레드는 block 상태이므로 영원히 대기하게 된다.

비동기 메서드를 감싼 동기 메서드를 작성하면 안되는 규칙의 예외로 콘솔의 Main() 함수는 함수가 반환되면 프로그램이 종료되기 때문에 동기 메서드로 작성하여 작업을 기다리게 하는 것이 보통이다. 최근에는 Main() 함수도 비동기로 작성할 수 있게 되었다.

 

3) 리소스 낭비

 

단순 CPU 작업을 비동기 메서드로 감싸서 제공하는 것을 말한다. 대부분의 API 설계에서 해당 메서드가 비동기가 가능하다면 동기 버전과 비동기 버전 모두를 제공하는 것이 좋다. 그러나 그런 CPU 작업을 비동기 메서드로 감싼 형태로 제공하는 것은 해당 작업을 동기로 작업할 수 있는 선택을 없앨수 있다.

public float ComputeExpensive()
{
    float v = 0;
    for (int i = 0; i < 10000000; ++i)
    	v += function(i);
    return v;
}

public Task<float> ComputeExpensiveAsync()
{
	return Task.Run(() => ComputeExpensive());
}

CPU를 많이 사용하는 작업은 리소스를 차지하게 되고 성능 저하로 다른 주요한 처리를 방해할 수 있다. 가능하다면 CPU의 사용은 Main에서 시작하여 실행되는 주요한 프로시저에 온전히 쓰게한다. 비동기 작업은 핸들러의 호출, I/O 발생과 같은 본래 비동기 작업 성질이 큰 곳에 사용하고 CPU만 사용하는 작업이 필요하다면 그리고 규모가 큰 경우에는 다른 프로세스에서 새로운 프로그램으로 실행하는 것이 나을 수 도 있다.

 

보통 비동기 메서드가 하나 포함되기 시작하면 상위 호출 메서드도 비동기 메서드가 되고 이는 프로그램 전반으로 퍼지게 된다. 이를 위해 동기와 비동기 버전의 API를 제공하는 것은 중요하고 반드시 작업이 본질적을 비동기일 경우에만 새로운 스레드에 작업을 요청하도록 제한한다.

 

아이템 30. 비동기 메서드를 사용해서 스레드 생성과 콘텍스트 전환을 피하라

 

Context-Free-Code 자유 코드 : 어떤 콘텍스트에서도 실행될 수 있는 코드를 의미한다.

Context-Aware-Code 콘텍스트 인식 코드 : 특정 SynchronizationContext에서만 실행될 수 있는 코드를 의미한다.

 

동기 컨텍스트에서만 실행될 수 있는 어플리케이션은 GUI/웹 등이 있다. 그 예로 UI 스레드가 존재하고 해당 스레드에서만 UI 렌더링과 연관된 함수를 호출할 수 있다.

 

대부분은 자유 코드이지만 기본 동작방식은 동기 콘텍스트에서 실행되도록 설정되어 있다. 자유 코드는 어떠한 방식이든 상관이 없지만 콘텍스트 인식 코드는 반드시 해당 콘텍스트에서 실행되어야한다.

 

하지만 그렇다고 반드시 모든 작업을 해당 콘텍스트에서 할 필요는 없다. 불필요하게 콘텍스트에서 동기처리가 된다면 UI 어플리케이션의 성능은 떨어질 것이다.

 

ConfigureAwait() 설정은 await이후 계속 실행되는 작업을 캡쳐된 컨텍스트에서 실행되지 않도록 만들 수 있다.

public static async Task<Config> ReadConfig()
{
    var result = await DownloadAsnyc().ConfigureAwait(false);

    var items = XElement.Parse(result);
    var userConfig = from node in items.Descendants()
                        where node.name == "Config"
                        select node.Value;

    var configUrl = userConfig.SingleOrDefault();
    if (configUrl != null)
    {
        result = await DownloadAsnyc(configUrl).ConfigureAwait(false);
        var config = await ParseConfig(result).ConfigureAwait(false);
        return config;
    }
    else
        return new Config();
}

첫 비동기 작업이 캡쳐되지 않은 기본 컨텍스트에서 진행되도록 설정하였다면 그 이후의 설정도 기본 컨텍스트로 진행되도록 명시하는 게 좋다. 첫 비동기 작업이 바로 반환되어 동기적으로 수행되었다면 그 이후 비동기 작업이 설정되지 않았을 때 동기 컨텍스트를 캡쳐할 수 있기 때문이다.

 

작업이 동기 컨텍스트에서 진행되어야 하는 코드와 자유 코드가 섞여있다면 이를 분리해서 작성하도록 한다. 위의 코드는 온전히 자유 코드라고 볼 수 있으며 다음 코드에서 비동기 메서드로 호출되어 동기 컨텍스트를 사용하도록 할 수 있다.

private async void OnClickEvent(object sender, RoutedEventArgs e)
{
    var viewModel = (DataContext as SampleViewModel);
    try
    {
    	Config config = await ReadConfigAsync(viewModel);
    	await viewModel.Update(config);
    }
    catch (Exception ex) when (logMessage(viewModel, ex))
    {
    }
}

위 비동기 이벤트 핸들러는 자유 비동기 작업이 끝난 이후 UI 스레드에서 그 내용을 반영한다.

 

아이템 32. 비동기 작업은 태스크 객체를 사용해 구성하라

 

다양한 방법으로 Task 객체를 다루고 작업 구성과 완료를 설계한다.

 

- Task.WhenAll

여러 작업을 미리 시동시키고 마지막에 모든 작업이 완료되었을 때, 모든 결과를 수합하는 알고리즘에 적합하다.

public static async Task<int> SendAll(byte[] data)
{
    int bytesSent = 0;
    List<Task<int>> result = new List<Task<int>>();
    foreach (var client in GetClients())
    {
        result.Add(client.SendAsync(data));
    }

    await Task.WhenAll(result);

    foreach(var v in result)
    {
        bytesSent += v.Result;
    }
    return bytesSent;
}

 

- Task.ContinueWith()

위 코드에서 각 비동기 메서드가 끝난 뒤 처리할 작업을 미리 지정하여 두 작업을 한 작업으로 취급하여 실행할 수 있다.

ContinueWith(Action<Task<TResult>>) 원형으로 이전 작업이 결과를 받아 다음 실행할 작업을 정의한다.

ContienueWith를 사용하면 이전 Task에서 발생한 오류 및 취소 처리를 한꺼번에 할 수 있다.

public static async Task<int> SendAll(byte[] data)
{
    int bytesSent = 0;
    List<Task> result = new List<Task>();
    foreach (var client in GetClients())
    {
        result.Add(client.SendAsync(data)
            .ContinueWith( t =>
            {
                if (t.IsCompletedSuccessfully)
                {
                    Interlocked.Add(ref bytesSent, t.Result);
                }
                else if (t.IsFaulted)
                {
                    Console.WriteLine($"Send Failed : {t.Exception.Message}");
                }
            }
            ));
    }

    await Task.WhenAll(result);

    return bytesSent;
}

 

아이템 33. 태스크 취소 프로토콜 구현을 고려하라

 

태스크 기반 비동기 프로그래밍 모델 TAP는 진행을 취소하거나 보고하기 위한 표준 API를 제공한다.

작업 진행시 특정 상황에 따라 해당 작업을 취소해야하는 경우가 있을 수 있다.

여러 단계를 진행하여 급여를 계산하고 각 직원에게 급여를 송금하는 시스템이 있다하자.

    public class PayrollSystem
    {
        public async Task PayNow()
        {
            // 1 단계 : 모든 직원 정보를 가져온다.
            var employees = (await ReadEmployeesData()).ToList();
            Console.WriteLine($"Total Employees : {employees.Count}");

            // 2 단계 : 급여 및 세금을 계산한다.
            var payroll_infos = await CalculatePayroll(employees);
            int totalCost = 0;
            foreach(var info in payroll_infos)
            {
                totalCost += info.Salary;
            }
            Console.WriteLine($"Total Cost : {totalCost}");

            // 3 단계 : 은행을 통해 각 계산된 급여만큼 송금한다.
            await MakeBankTransactionPayroll(employees, payroll_infos);
            Console.WriteLine($"Transaction Success");

            // 4 단계 : 각 직원에게 임금 지급 결과를 메일로 보낸다.
            await NotifyEmployeesPayrollDays(employees);

            // 5 단계 : 정산 시스템을 마감한다.
            await OnRecordPayRollEnd(DateTime.Now);
        }

        private async Task<IEnumerable<Employee>> ReadEmployeesData()
        {
            await Task.Delay(5000);

            return new List<Employee>();
        }

        private async Task<IEnumerable<(int Salary, int Taxes)>> CalculatePayroll(IEnumerable<Employee> emlp)
        {
            await Task.Delay(5000);

            return new List<(int, int)>();
        }

        public async Task MakeBankTransactionPayroll(IEnumerable<Employee> employees, IEnumerable<(int,int)> results)
        {
            await Task.Delay(1000);
        }

        public async Task NotifyEmployeesPayrollDays(IEnumerable<Employee> employees)
        {
            await Task.Delay(1000);
        }

        public async Task OnRecordPayRollEnd(DateTime date)
        {
            await Task.Delay(1000);
        }
    }

 

취소를 시키는 CancellationTokenSource와 그 취소 상황을 알 수 있는 CancellationToken을 이용하여 각 단계에서 취소에 따른 정산 시스템 단계를 중간에 중단할 수 있다.

public async Task PayNowEx(CancellationToken token)
        {
            // 1 단계 : 모든 직원 정보를 가져온다.
            var employees = (await ReadEmployeesData()).ToList();
            token.ThrowIfCancellationRequested();

            Console.WriteLine($"Total Employees : {employees.Count}");

            // 2 단계 : 급여 및 세금을 계산한다.
            var payroll_infos = await CalculatePayroll(employees);
            token.ThrowIfCancellationRequested();

            int totalCost = 0;
            foreach (var info in payroll_infos)
            {
                totalCost += info.Salary;
            }
            Console.WriteLine($"Total Cost : {totalCost}");

            // 3 단계 : 은행을 통해 각 계산된 급여만큼 송금한다.
            await MakeBankTransactionPayroll(employees, payroll_infos);
            token.ThrowIfCancellationRequested();

            Console.WriteLine($"Transaction Success");

            // 4 단계 : 각 직원에게 임금 지급 결과를 메일로 보낸다.
            await NotifyEmployeesPayrollDays(employees);
            token.ThrowIfCancellationRequested();

            // 5 단계 : 정산 시스템을 마감한다.
            await OnRecordPayRollEnd(DateTime.Now);
            token.ThrowIfCancellationRequested();
        }

호출 메서드는 다음과 같이 CancellationTokenSource 객체를 이용하여 중간에 해당 작업을 취소시킬 수 있다.

var cts = new CancellationTokenSource();
paySystem.PayNowEx(cts.Token);

// 취소
cts.Cancel();

 

ThrowIfCancellationRequested()는 OperationCancelException 예외를 던지고 해당 Task를 Canceled 상태로 만든다.

취소된 Task 안에 AggregateException Exception 속성에 해당 예외가 저장되는 것은 아니다.

 

아이템 34. 비동기 메서드의 반환값을 캐시하는 경우 ValueTask<T>를 사용하라

 

ValueTask는 참조 타입의 Task 대신 값 타입을 사용하는 것으로 메모리 제약이 있는 환경에서 유용하다.

async await 비동기 메서드는 반환 타입으로 Task를 강제하지않으며 Awaiter 패턴을 따르는 타입을 반환하도록 요구한다. ValueTask의 GetAwaiter()는 INotifyCompletion과 ICriticalNotifyCompletion 인터페이스를 구현한 객체를 반환한다.

 

자주 호출되는 비동기 작업으로써 특정 조건에서만 비동기 메서드를 요구하고 평소에는 캐시된 값을 자주 반환한다면 ValueTask가 적절한 사용 예시가 될 수 있다.

 

  • ValueTask는 ValueTask<TResult>와 같은 반환타입을 갖는 Task<TResult> 생성자 매개변수로 전달하여 생성할 수 있다.
  • ValueTask는 GetAwaiter().GetResult() 두번 이상 호출 혹은 Result 속성 접근을 여러번 할 수 없다. 그런 방식으로 사용하고자 하면 AsTask()로 Task 객체로 변환시켜서 사용해야한다.

- 예시

  • 5분 마다 기상 정보를 업데이트 해야하는 작업이 있다하자.
  • 기준 시간이 5분이 초과하지 않았다면 캐시를 이용하여 ValueTask를 반환한다.
  • 그렇지 않다면 비동기 작업을 사용하여 결과를 얻고 캐시한다.
        private List<WeatherData> recentObservations = new List<WeatherData>();

        private DateTime startDate;
        private DateTime endDate;
        private DateTime lastReading;

        public static async Task<WeatherData> RetrieveObservationData(DateTime observeDate)
        {
            await Task.Delay(100);
            return new WeatherData();
        }

        public ValueTask<IEnumerable<WeatherData>> RetrieveHistoricalData()
        {
            if (DateTime.Now - lastReading < TimeSpan.FromMinutes(5))
            {
                return new ValueTask<IEnumerable<WeatherData>>(recentObservations);
            }
            else
            {
                async Task<IEnumerable<WeatherData>> loadCache()
                {
                    recentObservations = new List<WeatherData>();
                    var observationDate = this.startDate;
                    while (observationDate < this.endDate)
                    {
                        var observation = await RetrieveObservationData(observationDate);
                        recentObservations.Add(observation);
                        observationDate += TimeSpan.FromDays(1);
                    }
                    lastReading = DateTime.Now;
                    return recentObservations;
                }
                return new ValueTask<IEnumerable<WeatherData>>(loadCache());
            }
        }

RetrieveHistoricaldata() 메서드는 비동기 메서드가 아님에 유의한다. 만약 결과를 직접 계산해야한다면 비동기 메서드를 호출하여 반환되는 결과를 ValueTask 생성자 매개변수로 전달하여 값 타입의 ValueTask를 반환할 수 있다.

캐쉬를 이용한다면 이미 있는 데이터를 이용하여 ValueTask를 반환한다.

 

참고 : More Effective C# 빌 와그너 지음 - 한빛 미디어