ValueTask / ValueTask<T>
이게 뭐고 왜 쓰나
이름처럼 ValueTask는 값(Value) 타입의 Task객체이다. 그렇다 Task는 참조 타입이다. 따라서 Task객체를 생성하는 것은 곧 힙 할당이 일어난다는 말이다.
그런데 Task들 중에서는 작업이 동기적으로 완료될 수 있는 애들이 있다. 즉 한 Task를 호출한 쓰레드가 그대로 이어서 다음 코드를 실행하는 것이다.
private async Task<int> GetData(int index)
{
if (_cachedData.TryGetValue(index, out var data))
return data;
return await CalcSomethingLong(index);
}
이를태면 위와 같은 메서드는 그 값이 이미 계산된 적이 있다면 즉시 값을 리턴한다. 이 경우 위 Task는 동기적으로 처리될수 있다.
그럼에도 불구하고 여기서 Task는 참조 타입이기 때문에 항상 힙 할당이 일어난다. 따라서 동기적으로 작업이 완료되는 경우 값 타입인 ValueTask<int>를 사용하면 힙 할당을 피할 수 있다.
private async ValueTask<int> GetData(int index)
{
//...동일
}
그렇다면 비동기적으로 처리되는 경우에는 어떨까? 단순히 ValueTask를 Task로 감싸(wrap)리턴하면 그만이다. (컴파일러가 알아서 한다.)
그럼 ValueTask가 상위호환인가?
그렇지 않다. 여전히 많은 아티클이나 책에서 기본적으로 Task를 쓰되, 제한적으로 성능 측정을 해보고 ValueTask를 쓸 것을 권하고 있다.
이유는 다음과 같다.
- 환경에 따라 Task.FromResult는 최적화가 되어있다. 즉, 몇개의 값들을 미리 캐싱해두었다가 재사용한다. 이런 경우 ValueTask와 거의 동등한 성능을 기대할 수 있다. 만약 Task<bool>을 리턴하는 경우라면 굳이 ValueTask<bool>을 쓸 필요가 없다.
- 작업이 비동기적으로 처리될 때 복사비용이 커질 수 있다 : 참조 타입은 참조 하나만 유지하면 되는 반면에 값 타입은 모든 데이터를 복사해야 한다.
- ValueTask는 제약이 많다. (아래서 알아본다.)
ValueTask의 제약
ValueTask는 다음 3가지의 제약이 있다.
1. 여러 번 대기(await)하면 안된다.
여러 번 소비(consume)하면 안 된다고도 표현한다.
//NG
ValueTask<int> vt = SomeValueTask();
int result = await vt;
int result2 = await vt;
첫 번째 await이후에 ValueTask객체는 이미 다른 동작에 의해 재활용되고 있을 수 있다. Task의 경우 complete상태에서 incomplete상태로 전환되지 않으므로 여러 번 대기해도 상관없다.
2. 동시에 ValueTask를 대기하면 안된다.
//NG
ValueTask<int> vt = SomeValueTask();
Task.Run(async () => await vt);
Task.Run(async () => await vt);
ValueTask는 한 번에 단일 이용자(consumer)가 한번 사용할 것으로 기대하고 설계되었다. 만약 동시에 ValueTask를 await하면 race condition에 빠질 수 있다.
3. 작업이 완료되기 전에 .GetWaiter().GetResult()를 호출하면 안된다.
//NG
ValueTask<int> vt = SomeValueTask();
int result = vt.GetAwaiter().GetResult();
Task에서는 동기적으로 결과를 구하면 작업을 완료할 때 까지 호출한 스레드를 차단한다. 반면에 ValueTask는 그런 보장을 하지 않는다.
4. Task.WhenAny나 Task.WhenAll과 함께 사용할 수 없음
ValueTask 올바르게 쓰기
//OK
int result = await SomeValueTask();
//OK
int result = await SomeValueTask().ConfigureAwait(false);
//OK
Task<int> t = SomeValueTask().AsTask();
당연하겠지만 위와 같이 사용하는 것은 OK이다.
//HMM...
ValueTask<int> vt = SomeValueTask();
int result = await vt;
위와 같은 경우는 미묘하다. 만약 위 코드 이후로 vt를 더 이상 쓰지 않는다면 OK이다. 그러나 실수로 vt를 다시 사용하는 경우 위에서 언급한 제약에 해당될 가능성이 있다.
ValueTask<int> vt = SomeValueTask();
int result;
if (vt.IsCompletedSuccessfully)
result = vt.Result;
else
result = await vt;
위와 같이 <제약3 : 작업이 완료되기 전에 .GetWaiter().GetResult()를 호출하면 안 된다.>를 우회할 수 있다. 그러나 위에서 언급한 것 처럼 vt에 더 이상 접근하면 위험하다.
Task.WhenAny, WhenAll 이런 것과 같이 쓰려면 AsTask()메서드를 이용해 ValueTask를 Task로 변환해서 써야한다.
ValueTask<int> vt1 = SomeValueTask();
ValueTask<int> vt2 = SomeValueTask();
Task<int> rt1 = vt1.AsTask();
Task<int> rt2 = vt2.AsTask();
Task.WaitAll(rt1, rt2);
여러 번 대기할 필요가 있는 경우에도 AsTask를 활용할 수 있다.
//OK
ValueTask<int> vt = SomeValueTask();
Task<int> rt = vt.AsTask();
int result1 = await rt;
int result2 = await rt;
다만 위에서 언급한 것 처럼 vt, vt1, vt2등에는 더 이상 접근하면 위험할 수 있다.
요약
ValueTask가 뭔지 알아보기 귀찮으면 그냥 Task쓰는 게 낫다.
참고
스티븐 클리어리, C# 동시성 프로그래밍 2/e , O'REILLY
https://www.infoworld.com/article/3565433/how-to-use-valuetask-in-csharp.html
https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/