.NET Framework 4에서 도입된 Task와 Task<TResult>는 async / await 키워드와 함께 비동기 프로그래밍 모델의 핵심 요소다.
클래스로서의 Task는 매우 유연하며 그에 따른 이점이 있다. 반환된 Task 객체를 동시에, 또는 여러 번 await할 수도 있고, 따로 저장해두어 비동기 결과의 캐시로 사용할 수도 있다. 또한 WhenAny(), WhenAll()과 같이 Task에 대한 다양한 연산을 사용할 수 있다.
하지만 가장 일반적인 경우는 단순히 비동기 작업을 호출하고 그 결과를 await하는 경우이다. 이러한 경우에는 위 같은 유연성이 필요하지 않다.
더 나아가서, Task는 인스턴스가 많이 생성되거나 성능이 중요한 시나리오에서는 잠재적인 단점을 가지고 있다.
Task가 클래스이기 때문에 작업을 수행할 때마다 객체를 할당해야 해서 가비지 컬렉터가 해야 할 일이 많아져서 다른 곳에 쓸 수 있는 리소스를 그곳에 소비하게 된다.
.NET 런타임과 코어 라이브러리는 많은 상황에서 이를 완화한다. 아래와 같이 Task를 반환하는 비동기 메서드 중에 자주 호출되지만, 동기적으로 끝나는 경우가 많은 케이스를 살펴보자.
public async Task WriteAsync(byte value) {
if (_bufferedCount == _buffer.Length) {
await FlushAsync();
}
_buffer[_bufferedCount++] = value;
}
위의 메서드는 반환 값이 단일 Task이기 때문에 await 분기를 타지 않고 동기적으로 바로 끝나면, .NET 런타임은 최적화를 위해 Task.CompletedTask 라는 싱글톤 객체를 캐시해두고 반환한다.
작업이 비동기로 완료될 때는 꼭 새로운 Task를 할당해야 하는데, 이는 작업 결과를 알기 전에 호출자에게 객체를 반환해야 하고, 작업이 완료되었을 때 결과를 저장할 고유한 객체가 필요하기 때문이다.
동일한 케이스이지만 반환 값이 Task<bool> 이라면 bool 형은 true와 false만 나타낼 수 있기 때문에 .NET 런타임은 2개의 Task<bool> 객체를 캐시하여 재사용할 수 있다.
하지만 Task<int>의 경우는 어떨까? bool 케이스와 다르게 약 40억 개의 경우의 수를 가진다.
모든 경우에 대해 Task<int>를 캐시하면 수백 기가 바이트의 메모리를 소모할 수 있기 때문에 .NET 런타임은 Task<int>에 대해 일부 작은 캐시만 유지할 수 있다.
public override ValueTask<int> ReadAsync(byte[] buffer, int offset, int count) {
try {
int bytesRead = Read(buffer, offset, count);
return new ValueTask<int>(bytesRead);
} catch (Exception e) {
return new ValueTask<int>(Task.FromException<int>(e));
}
}
ValueTask<TResult>는 .NET Core 2.0에서 도입된 구조체로, TResult 또는 Task<TResult> 중 하나를 감쌀 수 있다.
즉, 비동기 메서드에서 반환할 때 해당 메서드가 동기적으로 완료되었다면 아무 것도 할당할 필요가 없고 단순히 TResult를 ValueTask 구조체에 넣어 초기화하면 되기 때문이다.
메서드가 비동기로 완료될 때는 Task를 할당해야 하며, ValueTask는 그 인스턴스를 감싸게 된다.
처리되지 않은 예외로 실패한 경우에도 필드로 해당 예외를 따로 저장하지 않고 Task를 할당하여 단순히 감싸도록 한다. 크기를 최소화하고 성공 경로를 최적화하기 위함이다.
Task를 할당해야 하는 이유는 await 모델에서는 비동기로 완료되는 모든 작업에 대해 객체를 반환해야 하고, 호출자는 작업이 완료될 때 호출될 콜백(컨티뉴에이션) 을 넘겨줄 수 있어야 하는데 이 작업을 위해서는 매개체 역할을 하는 heap 상의 고유한 객체가 필요하기 때문이다.
public interface IValueTaskSource<out TResult>{
ValueTaskSourceStatus GetStatus(short token);
void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags);
TResult GetResult(short token);
}
.NET Core 2.1에서는 pooling 및 재사용 지원을 위해 새로운 인터페이스인 IValueTaskSource가 도입되었으며, ValueTask는 Task와 IValueTaskSource 중에 하나를 선택해서 감쌀 수 있게 되었다.
Task 대신 IValueTaskSource 를 사용하면, 메서드가 비동기로 완료될 때마다 Task를 할당할 필요 없고 IValueTaskSource 객체를 재사용한다. 하지만 이 인터페이스는 대부분의 개발자는 볼 필요 없고 주로 성능 중심의 API 개발자가 할당을 피할 수 있도록 하기 위해 존재한다.
ValueTask는 이처럼 재사용 가능한 객체를 감쌀 수 있으므로, Task와 비교했을 때 사용하는 데에 있어서 몇 가지 제약이 따른다.
1. ValueTask를 여러 번 await하는 것은 기본 객체가 이미 재활용되어 다른 작업에서 사용 중일 수 있어서 수행해서는 안 된다.
반면 Task는 완료 상태에서 미완료 상태로 전환되지 않으므로 필요한 만큼 여러 번 await 해도 항상 같은 답을 얻는다.
2. ValueTask를 동시에 await하는 것은 race condition에 놓여 에러가 발생하기 쉽다. 기본 객체는 한 번에 하나의 사용처로부터 하나의 콜백만 처리하도록 예상한다.
3. 작업이 완료되지 않았는데 .GetAwaiter().GetResult()를 사용하는 것도 본질적으로 race condition이며, 작업이 완료될 때까지 block하는 것을 지원하지 않기 때문에 호출자의 의도대로 작동하지 않을 가능성이 높다.
반면 Task는 이를 가능하게 하며 작업이 완료될 때까지 호출자를 차단한다.
ValueTask로 위와 같은 작업을 하려면.AsTask()를 사용하여 얻은 Task 객체에 대해 작업해야 하며, 그 시점 이후로는 해당 ValueTask와 다시 상호작용 해서는 안 된다.
// ValueTask<int>를 반환하는 메서드가 있다고 가정할 때...
public ValueTask<int> SomeValueTaskReturningMethodAsync();
…
// 좋음 (GOOD)
int result = await SomeValueTaskReturningMethodAsync();
// 좋음 (GOOD)
int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false);
// 좋음 (GOOD)
Task<int> t = SomeValueTaskReturningMethodAsync().AsTask();
// 경고 (WARNING)
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
... // 인스턴스를 로컬 변수에 저장하면 오용될 가능성이 훨씬 높아지지만,
// 그래도 괜찮을 수는 있음
// 나쁨 (BAD): 여러 번 await 함
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result = await vt;
int result2 = await vt;
// 나쁨 (BAD): 동시에 await 함 (정의상 여러 번 await 하는 것임)
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
Task.Run(async () => await vt);
Task.Run(async () => await vt);
// 나쁨 (BAD): 완료된 것을 모르는 상태에서 GetAwaiter().GetResult() 사용
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result = vt.GetAwaiter().GetResult();
모든 새로운 비동기 API가 ValueTask를 반환할 필요는 없다. 기본 선택은 여전히 Task이다.
우선 ValueTask는 Task보다 올바르게 사용하기 어렵기 때문에 성능상의 이점이 사용성 면에서의 이점보다 크지 않다면 Task를 선택하는 것이 낫다.
또한 Task를 await하는 것이 ValueTask를 await하는 것보다 약간 더 빠르다. 따라서 캐시된 Task를 사용할 수 있는 상황이라면 성능 면에서도 Task를 선택하는 것이 더 나을 수 있다. (Task나 Task<bool> 반환 경우)
하지만 API 호출자가 오직 직접 await만 할 것으로 예상되며 할당 관련 오버헤드를 피하는 것이 중요한 경우나 동기적 완료가 매우 빈번한 경우에는 ValueTask를 사용하는 것이 훌륭한 선택이 될 것이다.
Stephen Toub, Understanding the Whys, Whats, and Whens of ValueTask 글을 읽고 정리한 글입니다.
검색을 허용하지 않고, 수익을 창출하지 않습니다.
'Programming Language > C#' 카테고리의 다른 글
| [Unity/C#] UniTask 이해하기 Task, Coroutine과의 차이 (0) | 2026.01.31 |
|---|---|
| [C#] CancellationToken / CancellationTokenSource 작성 및 내부 구현 (1) | 2026.01.11 |
| [C#] 비동기 코드 async / await / Task 작성 및 내부 구현 (0) | 2025.12.29 |
| [C#] DateTime 구조체 파헤치기 (0) | 2025.01.30 |
| [C#] Stopwatch 클래스 이해하기 (0) | 2025.01.30 |