[ 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() 메서드
- 사용자의 확인을 기다리기 시작한다.
- 사용자의 확인 여부에 따라 작업을 수행한다.
- 사용자가 작업을 완료하면 OnUserAction()이 호출되어 대기 중인 태스크에 결과를 제공한다.
TaskCompletionSource<T>를 사용하면 태스크 완료 시점과 방법을 제어할 수 있다.
태스크를 완료시키는 방식은 3가지가 있는데( SetResult , SetCanceled, SetException )
이를 사용해서 반드시 태스크를 완료 시켜주도록 하자.
그렇지 않으면 태스크를 기다리는 스레드가 무기한 블로킹 되기 때문이다.
[ Item 33 : 태스크 취소 프로토콜 구현을 고려하라 ]
태스크 기반 비동기 프로그래밍 모델(TAP)은 진행을 취소하거나 보고하기 위한 표준 API를 제공한다.
이 API는 필수는 아니지만, 비동기 작업이 진행 상황을 효과적으로 보고하거나 작업을 취소할 수 있으려면 올바르게 구현돼야 한다.
복잡한 처리를 위해 연속된 5개의 웹 요청을 서로 다른 서비스에 각기 전달하는 상황을 살펴보자.
태스크 취소 프로토콜이 적용되기에 적절한 ‘급여 지급 프로그램’을 예시로 들어보았다.
- 임직원 목록과 임직원별 근무 시간을 알려주는 웹 서비스를 호출한다.
- 세금을 계산해 신고하는 웹 서비스를 호출한다.
- 급여명세서를 생성해서 임직원에게 이메일로 전송하는 웹 서비스를 호출한다.
- 급여를 송금하는 웹 서비스를 호출한다.
- 급여 지급을 마감하는 웹 서비스를 호출한다.
요구사항
- 이 5개의 서비스가 20% 씩의 작업을 수행한다고 가정하고, 각 단계가 끝날 때마다 프로그램의 진행 상황을 보고하기 위해 보고용 작업을 구현할 수 있다.
- 취소 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"
}
}
- GetDataAsync 메서드는 비동기 메서드가 아니고, 대신 ValueTask를 반환한다.
- 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 |