ValueTask vs Task 성능 비교
Task<T>는 비동기 작업을 나타내는 클래스로 항상 힙에 할당됩니다. 반면 ValueTask<T>는 구조체(struct)로, 동기적으로 완료되는 경우 힙 할당 없이 결과를 반환할 수 있습니다. 이 차이가 고빈도 비동기 경로에서 큰 성능 차이를 만들어냅니다.
Task의 할당 비용
섹션 제목: “Task의 할당 비용”// 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 기본 동작
섹션 제목: “ValueTask 기본 동작”// 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는 아래 조건이 모두 맞을 때 사용합니다.
- 동기 완료가 자주 발생하는 경우 (캐시 히트, I/O 완료 큐 등)
- 단 한 번만 await되는 경우
- 결과를 저장하거나 여러 곳에서 await하지 않는 경우
// 올바른 사용await service.GetValueAsync(); // 단순 await
// 잘못된 사용 1 — 여러 번 awaitvar task = service.GetValueAsync();var r1 = await task; // OKvar r2 = await task; // 위험! ValueTask는 재사용 불가
// 잘못된 사용 2 — 저장 후 나중에 awaitValueTask<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; // OKIValueTaskSource — 할당 제로 비동기
섹션 제목: “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.Pipelines와 System.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 메서드와 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 |