Henu
개발냥발
Henu
전체 방문자
오늘
어제
  • 분류 전체보기 (411)
    • DevOps (52)
      • Kubernetes (19)
      • Docker (14)
      • AWS (3)
      • Nginx (4)
      • Linux (4)
      • ArgoCD (1)
      • CN (2)
      • NATS (0)
      • Git (5)
    • Back-End (30)
      • Django (18)
      • Spring (5)
      • JPA (1)
      • MSA (5)
    • CS (87)
      • SystemSoftware (20)
      • OS (25)
      • Computer Architecture (16)
      • Network (23)
      • Database (2)
    • Lang (21)
      • Java (9)
      • Python (4)
      • C# (8)
    • Life (12)
    • 블록체인 (2)
    • Algorithm (204)
      • BOJ (160)
      • 프로그래머스 (19)
      • LeetCode (4)
      • SWEA (1)
      • 알고리즘 문제 해결 전략 (8)
      • DS, algorithms (7)
      • Checkio (5)
    • IT (2)

블로그 메뉴

  • GitHub
  • 글쓰기
  • 관리자

공지사항

  • Free!

인기 글

태그

  • Kubernetes
  • docker
  • DFS
  • BFS
  • django
  • boj
  • 백트래킹
  • 다이나믹 프로그래밍
  • 프로그래머스
  • Network

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
Henu

개발냥발

Lang/C#

[More Effective C#] Chapter 3. 요약 (2)

2024. 4. 7. 22:50

[ Item 31 : 불필요한 콘텍스트 마샬링을 피하라 ]

현재 아이템은 SynchronizationContext와 관련된 성능 문제를 다룬다.
특히, 멀티스레딩 환경에서 특정 Context로의 데이터 전달이나 메서드 호출이 필요할 때 발생하는
마샬링 오버헤드를 최소화하는 방법을 집중해서 다룬다.

 

‘자유 코드’

  • 어떤 context에서도 실행될 수 있는 코드
  • 우리가 작성하는 대부분의 코드는 자유 코드이다.

‘Context 인식 코드’

  • 특정 SynchronizationContext 에서만 실행될 수 있는 코드
  • GUI 애플리케이션에서 UI 컨트롤과 상호작용하는 코드
  • 웹 애플리케이션에서 HTTPContext 등의 클래스와 상호작용하는 코드

 

콘텍스트 마샬링이란?

하나의 스레드에서 다른 스레드로 데이터를 전달하거나 메서드 호출을 위임할 때 사용되는 프로세스이다.

특히 UI 작업과 같이 특정 스레드에서만 수행되어야 하는 작업을 다른 스레드에서 요청할 때 중요하다.

 

캡처된 콘텍스트
- await 코드를 실행시키는 시점의 콘텍스트

컨티뉴에이션
- await 이후 진행될 코드

 

 

코드가 수행될 때 매번 캡처된 콘텍스트에서 컨티뉴에이션이 수행된다고 해도 항상 큰 문제를 일으키지는 않는다.

하지만 이렇게 캡처된 콘텍스트로 매번 컨티뉴에시션을 수행하는 방식이 누적되면 복합적인 문제를 일으킬 수 있다.

컨티뉴에이션을 늘 캡처된 콘텍스트에서 실행하기 때문에, 컨티뉴에이션을 다른 스레드에 위임할 기회가 사라진다.

 

그래서 GUI 애플리케이션에서는 UI 가 느려질 수 있고, 웹 애플리케이션에서는 시간당 처리량이 줄어들 수 있다.

즉, GUI 애플리케이션에서는 데드락이 발생할 가능성이 커지고 웹에서는 스레드 풀을 온전히 활용할 수 없게 되는 것이다.

 

 

해결 방안

  • ConfigureAwait() 사용하기 : await 키워드를 사용할 때 ConfigureAwait(false)를 사용하면, 비동기 작업이 완료된 후 원래의 SynchronizationContext로 돌아가지 않도록 할 수 있다.
  • 백그라운드 작업 분리 : 계산 작업이나 네트워크 요청과 같은 백그라운드 작업은 가능한 한 UI 스레드와 분리하여 실행한다.
public async Task<Config> ReadConfig(string uri)
{
    var result = await DownloadAsync(uri).ConfigureAwait(false); // 1.
    var items = XElement.Parse(result);
    
    // 콘텍스트 자유 코드
    // ...

    if (configUrl != null)
    {
        result = await DownloadAsync(uri).ConfigureAwait(false); // 2.
        var config = await ParseConfigAsync(result).ConfigureAwait(false); // 3.

        return config;
    }

    return new Config();
}

 

첫 번째 await에 도달하면 컨티뉴에이션이 기본 콘텍스트에서 실행될 것이라 생각하여, 뒤에 이어지는 비동기 호출에서는 ConfigureAwait()를 다시 사용하지 않아도 될 것이라 생각할 수 있다.

 

하지만 이건 잘못된 생각이다. 만약 첫 번째 태스크가 위 메서드처럼 동기식으로 완료된다면 어떻게 될까?

동기식으로 이전 메서드가 종료된다면 이후의 코드도 동기 메서드가 시작될 때 캡쳐된 콘텍스트에서 동기적으로 재개될 것이다.

이 경우엔 다음 await 작업도 여전히 캡처된 콘텍스트에서 실행될 것이다.

 

즉, 이후의 비동기 호출도 기본 컨텍스트에서 수행되지 못하고, 캡처된 콘텍스트에서 수행될 수 있는 상황이 있다.

따라서 이런 이유 때문에 컨티뉴에이션이 콘텍스트 자유 코드라면 항상 ConfigureAwait(false)를 사용하여 기본 콘텍스트에서 수행되도록 해야 한다.

 

 


[ Item 32 : 비동기 작업은 태스크 객체를 사용해 구성하라 ]

태스크는 다른 리소스(주로 스레드)에 작업을 위임할 수 있도록 추상화한 개념이다.
Task 타입은 객체이므로 메서드와 프로퍼티를 사용해서 조작할 수 있고, 여러 태스크를 모아서 거대한 태스크로 묶을 수도 있다.
그리고 이렇게 묶인 태스크를 병렬 또는 순서대로 실행할 수도 있다.

 

Task.WhenAll()

public async Task<IEnumerable<string>> ReadStockNameAsync(IEnumerable<string> symbols)
{
    var resultTasks = new List<Task<string>>();

    foreach (var symbol in symbols)
    {
        resultTasks.Add(ReadSymbolAsync(symbol));
    }

    // 모든 Task 동시 수행
    var results = await Task.WhenAll(resultTasks);
    return results;
}

 

WhenAll() 을 사용하면 독립적인 태스크들을 동시에 시작시킬 수 있다.

시작된 태스크가 모드 완료될 때까지 호출 스레드가 블로킹된다.

Task.WhenAll()은 완료된 (실패 태스크 포함) 모든 태스크를 배열에 저장해 반환한다.

 

Task.WhenAny()

public async Task<string> ReadStockNameOneAsync(IEnumerable<string> symbols)
{
    var resultTasks = new List<Task<string>>();

    foreach (var symbol in symbols)
    {
        resultTasks.Add(ReadSymbolAsync(symbol));
    }

    var whenAny = await Task.WhenAny(resultTasks);
    return await whenAny;
}

 

WhenAny() 도 WhenAll()과 동일하게 태스크를 동시에 시작시킬 수 있고 호출 스레드가 블로킹 된다.

다른 점은 여러 태스크들 중 가장 먼저 완료되는 태스크가 생긴하면 호출 스레드의 블로킹이 해제되고 해당 태스크만 반환된다는 점이다.

 

WhenAll 과 다르게 WhenAny 는 Task가 한번 더 감싸져 있기 때문에 값을 가져올 때 한번 더 await를 사용해야 한다.

 

 

TaskCompletionSource

Task 객체를 명시적으로 만들지 않으면서 비동기 메서드를 구현한 것처럼 만들 수도 있다.

주로 출발지 태스크들과 목적지 태스크들 사이에 길을 연결하는 데 쓰인다.

public class UserService
{
    private TaskCompletionSource<bool> _tcs;

    public Task<bool> WaitForUserConfirmation()
    {
        _tcs = new TaskCompletionSource<bool>();
        return _tcs.Task;
    }

    public void OnUserActionCompleted(bool isConfirmed)
    {
        _tcs.SetResult(isConfirmed);
    }
}

public class Application
{
    private readonly UserService _userService;
    
    public async Task Run()
    {
        Task<bool> confirmationTask = _userService.WaitForUserConfirmationAsync();

        // 사용자의 확인을 기다린다.
        bool confirmed = await confirmationTask;

        if (confirmed)
        {
            Console.WriteLine("사용자 확인됨.");
        }
        else
        {
            Console.WriteLine("사용자가 확인하지 않음.");
        }
    }

    public void OnUserAction(bool isConfirmed)
    {
        // 유저가 특정 버튼을 누르는 등의 확인 액션이 들어왔을 때 수행한다.
        _userService.OnUserActionCompleted(isConfirmed);
    }
}

 

Run() 메서드

  1. 사용자의 확인을 기다리기 시작한다.
  2. 사용자의 확인 여부에 따라 작업을 수행한다.
  3. 사용자가 작업을 완료하면 OnUserAction()이 호출되어 대기 중인 태스크에 결과를 제공한다.

 

TaskCompletionSource<T>를 사용하면 태스크 완료 시점과 방법을 제어할 수 있다.

 

태스크를 완료시키는 방식은 3가지가 있는데( SetResult , SetCanceled, SetException )

이를 사용해서 반드시 태스크를 완료 시켜주도록 하자.

그렇지 않으면 태스크를 기다리는 스레드가 무기한 블로킹 되기 때문이다.

 

 


[ Item 33 : 태스크 취소 프로토콜 구현을 고려하라 ]

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

이 API는 필수는 아니지만, 비동기 작업이 진행 상황을 효과적으로 보고하거나 작업을 취소할 수 있으려면 올바르게 구현돼야 한다.

 

복잡한 처리를 위해 연속된 5개의 웹 요청을 서로 다른 서비스에 각기 전달하는 상황을 살펴보자.

태스크 취소 프로토콜이 적용되기에 적절한 ‘급여 지급 프로그램’을 예시로 들어보았다.

  1. 임직원 목록과 임직원별 근무 시간을 알려주는 웹 서비스를 호출한다.
  2. 세금을 계산해 신고하는 웹 서비스를 호출한다.
  3. 급여명세서를 생성해서 임직원에게 이메일로 전송하는 웹 서비스를 호출한다.
  4. 급여를 송금하는 웹 서비스를 호출한다.
  5. 급여 지급을 마감하는 웹 서비스를 호출한다.

요구사항

  1. 이 5개의 서비스가 20% 씩의 작업을 수행한다고 가정하고, 각 단계가 끝날 때마다 프로그램의 진행 상황을 보고하기 위해 보고용 작업을 구현할 수 있다.
  2. 취소 API를 구현하여 4번째 과정(급여 송금)이 시작되기 전이라면 급여 처리를 취소할 수 있다. 하지만 돈이 송금된 이후에는 더 이상 취소가 불가능하다.

 

CancellationTokenSource

CancellationTokenSource는 취소 요청을 발생시킬 수 있는 메커니즘을 제공하고,

CancellationToken은 취소 요청을 받아들이는 방법을 제공한다.

 

CancellationTokenSource 를 사용하는 메커니즘

  • CancellationTokenSource 생성 : 취소 가능한 태스크를 시작하기 전에 CancellationTokenSource 객체를 생성한다.
  • CancellationToken 전달 : 생성된 CancellationToken 객체를 비동기 메서드에 전달하여, 해당 메서드 내에서 취소 요청을 확인할 수 있도록 한다.
  • 취소 요청 감지 : 비동기 메서드 내에서는 CancellationToken의 ThrowIfCancellationRequested 메서드를 주기적으로 호출하여 취소 요청이 있는지 확인한다. 취소 요청이 감지되면 OperationCanceledException이 발생하여 태스크가 취소된다.
  • 취소 요청 처리 : 비동기 메서드는 취소 요청을 적절히 처리하여 리소스를 정리하고, 필요한 경우 사용자에게 취소되었음을 알린다.

 

CancellationTokenSource의 장점

  • 비동기 및 동기 작업의 개수에 제한없이 동일한 취소 토큰을 전달할 수 있다.
  • 취소를 요청할 수 있는지 여부 및 적용되는 시기 등을 완전히 제어할 수 있다.
  • API를 사용하는 코드는 취소 요청이 전파되는 비동기 호출을 선택적으로 결정할 수 있다.
public async Task RunPayRollAsync(CancellationToken ct, IProgress<int> progress)
{
    progress?.Report(0);
    
    // Step 1: 근무 시간과 급여 계산
    ...
    ct.ThrowIfCancellationRequested();
    progress?.Report(20);
    
    // Step 2: 세금 계산 및 신고
    ...
    ct.ThrowIfCancellationRequested();
    progress?.Report(40);
    
    // Step 3: 급여명세서 생성 및 이메일 전송
    ...
    ct.ThrowIfCancellationRequested();
    progress?.Report(60);
    
    // Step 4: 급여 송금
    ...
    progress?.Report(80);
    
    // Step 5: 급여 지급 기간 마감
    ...
    progress?.Report(100);
    ct.ThrowIfCancellationRequested();
}

 

RunPayRollAsync 메서드 내부에는 CancellationToken 에 취소 요청이 들어온 경우 예외를 발생시키는 코드가 적절한 위치에 작성되어있다.

 

// 호출자 코드
var cts = new CancellationTokenSource();
var progress = new Progress<int>();

await RunPayRollAsync(cts.Token, progress);

if (cancelRequested)
{
    cts.Cancel();
}

 

호출자는 CancellationTokenSource를 사용해서 취소를 요청한다.

TaskCompletionSource 처럼 이 클래스는 취소를 요청하는 코드와 취소할 작업이 있는 코드 사이를 중개한다.

 

 


[ Item 34 : 비동기 메서드의 반환값을 캐시하는 경우 ValueTask<T>를 사용하라 ]

일반적인 비동기 코드의 반환 타입은 Task 또는 Task<T> 이다.

하지만 때에 따라서 Task 타입 때문에 성능이 떨어지기도 한다.

 

예를 들어 비동기 호출을 빽빽한 for 루프나 자주 호출되는 코드에서 사용하는 경우가 있는데,

비동기 메서드용으로 Task 인스턴스를 생성하고 사용하는 비용이 부담될 수 있기 때문이다.

 

그래서 .NET은 ValueTask<T> 라는 새로운 타입을 제공하는데 이는 기존 Task 보다 효율이 더 높다.

이 타입은 값 타입이어서 추가로 메모리를 할당할 필요가 없다(회수 비용을 줄여준다).

그래서 ValueTask<T> 타입은 비동기 메서드의 결과를 캐싱해놓고 나중에 사용할 경우 최고의 효율을 보여준다.

 

public class CacheExample
{
    private Dictionary<string, string> cache = new Dictionary<string, string>();

    public CacheExample()
    {
        // 캐시 초기화 예시
        cache["key1"] = "value1";
        cache["key2"] = "value2";
    }

    // 캐시에서 데이터를 가져오는 메서드
    public ValueTask<string> GetDataAsync(string key)
    {
        // 캐시에 데이터가 존재하는 경우, 즉시 ValueTask로 결과를 반환
        if (cache.ContainsKey(key))
        {
            return new ValueTask<string>(cache[key]);
        }
        // 캐시에 데이터가 없는 경우, 비동기 작업으로 데이터를 로드
        else
        {
            return new ValueTask<string>(LoadDataAsync(key));
        }
    }

    // 데이터를 비동기적으로 로드하는 메서드
    private async Task<string> LoadDataAsync(string key)
    {
        await Task.Delay(100); // 데이터 로드를 위한 대기
        string loadedData = $"loaded_{key}";
        cache[key] = loadedData; // 로드된 데이터를 캐시에 추가
        return loadedData;
    }
}

class Program
{
    static async Task Main(string[] args)
    {
        var cacheExample = new CacheExample();

        // 캐시에 존재하는 데이터 요청
        string value1 = await cacheExample.GetDataAsync("key1");
        Console.WriteLine(value1); // "value1"

        // 캐시에 존재하지 않는 데이터 요청, 데이터 로드 과정 거침
        string value2 = await cacheExample.GetDataAsync("key3");
        Console.WriteLine(value2); // "loaded_key3"
    }
}

 

  1. GetDataAsync 메서드는 비동기 메서드가 아니고, 대신 ValueTask를 반환한다.
  2. ValueTask는 Task를 인수로 받는 생성자를 제공하고 이를 통해 await 작업을 내부적으로 처리한다.

ValueTask 타입은 태스크 객체를 생성하기 위한 작업이 성능의 병목으로 밝혀졌을 때 사용할 수 있는 최적화 수단이다.

대부분의 비동기 메서드에는 여전히 Task 타입이 가장 적절하고, 메모리 할당이 성능의 병목이 되지 않는다면 Task와 Task<T>를 사용하는 것이 좋다.

'Lang > C#' 카테고리의 다른 글

[More Effective C#] Chapter 4. 요약 (2)  (2) 2024.04.28
[More Effective C#] Chapter 4. 요약 (1)  (1) 2024.04.21
[More Effective C#] Chapter 3. 요약 (1)  (0) 2024.03.31
[More Effective C#] Chapter 2. 요약 (2)  (0) 2024.03.24
[More Effective C#] Chapter 2. 요약 (1)  (4) 2024.03.17
    'Lang/C#' 카테고리의 다른 글
    • [More Effective C#] Chapter 4. 요약 (2)
    • [More Effective C#] Chapter 4. 요약 (1)
    • [More Effective C#] Chapter 3. 요약 (1)
    • [More Effective C#] Chapter 2. 요약 (2)

    티스토리툴바