ValueTask и аллокации

↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая

Обязательно~4 мин чтенияУровень: middle

Зачем это на собесе

Вопрос уровня 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().

Вопросы с ответами

Связанные темы