ValueTask и аллокации
↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая
Зачем это на собесе
Вопрос уровня senior: «Когда использовать
ValueTaskвместоTaskи в чём его ограничения?» Ждут понимания, что выигрыш есть только при частом синхронном завершении, и знания правил: не ожидать дважды, не использовать.Resultдо завершения.
Объяснение
Проблема
Каждый вызов async Task<T>-метода, который завершается синхронно (например, значение найдено в кэше), всё равно может выделять объект Task<T> в куче. В горячих путях (миллионы вызовов) это нагрузка на GC.
ValueTask<T>
Структура-обёртка, которая содержит либо готовый результат, либо Task<T>, либо IValueTaskSource<T>. Если результат уже есть, аллокации нет.
public ValueTask<Concept?> LookupAsync(string code, CancellationToken ct)
{
if (_cache.TryGetValue(code, out var c))
return ValueTask.FromResult<Concept?>(c); // синхронный путь: без аллокации
return new ValueTask<Concept?>(LoadAsync(code, ct)); // асинхронный путь: обычная Task
}
private async Task<Concept?> LoadAsync(string code, CancellationToken ct) { ... }Или через async: метод помечается async ValueTask<T>, и компилятор использует специальный builder, который избегает аллокации при синхронном завершении.
Правила использования (важно)
ValueTask нельзя:
- ожидать (
await) больше одного раза; - ожидать одновременно из нескольких мест;
- вызывать
.GetAwaiter().GetResult()до завершения; - сохранять и использовать после завершения, если под ним
IValueTaskSourceс повторным использованием.
Если нужно использовать несколько раз или хранить — преобразуйте в Task:
Task<int> t = valueTask.AsTask(); // теперь можно await несколько раз, WhenAll и т.д.Когда использовать
- Метод часто завершается синхронно (кэш, буфер уже содержит данные,
Stream.ReadAsync). - Метод на очень горячем пути и профиль показывает аллокации
Task. - Реализуете интерфейсы BCL (
Stream,Channel<T>,IAsyncEnumerator<T>.MoveNextAsync,IAsyncDisposable.DisposeAsync), где уже принятValueTask.
Когда не использовать
- По умолчанию: используйте
Task. Он проще и безопаснее. - Публичный API, где потребители могут ожидать результат несколько раз или передавать в
Task.WhenAll. - Метод почти всегда асинхронный (нет выигрыша, только неудобство).
Похожие приёмы
Task.CompletedTask,Task.FromResultдля кэшированных значений (аллокации одной Task на значение).- Кэшировать
Task<bool>для типичных результатов (Task.FromResult(true)в статическом поле). IValueTaskSource/ManualResetValueTaskSourceCore— пулинг источников для нулевой аллокации даже при асинхронном завершении (внутри BCL, каналы).AsyncMethodBuilder-атрибут[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]— пулинг автоматов (экспериментально, для узких мест).
Нюансы и подводные камни
ValueTask— структура ~16 байт: копирование дороже, чем ссылка наTask; для async-методов, которые почти всегда асинхронны, будет хуже.- Ошибки использования (двойной
await) не всегда падают сразу — проявляются как случайные ошибки под нагрузкой. Анализатор CA2012 предупреждает. - Не путать
ValueTask(аллокации) с параллелизмом: он не делает код быстрее сам по себе. - Измеряйте BenchmarkDotNet
[MemoryDiagnoser]: выигрыш заметен только при массовых вызовах.
Практика
- Написать кэширующий метод с
Task<T>иValueTask<T>, сравнитьAllocatedпри 99% попаданий в кэш. - Найти в коде BCL, где возвращается
ValueTask(Stream.ReadAsync(Memory<byte>),Channel<T>.Reader.ReadAsync). - Показать, что второй
awaitодногоValueTaskдаёт непредсказуемый результат, и исправитьAsTask().
Вопросы с ответами
Что такое
ValueTaskи зачем он нужен?Структура, представляющая асинхронную операцию, которая может завершиться синхронно без выделения
Taskв куче. Снижает аллокации на горячих путях с частым синхронным завершением.
Когда
ValueTaskхужеTask?Когда метод почти всегда асинхронный, когда результат нужно ожидать несколько раз или передавать в
WhenAll, и в публичных API, где потребитель может нарушить правила использования.
Какие правила использования
ValueTask?Ожидать один раз, не ожидать одновременно из нескольких мест, не читать результат до завершения. Для повторного использования преобразовать в
TaskчерезAsTask().
Как реализовать метод, не аллоцирующий при попадании в кэш?
Вернуть
ValueTask<T>: на попадании —new ValueTask<T>(value), на промахе —new ValueTask<T>(LoadAsync()).
Почему
Stream.ReadAsync(Memory<byte>)возвращаетValueTask<int>?Часто данные уже лежат в буфере, и чтение завершается синхронно.
ValueTaskизбавляет от выделенияTaskна каждое чтение.
Связанные темы
- Предыдущая: async∕await изнутри: state machine, SynchronizationContext, ConfigureAwait · Следующая: CancellationToken и таймауты
- async/await изнутри: async∕await изнутри: state machine, SynchronizationContext, ConfigureAwait
- Пулы и аллокации: Пулы объектов: ArrayPool, ObjectPool, RecyclableMemoryStream
- Span и Memory: Span и Memory: stackalloc, ref struct