콘텐츠로 이동

ValueTask vs Task 성능 비교

Task<T>는 비동기 작업을 나타내는 클래스로 항상 힙에 할당됩니다. 반면 ValueTask<T>는 구조체(struct)로, 동기적으로 완료되는 경우 힙 할당 없이 결과를 반환할 수 있습니다. 이 차이가 고빈도 비동기 경로에서 큰 성능 차이를 만들어냅니다.


// Task<T> — 항상 힙 할당
public async Task<int> GetValueAsync() {
// 캐시에 있으면 즉시 반환해도 Task 객체는 생성됨
if (_cache.TryGetValue(key, out int val))
return val; // Task<int> 객체 힙 할당 발생
var result = await FetchFromDbAsync();
return result;
}

초당 수십만 번 호출되는 경로라면 Task 객체 생성이 GC 압력의 주요 원인이 됩니다.


// ValueTask<T> — 동기 완료 시 힙 할당 없음
public ValueTask<int> GetValueAsync() {
if (_cache.TryGetValue(key, out int val))
return new ValueTask<int>(val); // 구조체, 힙 할당 없음
return new ValueTask<int>(FetchFromDbAsync()); // Task 래핑
}

ValueTask<int>는 내부적으로 결과값 또는 Task를 보관하는 공용체(union-like) 구조체입니다.

ValueTask<T> 내부:
┌─────────────────────┐
│ object? _obj │ ← null이면 동기 완료
│ T _result │ ← 동기 결과값
│ short _token │ ← IValueTaskSource 토큰
└─────────────────────┘

ValueTask는 아래 조건이 모두 맞을 때 사용합니다.

  1. 동기 완료가 자주 발생하는 경우 (캐시 히트, I/O 완료 큐 등)
  2. 단 한 번만 await되는 경우
  3. 결과를 저장하거나 여러 곳에서 await하지 않는 경우
// 올바른 사용
await service.GetValueAsync(); // 단순 await
// 잘못된 사용 1 — 여러 번 await
var task = service.GetValueAsync();
var r1 = await task; // OK
var r2 = await task; // 위험! ValueTask는 재사용 불가
// 잘못된 사용 2 — 저장 후 나중에 await
ValueTask<int> saved = service.GetValueAsync();
DoOtherWork();
var r = await saved; // IValueTaskSource 재사용 문제 발생 가능
// Task로 변환하면 안전하게 여러 번 사용 가능
Task<int> safeTask = service.GetValueAsync().AsTask();
var r1 = await safeTask;
var r2 = await safeTask; // OK

IValueTaskSource — 할당 제로 비동기

섹션 제목: “IValueTaskSource — 할당 제로 비동기”

소켓, 파이프라인 등 고성능 I/O에서는 IValueTaskSource<T>를 직접 구현해 Task 객체 자체도 없애는 고급 패턴을 사용합니다.

using System.Runtime.CompilerServices;
using System.Threading.Tasks.Sources;
// ManualResetValueTaskSourceCore 활용 (재사용 가능한 ValueTask 소스)
public class SocketAwaitable : IValueTaskSource<int> {
private ManualResetValueTaskSourceCore<int> _core;
public ValueTask<int> ReceiveAsync(Memory<byte> buffer) {
// 소켓 수신 시작...
return new ValueTask<int>(this, _core.Version);
}
// 수신 완료 시 호출
public void Complete(int bytesRead) => _core.SetResult(bytesRead);
public void Fail(Exception ex) => _core.SetException(ex);
// IValueTaskSource<T> 구현
public int GetResult(short token) {
var result = _core.GetResult(token);
_core.Reset(); // 재사용을 위한 리셋
return result;
}
public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token);
public void OnCompleted(Action<object?> continuation, object? state,
short token, ValueTaskSourceOnCompletedFlags flags)
=> _core.OnCompleted(continuation, state, token, flags);
}

이 패턴은 System.IO.PipelinesSystem.Net.Sockets.Socket에서 내부적으로 사용됩니다.


[MemoryDiagnoser]
public class AsyncBenchmark {
private readonly Dictionary<string, int> _cache = new() { ["key"] = 42 };
[Benchmark]
public async Task<int> TaskWithCacheHit() {
if (_cache.TryGetValue("key", out int val))
return val;
return await SlowFetchAsync();
}
[Benchmark]
public async ValueTask<int> ValueTaskWithCacheHit() {
if (_cache.TryGetValue("key", out int val))
return val;
return await SlowFetchAsync();
}
}
| Method | Mean | Gen0 | Allocated |
|---------------------- |---------:|-------:|----------:|
| TaskWithCacheHit | 45.2 ns | 0.0076 | 48 B |
| ValueTaskWithCacheHit | 3.1 ns | - | 0 B |

캐시 히트 경로에서 ValueTask는 힙 할당이 전혀 없고 약 15배 빠릅니다.


// Task<T> 사용 — 항상 비동기이거나, 결과를 여러 곳에서 사용
public Task<Data> LoadDataAsync() { ... }
// ValueTask<T> 사용 — 동기 완료가 빈번한 캐시/풀 패턴
public ValueTask<int> ReadByteAsync() { ... }
// ValueTask (반환값 없음) — 완료만 신호하는 고빈도 콜백
public ValueTask FlushAsync() { ... }
// 인터페이스 설계 — 구현체에 유연성 부여
public interface IDataReader {
ValueTask<byte[]> ReadAsync(int count);
}

.NET 런타임 자체도 IAsyncEnumerator, IAsyncDisposable 등에서 ValueTask를 채택했습니다.


async 키워드와 함께 ValueTask를 반환할 때는 상태 머신 객체가 생성되므로 동기 완료 경로에서도 할당이 발생합니다.

// 할당 발생 — async 상태 머신 때문
public async ValueTask<int> GetAsync() {
if (_cache.TryGetValue(key, out int v)) return v; // 여기서도 상태 머신 할당
return await FetchAsync();
}
// 할당 없음 — 동기 경로를 명시적으로 분리
public ValueTask<int> GetAsync() {
if (_cache.TryGetValue(key, out int v))
return new ValueTask<int>(v); // 구조체만, 할당 없음
return new ValueTask<int>(FetchAndCacheAsync());
}
private async Task<int> FetchAndCacheAsync() {
var result = await FetchAsync();
_cache[key] = result;
return result;
}

항목Task<T>ValueTask<T>
타입클래스 (힙 할당)구조체
동기 완료 시항상 할당할당 없음
재사용가능불가 (1회 await)
저장/공유안전위험
사용 난이도쉬움주의 필요
적합한 상황일반적인 비동기캐시 히트, 고빈도 I/O