항상 ValueTask를 반환하는게 좋지 않나요?

마수리·2024년 10월 25일
0

ValueTask

목록 보기
1/1

안녕하세요. 마수리입니다.

오늘은 많은 사람들이 헷갈려 하는 ValueTask의 오해에 대해서 이야기 해보는 시간을 가져볼까합니다.

ValueTaskTask가 뭔지는 잘 모르시더라도 ValueTask, Task참조 그러면.. 스택에 할당되는 ValueTask가 더 오버헤드가 적어서 좋은거 아니야?

그럼, 모든 Task를 반환하는 객체를 ValueTask를 반환하게 바꾸면 성능이 향상되겠다!

오늘은 이런 질문에 대한 답을 드리도록 하겠습니다.

내부적으로 살펴보면 무조건 ValueTask를 사용하는 것은 절대 좋은 선택이 아닙니다.

ValueTask를 반환하는 함수라 하더라도 실제로 await 같은 비동기 호출이 포함된 경우 완료를 기다리는 Task를 포함한 구조체를 반환하게 됩니다. 그러니까 ValueTask로 반환한다고 해도 어차피 Task를 한번 감싼 ValueTask가 반환되어서 아무런 이득을 취할 수가 없을 뿐더러 밑에서 보시겠지만 더 많은 오버헤드를 일으킵니다.

우선 가장 중심이 되는 ValueTask가 어떤 데이터를 갖고 있는지 확인해보도록 하겠습니다.

{
  private static volatile Task? s_canceledTask;
  // 기다릴 필요가 있다면 여기에 Task나 IValueTaskSource가 들어갑니다.
  internal readonly object? _obj; 
  internal readonly short _token;
  internal readonly bool _continueOnCapturedContext;  
  /*생략*/
}

위에보시는 _obj객체는 ValueTask를 반환하는 객체가 동기적으로 완료 된다면 null을 담고 그렇지 않다면 TaskIValueTaskSource가 들어갑니다.

// 이런 함수는 곤란합니다.. 😣
public async ValueTask<int> DoAsync()
{
	// 언제나 비동기로 실행되는 함수
    return await DoSomethingAsync();
}

위 함수처럼 ValueTask를 반환하는 함수가 비동기적으로 실행되어 함수 호출이 완료되는 시점을 비동기적으로 기다려야 하는 경우 _obj 객체에 해당 Task 객체를 저장하게 됩니다. 비동기적으로 코드를 기다린다는 것은 내부적으로 GetAwaiter()를 호출하여 대기 상태에 들어가게 되는데 ValueTaskTaskTaskAwaiter가 구현한 ICriticalNotifyCompletion 인터페이스를 ValueTaskAwaiter를 통해 지원하여 이러한 대기 메커니즘을 제공합니다.

await가 붙은 코드는 내부적으로 상태머신코드로 변경되며 IsCompletedOnCompleted() 같은 코드들을 이용해서 함수가 완료 되었는지 확인하거나 완료 된 후 진행 될 코드들을 입력하는데 ValueTask는 이러한 것들을 모두 구현해 두었고 구현부 내부는 모두 Task객체인 _obj 것들을 호출하는 것으로 구현되어있습니다.

비동기로 종료되는 함수를 ValueTask로 반환해도 어차피 내부적으론 Task가 사용됩니다.

정말 그럴까요?

ValueTaskIsCompletedOnCompleted()을 직접 보면 확인할 수 있을 것입니다.

public bool IsCompleted
{
    get
    {
        object? obj = _obj;
        /* 생략 */
        if (obj is Task t)
        {
            return t.IsCompleted;
        }
        /* 생략 */
    }
}
public void OnCompleted(Action continuation)
{
    // _value는 ValueTask이고 obj는 기다릴 Task입니다.
    object? obj = _value._obj;
	/* 생략 */
    if (obj is Task t)
    {
        t.GetAwaiter().OnCompleted(continuation);
    }
    /* 생략 */
}

둘 다 모두 Task의 것을 호출하는 것을 볼 수 있습니다.

결국 처음에 설명 드렸던 것 처럼 ValueTask를 반환하지만 항상 비동기로 동작하는 함수는 결국 Task를 포함한 ValueTask를 반환하고 내부적으론 Task를 사용하는 형태가 됩니다.

이 말은 그냥 Task를 반환하는게 낫지 굳이 Warrping 된 구조체를 반환하는 것은 불필요 하다는 것입니다.

이것 외에도 단일 필드인 Task에 비해 위에서 보신 것 처럼 ValueTask는 더 많은 필드를 가지고 있어 ValueTask를 리턴하면 더 많은 데이터 복사가 일어나게 됩니다. 또한, 여러 필드를 가지고 있는 ValueTask를 대기하기 위해 만들어신 상태 머신 코드는 Task에 의해 만들어진 상태 코드 머신 보다 커지게 됩니다.

이러한 이유로 ValueTask가 필요한 곳에만 ValueTask를 사용하셔야 합니다.

그럼 도대체 언제 ValueTask를 쓰는게 가장 좋을까요?

그 내용은 다음 주제로 이야기 해보도록 하겠습니다.

감사합니다.

profile
.NET 개발자 마수리입니다 🖐

0개의 댓글