[More Effective C#] 3장 - Item 32: 비동기 작업은 태스크 객체를 사용해 구성하라

SY·2023년 10월 31일

More Effective C#

목록 보기
31/46
post-thumbnail

빌 와그너, ⌜More Effective C# 2판⌟, 김완섭 옮김, 한빛미디어, 2019
이 포스팅은 ⌜More Effective C# 2판⌟의 내용을 기반으로 작성되었습니다.

태스크와 컨티뉴에이션에 대한 설명 출처
: https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/chaining-tasks-by-using-continuation-tasks


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


태스크(Task)

  • 다른 리소스(주로 스레드)에 작업을 위임할 수 있도록 추상화한 개념
  • 객체이므로 메서드와 속성을 사용해서 조작할 수 있음
  • 여러 태스크들을 모아서 거대한 태스크로 묶을 수도 있음


컨티뉴에이션 (Continuation)

  • 연속 작업 : Task 선행 작업이 완료되면 이어서 수행되는 작업

  • Task.TaskStatus 값이 WaitingForActivation 상태일때 생성되는 작업

  • 단일 선행 사건에 대한 연속 작업 생성 방법

    • Task.ContinueWith() 메서드를 호출하여 생성
    • 예시 코드
    using System;
    using System.Threading.Tasks;
    
    public class SimpleExample
    {
        public static async Task Main()
        {
            // Declare, assign, and start the antecedent task.
            Task<DayOfWeek> taskA = Task.Run(() => DateTime.Today.DayOfWeek);
    
            // Execute the continuation when the antecedent finishes.
            await taskA.ContinueWith(antecedent => Console.WriteLine($"Today is {antecedent.Result}."));
        }
    }
  • 여러 선행 항목에 대한 연속 작업 생성 방법

    • 여러 작업이 모두 끝났을때 연속 작업을 수행하는 경우

      • Task.WhenAll() 또는 TaskFactory.ContinueWhenAll() 메서드 호출
    • 여러 작업 중 하나라도 끝났을 때 연속 작업을 수행하는 경우

      • Task.WhenAny() 또는 TaskFactory.ContinueWhenAny() 메서드 호출
    • 예시 코드

      using System.Collections.Generic;
       using System;
       using System.Threading.Tasks;
      
       public class WhenAllExample
       {
           public static async Task Main()
           {
               var tasks = new List<Task<int>>();
               for (int ctr = 1; ctr <= 10; ctr++)
               {
                   int baseValue = ctr;
                   tasks.Add(Task.Factory.StartNew(b => (int)b! * (int)b, baseValue));
               }
      
               var results = await Task.WhenAll(tasks);
      
               int sum = 0;
               for (int ctr = 0; ctr <= results.Length - 1; ctr++)
               {
                   var result = results[ctr];
                   Console.Write($"{result} {((ctr == results.Length - 1) ? "=" : "+")} ");
                   sum += result;
               }
      
               Console.WriteLine(sum);
           }
       }
       // The example displays the similar output:
       //    1 + 4 + 9 + 16 + 25 + 36 + 49 + 64 + 81 + 100 = 385
      



Task.WhenAny()는 호출할때마다 새로운 태스크를 생성하기 때문에 관리해야할 태스크가 늘어날 수록 더 많은 자원을 필요로해 효율이 급격히 떨어진다.

대안으로 TaskCompletionSource 클래스를 생각할 수 있다.


TaskCompletionSource

  • 결과를 생성하기 위해 나중에 조작할 태스크 객체를 반환할 수 있게 해준다.
  • 모든 메서드의 결과를 비동기적으로 생성할 수 있는 것
  • 출발지 Task가 완료되기를 기다린 후 TaskCompletionSource를 이용해 목적지 Task를 갱신한다.
  • 예시 코드
public static Task<T>[] OrderByCompletion<T>(this IEnumerable<Task<T>> tasks)
{
	// 여러 번 열거되기 때문에 리스트에 복사한다.
	var sourceTasks = tasks.ToList();
    
    // 출발지 태스크와 목적지 태스크를 할당한다.
    // 각 목적지 태스크는 각 TaskCompletionSource와 연결된다.
    var completionSources = new TaskCompletionSource<T>[sourceTasks.Count];
    var outputTasks = new Task<T>[completionSources.Length];
    for(int i = 0; i < completionSources.Length; i++)
    {
    	completionSources[i] = new TaskCompletionSource<T>();
        outputTasks[i] = completionSources[i].Task;
    }
    
    
    // 첫 번째 마법 
    // 각 태스크가 가지고 있는 컨티뉴에이션은 
    // 자신의 결과를 completionSources 배열의 다음 위치에 저장한다.
    int nextTaskindex = -1;
    Action<Task<T>> continuation = completed =>
    {
    	var bucket = completionSources[Interlocked.Increment(ref nextTaskIndex)];
        if(completed.IsCompleted)
        	bucket.TrySetResult(completed.Result);
        else if(completed.IsFaulted)
        	bucket.TrySetException(completed.Exception);
    };
    
    // 두 번째 마법    
    // 각 입력 태스크에 컨티뉴에이션을 설정하여 결과 태스크와 연결한다.
    // 각 태스크가 끝나면 다음 빈 공간을 사용한다.
    foreach (var inputTask in sourceTasks)
    {
    	inputTask.ContinueWith(continuation,
        	CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
    }
    return outputTask;
}

태스크 10개를 실행하는 예라고 했을때, 태스크 3, 7, 2, 0 ... 순서로 끝난다고 가정해보자.
태스크 3이 끝나고 컨티뉴에이션이 실행되어 Task 결과를 목적지 배열의 슬롯 0에 배치한다.
그 다음 태스크 7이 끝나면 목적지 슬롯 1에 배치한다.
태스크 완료 끝까지 반복한다.


Task 라이브러리에서 찾을 수 있는 이런 확장 기능을 이용하면 태스크가 끝난 시점에 취할 수 있는 액션을 지정할 수 있다.
또한 매우 비효율적인 코드를 가독성 높은 코드로 수정할 수 있다.

profile
게임 개발 공부

0개의 댓글