Task vs. ValueTask

2023. 6. 17. 22:44Language/C#

개요

C# 프로그래밍에 익숙해지다 보면 비동기 프로그래밍에 관심을 갖게 됩니다. 비동기 프로그래밍은 사용자 경험을 향상시키고, 자원을 최적화하며, 성능을 높여주기 때문에 무척 중요한 개념입니다.

이 글에서는 C#에서 사용되는 주요 비동기 작업 유형인 Task와 ValueTask에 대해 알아보겠습니다. 이 두 가지 유형의 차이점을 이해하면 비동기 작업을 더 효율적으로 설계하고 구현할 수 있습니다.

기본 개념

Task는 비동작 작업을 나타내는 참조 형식입니다. 비동기 작업을 표현하기 위해 Task를 사용하면 코드의 가독성과 유지 관리가 향상 됩니다.

ValueTask는 비동기 작업을 나타내는 값 형식입니다. Task와 달리, 메모리 할당 및 GC에 더 효율적입니다.

여기서 중요한 것은 참조 형식이냐? 값 형식이냐 입니다.

ValueTask의 경우 값 형식이기 때문에 스택에 메모리가 할당 됩니다. 그래서 메모리 사용이 Task에 비해 덜 합니다.

하지만 메모리 사용에 좋다고 해서 꼭 속도가 빠른 것은 아닙니다. 속도는 Task쪽이 미세하게 빠를 수 있습니다. ValueTask의 경우 값 형식이기 때문에 스택에 값을 복사해야 하는데, ValueTask 자체의 필드가 3개 있으므로 Task가 IntPtr.Size 만큼의 복사만 발생하는 것과 비교하면 부하가 더 있을 수도 있습니다.

물론, 추후 GC로 인해 속도는 ValueTask가 전체적으로 빠를 수도 있습니다.

사용방법

사실 Task나 ValueTask나 사용법에 대해서는 크게 차이가 없습니다.

async Task<int> GetTask()
{
    await Task.Delay(1000);
    return 1;
}

async ValueTask<int> GetValueTask()
{
    await Task.Delay(1000);
    return 1;
}

var taskResult = await GetTask();
var taskValueResult = await GetValueTask();

System.Console.WriteLine(taskResult);
System.Console.WriteLine(taskValueResult);

똑같이 async로 선언하고 await로 호출하면 값을 받을 수 있습니다.

그럼 언제 ValueTask를 써야 할까?

한번 또는 1% 정도만 비동기를 수행해야 하고 99%의 작업을 동기로 수행해야 할 때 ValueTask를 사용하면 좋습니다.

예를 들면, 디스크의 파일을 비동기로 값을 읽고 그 후로는 읽은 값을 계속 반환하는 메서드의 경우와 같이 캐싱을 구현하여 사용 할 때 사용 하면 좋습니다.

public class ReadFile
{
    private string filePath = $@"D:\Workspace\Project\BlogPosts\CSharp\ValueTask\Some.txt";
    private string readText = string.Empty;
    public async Task<string> GetTask()
    {
        if(!string.IsNullOrEmpty(readText))
            return readText;
        var result = await File.ReadAllTextAsync(filePath);
        return result;
    }

    public async ValueTask<string> GetValueTask()
    {
        if(!string.IsNullOrEmpty(readText))
            return readText;
        var result = await File.ReadAllTextAsync(filePath);
        return result;
    }
}

위 예제를 보면 GetTask()의 경우 ReadAllTextAsync를 100% 수행합니다.

하지만 GetValueTask()의 경우는 1번 읽은 파일을 계속 동기로 반환하고 있습니다.

이럴 때 ValueTask를 사용하면 성능이 많이 향상이 됩니다.

이유는??

위 메서드에서 보면 아래와 같은 코드가 있습니다.

if(!string.IsNullOrEmpty(readText))
  return readText;

하지만 실제로는 return Task.FromResult(readText) 이며, return new ValueTask(result) 입니다.

Task는 참조 형식이기 때문에 힙에 계속 저장이 되고 ValueTask는 스택에 저장되기 때문에 GC가 메모리를 해제 할 때 스택이 훨씬 빠르고 효율적일 수 밖에 없기 때문입니다.

주의점

ValueTask는 절대 한번에 여러번 호출하면 안됩니다. 첫 번째 호출 개체가 삭제 될 수 있기 때문에 Exception이 발생합니다.

ValueTask<List<Employee>> employeesTask = GetEmployees();
Var a = await employeesTask;
Var b = await employeesTask;

또는

ValueTask<List<Employee>> employeesTask = GetEmployees();
Task.Run(async a => await employeesTask);
Task.Run(async b => await employeesTask);

그리고 GetAwaiter()는 사용하면 안됩니다. 메서드가 언제 완료 될지 모르기 때문에 IsCompleted 속성을 사용하여 작업이 완료되었는지를 확인해야 합니다.

ValueTask<List<Employee>> employeesTask = GetEmployees();
var a = emplyeeTask.GetAwaiter().GetResult();

'Language > C#' 카테고리의 다른 글

MemoryCache  (0) 2023.06.19
dotnet cli nuget 저장소 지정  (0) 2023.06.16
.NET AOP DynamicProxy  (0) 2023.06.05
StringBuilder vs String Join  (0) 2023.06.04
상속에서 Dispose 패턴  (0) 2023.05.31