언어/C#

ValueTask / ValueTask<T>

tsyang 2024. 5. 2. 21:12

이게 뭐고 왜 쓰나


 

이름처럼 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를 쓸 것을 권하고 있다.

 

 

이유는 다음과 같다.

 

  1. 환경에 따라 Task.FromResult는 최적화가 되어있다. 즉, 몇개의 값들을 미리 캐싱해두었다가 재사용한다. 이런 경우 ValueTask와 거의 동등한 성능을 기대할 수 있다. 만약 Task<bool>을 리턴하는 경우라면 굳이 ValueTask<bool>을 쓸 필요가 없다.
  2. 작업이 비동기적으로 처리될 때 복사비용이 커질 수 있다 : 참조 타입은 참조 하나만 유지하면 되는 반면에 값 타입은 모든 데이터를 복사해야 한다.
  3. 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/

https://www.sysnet.pe.kr/2/0/13115?pageno=9