12. ValueTask vs Task (할당·풀링·"한 번만 소비" 규칙)
난이도 상내 리뷰 · C#
해설 · C#
해설 — ValueTask vs Task (할당·풀링·"한 번만 소비" 규칙)
난이도: 상
요약
ValueTask<T>는 결과 T(동기 완료) 또는 비동기 백킹(Task<T>/IValueTaskSource<T>)을 감싸는 구조체다. 캐시 적중처럼 동기적으로 끝나는 경우 Task<T> 객체 할당을 피해 GC 압박을 줄인다 — 초당 수십만 번 불리는 게임 핫패스에서 의미가 크다. 대신 "정확히 한 번만, 완료 후에 소비" 라는 엄격한 규칙이 있어 (A) 이중 await, (B) 완료 전 .Result 접근 같은 오용이 미묘한 버그를 만든다.
핵심 개념
Task<T>: 참조 타입(힙 객체). 한 번 만들면 여러 번 await, 저장, 공유,WhenAll/WhenAny조합이 모두 안전. 대신 매 호출 할당.ValueTask<T>:readonly struct. 동기 완료면 내부에T만 들고 할당 0. 비동기면IValueTaskSource(풀링 가능)나Task를 가리킨다. 풀링 백킹은 첫 소비 후 재활용될 수 있어, 두 번째 소비 시 다른 작업의 상태를 읽거나 예외가 난다.
단계별 내부 동작 / 위험
- (1) 동기 완료의 이득:
_hot에 있으면new ValueTask<PlayerData>(p)— 힙 할당 없이 값만 감싼다.Task<T>였다면 (캐시된 Task를 재사용하지 않는 한) 매번 Task를 할당. 캐시 적중률이 높은 조회(플레이어/세션/설정)에서 할당과 GC를 크게 줄인다. - (2)
(A)이중 await — 잘못됨:ValueTask는 한 번만 await해야 한다.await vt를 두 번 하면, 백킹이 풀링된IValueTaskSource일 때 첫 await 후 소스가 풀로 반환·재사용되어 두 번째 await가 엉뚱한 결과/예외/교착을 부른다. 동기 완료나Task백킹이면 "우연히" 동작할 수도 있어 더 위험하다(테스트는 통과, 운영에서 간헐 실패). 규칙상 미정의 동작으로 취급해야 한다. - (3)
(B)완료 전.Result:ValueTask.Result(와GetAwaiter().GetResult())는 완료된 뒤에만 유효하다. 아직 완료 전이면 동작이 정의되지 않는다(블로킹 보장도 없음). 게다가 이미 await로 한 번 소비한 뒤.Result로 또 접근하는 것도 "두 번 소비"라 금지. 동기 대기가 꼭 필요하면vt.AsTask().Result처럼 Task로 만들어야 하지만, 그 자체가 블로킹 안티패턴. - (4) 여러 번/늦게 쓰려면: 한 번만 await해서 결과를 변수에 담거나,
ValueTask를 즉시.AsTask()로 한 번 변환해 일반 Task처럼 다룬다(이러면 할당이 생기지만 안전)..Preserve()로 다회 await 가능한 형태로 만들 수도 있다.
// (A) 수정: 한 번만 await, 결과를 재사용
var data = await cache.GetAsync(id);
var first = data; var second = data;
// 여러 곳에서 써야 하면 Task 로 승격
Task<PlayerData> t = cache.GetAsync(id).AsTask();
await Task.WhenAll(t, SomethingElseAsync());
var d = await t; // Task 는 다회 await 안전
- (5) 반환 타입 선택:
- ValueTask 권장: 자주 동기 완료되고(캐시/버퍼drain) 호출 빈도가 매우 높아 할당을 줄여야 하는 핫패스. 단 소비자가 규칙을 지킨다는 전제.
- Task 권장: 결과를 저장·공유·다회 await 하거나
WhenAll/WhenAny로 조합할 일이 많을 때, 또는 API 단순성이 중요할 때. Task는 함정이 적다. - 측정 없이 ValueTask로 "최적화"하지 말 것 — 대부분 비동기로 완료된다면 ValueTask는 이득이 거의 없고 복잡성만 는다.
흔한 오해·함정
- "ValueTask는 그냥 빠른 Task" → 아니다. 소비 규칙(한 번, 완료 후)이 다르고, 잘못 쓰면 간헐 버그.
Task처럼 캐싱/공유하면 안 된다. - "동기 완료니 await 안 하고
.Result써도 된다" →IsCompletedSuccessfully확인 후에만. 일반적으로는 그냥 await가 안전. - 풀링 백킹(
[AsyncMethodBuilder]/PoolingAsyncValueTaskMethodBuilder,ManualResetValueTaskSourceCore)일수록 이중 소비가 치명적.
면접 포인트
- ValueTask가 무엇을 감싸며 동기 완료 시 어떻게 할당을 피하는가, 게임 핫패스에서의 이점.
- "정확히 한 번, 완료 후 소비" 규칙과 이중 await/조기
.Result가 위험한 이유(풀링 재활용). - Task vs ValueTask 선택 기준, 다회 사용이 필요할 때
.AsTask()/.Preserve().