Особенности работы ValueTask

Класс Task и Task<T> являются ссылочными типами и потому для них выделяется память на куче каждый раз когда асинхронный метод возвращает одного из них (плюс работает сборщик мусора). А для типов-значений память выделяется в месте их определения и они никак не задействуют сборщик мусора. Также важной фишкой тасков является тот факт, что рантайм кеширует их. Вместо того чтобы эвейтить (await) метод можно просто получить ссылку на таску, возвращаемую асинхронным методом, а с этой ссылкой можно вызывать несколько задач одновременно. Также можно вызывать эту задачу более одного раза. Важно, что рантайм кэширует таску, что сказывается на потреблении памяти.

Для высокопроизводительных решений большое количество выделенных объектов типа Task могут представлять проблему, поэтому возможность исключить выделение памяти для тасок в куче плюс сборку мусора может представлять выгоду.

Начиная с .NET Core 2.0 в C# появились две структуры: ValueTask и ValueTask<T>, которые можно возвращать из асинхронных методов. Они размещаются в стэке и разработаны для увеличения производительности асинхронных методов в случаях, когда издержки на выделение памяти в куче критичны для производительности. Рантайм их не кэширует, что уменьшает выделение памяти и упрощает управление ею. Казалось бы, заменяй возвращаемый методом тип с Task на ValueTask (либо Task<T> на ValueTask<T>) и радуйся жизни, но есть пару но… как обычно…

Важным условие использования структур ValueTask/ValueTask<T> является обязательность ожидания их await-ом из асинхронного метода и невозможность их повторного вызова. Это может иметь большое значение, если вы разрабатываете библиотеки переиспользуемые другими разработчиками, т.к. у них сразу исключаются сценарии одновременной параллельной работы с тасками. Тут обычным таскам нет альтернативы.

Структура ValueTask<TResult> способна обернуть как TResult, так и Task<TResult>. Её можно возвращать из async метода, причём если этот метод выполнится синхронно, то никакого объекта в куче размещать не будет. В случае асинхронного выполнения объект Task<TResult> будет размещён в куче, а ValueTask<TResult> обернёт его, чтобы минимизировать размер структуры и оптимизировать случай успешного исполнения. Аsync метод, который завершается исключением, тоже будет размещать Task<TResult> внутри, и ValueTask<TResult> обернёт его и не будет таскать с собой дополнительное поле для хранения Exception.

Поэтому важно осознавать существующие значительные ограничения в применении ValueTask/ValueTask<T> в сравнении с Task/Task<T>, если отклониться от обычного сценария простого ожидания await.

Следующие операции никогда не должны выполняться с ValueTask/ValueTask<T>:

  • Повторное ожидание ValueTask/ValueTask<T>
    Объект результата может уже быть утилизирован и использоваться в другой операции. Напротив, Task/Task<T> никогда не переходит из завершённого состояния в незавершённое, поэтому вы можете повторно ожидать его столько раз, сколько потребуется и получать один и тот же результат каждый раз.

  • Параллельное ожидание ValueTask/ValueTask<T>
    Объект результата ожидает обработки только одним callbackом от одного потребителя в один момент времени, и попытка его ожидания из разных потоков одновременно может легко привести к гонкам и трудноуловимым ошибкам программы. В сравнении с этим, Task/Task<T> обеспечивает любое количество параллельных awaitов.

Если из метода получили ValueTask/ValueTask<T>, но необходимо выполнить одну из двух операций вышеописанных операций, то можно использовать .AsTask(). После этого работать с полученной такской как обычно, однако больше вы не сможете использовать изначальный ValueTask/ValueTask<T>.

1
2
3
public ValueTask<int> SomeMethodAsync() {...};
...
Task<int> t = SomeMethodAsync().AsTask();

Короче говоря, при применении ValueTask/ValueTask<T> вы должны или await его непосредственно (возможно с .ConfigureAwait(false)) или вызвать .AsTask() и больше его не использовать.

Однако ValueTask/ValueTask<T> будет отличным выбором когда:

  • вы ожидаете, что вызывать ваш метод будут только с await
  • затраты на выделение памяти критичны для вашего приложения
  • синхронное выполнение будет происходить часто