[Перевод] ValueTask<TResult> — почему, зачем и как?
Предисловие к переводу
В отличие от научных статей, статьи данного типа сложно переводить «близко к тексту», приходится проводить довольно сильную адаптацию. По этой причине приношу свои извинения, за некоторую вольность, с моей стороны, в обращении с текстом исходной статьи. Я руководствуюсь лишь одной целью — сделать перевод понятным, даже если он, местами, сильно отклоняется от исходной статьи. Буду благодарен за конструктивную критику и правки / дополнения к переводу.
Введение
Пространство имен System.Threading.Tasks
и класс Task
впервые были представлены в .NET Framework 4. С тех пор, этот тип, и его производный класс Task
, прочно вошли в практику программирования на .NET, стали ключевыми аспектами асинхронной модели, реализованной в C# 5, с его async/await
. В этой статье я расскажу о новых типах ValueTask/ValueTask
, которые были введены с целью повышения производительность асинхронного кода, в тех случаях, когда ключевую роль играют накладные расходов при работе с памятью.
Task
Task
служит нескольким целям, но основная из них это «promise» — объект, представляющий возможность ожидать завершение какой-либо операции. Вы инициируете операцию и получаете Task
. Этот Task
будет завершен, когда завершиться сама операция. При этом, есть три варианта:
- Операция завершается синхронно, в потоке инициатора. Например, при выполнении доступа к некоторым данным, которые уже находятся в буфере.
- Операция выполняется асинхронно, но успевает завершиться к тому моменту, когда инициатор получит
Task
. К примеру, когда выполняется быстрый доступ к данным, которые еще не были буферизированы - Операция выполняется асинхронно, и завершается после того как инициатор получил
Task
Примером может быть получение данных по сети.
Чтобы получить результат асинхронного вызова, клиент может, либо блокировать вызывающий поток в ожидании завершения, что, часто, противоречить идеи асинхронности, либо же предоставить метод обратного вызова, который будет выполнен по завершению асинхронной операции. Модель обратного вызова в .NET 4 была представлена явным образом, посредством метода ContinueWith
объекта класса Task
, принимавшего на вход делегат, вызываемый по завершению асинхронной операции.
SomeOperationAsync().ContinueWith(task =>
{
try
{
TResult result = task.Result;
UseResult(result);
}
catch (Exception e)
{
HandleException(e);
}
});
С .NET Frmaework 4.5 и C# 5 получение результата асинхронной операции было упрощено за счет введение ключевых слов async/await
и механизма, скрывающегося за ними. Этот механизм, генерируемый код, способен оптимизировать все упомянутые выше случаи, корректно обрабатывая завершение несмотря на то, по какому пути он было достигнуто.
TResult result = await SomeOperationAsync();
UseResult(result);
Класс Task
довольно гибок и имеет ряд преимуществ. Например, вы можете «ожидать» объект этого класса несколько раз, ожидать результата можно конкурентно, любым количеством потребителей. Экземпляры класса можно сохранить в словарь для любого числа последующих вызовов, с целью «ожидания» в будущем. Описанные сценарии, позволяют рассматривать объекты Task
в качестве своеобразного кэша результатов, получаемых асинхронно. Кроме того, Task
предоставляет возможность блокировать ожидающий поток, до завершения операции, если того требует сценарий. Так же есть т. н. комбинаторы для различных стратегий ожидания завершения наборов задач, например, «Task.WhenAny» — асинхронное ожидание завершения первой, из множества, задач.
Но, все же, наиболее частым случаем использования является просто запуск асинхронной операции и дальнейшее ожидания результата ее выполнения. Такой простой случай, довольно распространенный, не требует вышеупомянутой гибкости:
TResult result = await SomeOperationAsync();
UseResult(result);
Это очень похоже на то, как мы пишем синхронный код (например TResult result = SomeOperation();
). Такой вариант естественным образом транслируется в async/await
.
Кроме того, при всех достоинствах, тип Task
имеет потенциальный недостаток. Task
— это класс, а значит, каждая операция, которая создает экземпляр задачи, аллоцирует объект в куче. Чем больше объектов мы создаем, тем больше требуется работы со стороны GC, и тем больше тратиться ресурсов на работу сборщика мусора, ресурсов, которые могли бы быть использованы на другие цели. Это становится явной проблемой для кода, в котором, с одной стороны, экземпляры Task
создаются часто, а с другой, к которому предъявляются повышенные требования по пропускной способности и производительности.
Среде выполнения и основным библиотекам, во многих ситуациях, удается смягчить этот эффект. Например, если вы пишите метод, подобный приведенному ниже:
public async Task WriteAsync(byte value)
{
if (_bufferedCount == _buffer.Length)
{
await FlushAsync();
}
_buffer[_bufferedCount++] = value;
}
и, чаще всего, в буфере будет достаточно места, то операция будет завершиться синхронно. Если это так, то нет ничего особенного в возвращаемой задаче, нет возвращаемого значения, а операция уже завершена. Другими словами, мы имеем дело с Task
— эквивалентом синхронной void
-операции. В таких ситуациях среда выполнения просто кэширует объект Task
, и использует его каждый раз как результат для любого async Task
— метода, завершающегося синхронно (Task.ComletedTask
). Другой пример, допустим вы пишете:
public async Task MoveNextAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
return _bufferedCount > 0;
}
Допустим, так же, что в большинстве случаев, в буфере есть некоторые данные. Метод проверяет _bufferedCount
, видит, что переменная больше нуля, и возвращает true
. Только если на момент проверки данные не были буферизированы, требуется выполнить асинхронную операцию. Как бы там ни было, есть только два возможных логических результата (true
и false
), и только два возможных состояния возврата через Task
. В расчете на синхронное завершение, или асинхронное, но до выхода из метода, среда выполнения кэширует два экземпляра Task
(одно для значения true
, второе для false
), и возвращает нужный из них, избегая дополнительных аллокаций. Единственный вариант, когда приходится создавать новый объект Task
, это случай асинхронного выполнения, которое завершается уже после «возврата». В этом случае, методу приходится создавать новый объект Task
, т.к. на момент выхода из метода, результат завершения операции еще не известен. Возвращаемый объект должен быть уникальным, т.к. в него будет, в конечном итоге, сохранен результат асинхронной операции.
Есть и другие примеры подобного кэширования со стороны среды выполнения. Но такая стратегия применима не везде. Например, метод:
public async Task ReadNextByteAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
if (_bufferedCount == 0)
{
return -1;
}
_bufferedCount--;
return _buffer[_position++];
}
так же, часто завершается синхронно. Но, в отличие от предыдущего примера, этот метод возвращает целочисленный результат, который имеет примерно четыре миллиарда возможных значений. Для кэширования Task
, в этой ситуации, потребовалось бы сотни гигабайт памяти. Среда и здесь поддерживает небольшой кэш для Task
, для нескольких небольших значений. Так, например, если операция завершится синхронно (данные присутствуют в буфере), с результатом 4, будет использован кэш. Но если результатом, пусть и синхронного, завершение будет значение 42, будет создан новый объект Task
, аналогично вызову Task.FromResult(42)
.
Многие реализации библиотеки пытаются смягчить подобные ситуации посредством поддержки своих собственных кэшей. Одним из примеров является, перегрузка MemoryStream.ReadAsync
. Эта операция, введенная в .NET Framework 4.5, всегда завершается синхронно, т.к. это всего лишь чтение из памяти. ReadAsync
возвращает Task
, где целочисленный результат представляет число прочитанных байтов. Довольно часто, в коде, встречается ситуация, когда ReadAsync
используется в цикле. При этом, если имеются следующие признаки:
- Количество запрашиваемых байтов, не меняется для большинства итераций цикла;
- В большинстве итераций
ReadAsync
может прочитать запрошенное количество байт.
То, для повторяющихся вызовов ReadAsync
выполняется синхронно и возвращает объект Task
, с одинаковым результатом от итерации к итерации. Логично, что MemoryStream
кэширует крайнюю успешно выполненную задачу, и для всех последующих вызовов, если новый результат совпадает с предыдущим, возвращает экземпляр из кэша. Если же результат не совпадает, то используется Task.FromResult
для создания нового экземпляра, который, в свою очередь, так же, кэшируется перед возвратом.
Но, все же, существует много случаев, когда операция вынуждены создавать новый объекты Task
, даже при синхронном завершении.
ValueTask и синхронное завершение
Все это, в конечном итоге, послужило мотивацией для введения в .NET Core 2.0 нового типа ValueTask
. Так же, через nuget-пакет System.Threading.Tasks.Extensions
, этот тип сделали доступным и в других релизах .NET.
ValueTask
был введен в .NET Core 2.0 как структура, способная оборачивать TResult
или Task
. Это означает, что объекты этого типа могут быть возвращены из async
-метода. Первый плюс от введение этого типа виден сразу: если метод завершился успешно и синхронно, нет необходимости создавать что-либо в куче, достаточно просто для возврата создать экземпляр ValueTask
со значением результата. Только если метод завершается асинхронно, нам необходимо создать Task
. В таком случае ValueTask
используется как обертка над Task
. Решение сделать ValueTask
способным агрегировать Task
было принято с целью оптимизации: и в случае успеха, и в случае неудачи, асинхронный метод создает Task
, с точки зрения оптимизации по памяти, лучше агрегировать сам объект Task
, чем держать дополнительные поля в ValueTask
на разные случаи завершения (например для хранения исключения).
Учитывая вышеизложенное, больше нет необходимости в кэшировании в таких методах как приведенный выше MemoryStream.ReadAsync
, вместо этого его можно реализовывать следующим образом:
public override ValueTask ReadAsync(byte[] buffer, int offset, int count)
{
try
{
int bytesRead = Read(buffer, offset, count);
return new ValueTask(bytesRead);
}
catch (Exception e)
{
return new ValueTask(Task.FromException(e));
}
}
ValueTask и асинхронное завершение
Иметь возможность писать асинхронные методы, которые не требуют дополнительных аллокаций памяти для результата, при синхронном завершении, это действительно большой плюс. Как говорилось выше, это было основной целью для введения нового типа ValueTask
в .NET Core 2.0. Все новые методы, которые, как ожидается, будут использоваться на «горячих путях», теперь в качестве типа возвращаемого значения вместо Task
используют ValueTask
. К примеру, новая перегрузка метода ReadAsync
для Stream
, в .NET Core 2.1 (принимающая в качестве параметра Memory
вместо byte[]
), возвращает экземпляр ValueTask
. Это позволило значительно снизить количество аллокаций при работе с потоками (очень часто метод ReadAsync
завершается синхронно, как и в примере с MemoryStream
).
Однако, и при разработке сервисов с высокой пропускной способностью, в которых асинхронное завершение не редкость, нам нужно всеми силами стараться избегать дополнительных аллокаций.
Как говорилось ранее, в модели async/await
, любая операция, которая завершается асинхронно, должна вернуть уникальный объект, для ожидания завершения. Уникальный, т.к. он будет служить каналом выполнения обратного вызова. Отметим, однако, что данная конструкция ничего не говорит о том, можно ли повторно использовать возвращенный объект ожидания, уже после завершения асинхронной операции. Если объект может быть повторно использован, то API может поддерживать пул для такого рода объектов. Но, в таком случае, этот пул не может поддерживать конкурентный доступ — объект из пула будет переходит из состояния «завершен» в состояние «не завершен» и обратно.
Для поддержки возможности работы с такого рода пулами, в .NET Core 2.1 был добавлен интерфейс IValueTaskSource
, а структура ValueTask
была расширена: теперь объекты этого типа могут оборачивать не только объекты типа TResult
или Task
, но и экземпляры IValueTaskSource
. Новый интерфейс обеспечивает базовый функционал, который позволяет объектам ValueTask
работать с IValueTaskSource
в той же манере, что и с Task
:
public interface IValueTaskSource
{
ValueTaskSourceStatus GetStatus(short token);
void OnCompleted(
Action
GetStatus
предназначен для использования в свойстве ValueTask
— позволяет узнать завершилась ли операция, или нет (успешно или нет). OnCompleted
используется в ValueTask
для запуска обратного вызова. GetResult
используется для получения результата, или для распространения возникшего исключения.
У большинства разработчиков вряд ли когда-либо возникнет необходимость иметь дело с интерфейсом IValueTaskSource
, т.к. асинхронные методы, при возврате, скрывают его за экземпляром ValueTask
. Сам интерфейс, в первую очередь, предназначен для тех, кто разрабатывает высокопроизводительные API, и стремится избегать излишней работы с кучей.
В .NET Core 2.1 можно выделить несколько примеров такого рода API. Наиболее известный из них, это новые перегрузки методов Socket.ReceiveAsync
и Socket.SendAsync
. К примеру:
public ValueTask ReceiveAsync(
Memory buffer,
SocketFlags socketFlags,
CancellationToken cancellationToken = default);
В качестве возвращаемого значения используются объекты типа ValueTask
.
Если метод завершается синхронно, то он возвращает ValueTask
с соответствующим значением:
int result = …;
return new ValueTask(result);
Если же операция завершается асинхронно, то используется кэшируемый объект, реализующий интерфейс IValueTaskSource
:
IValueTaskSource vts = …;
return new ValueTask(vts);
Реализация Socket
поддерживает один кэшируемый объект для получения, и один для отправления данных, до тех пор, пока каждый из них используется без конкуренции (нет, например, конкурентных отправок данных). Такая стратегия снижает количество дополнительно выделяемой памяти, даже в случае асинхронного выполнения.
Описанная оптимизация Socket
в .NET Core 2.1, позитивно повлияла на производительность NetworkStream
. Его перегрузка метод ReadAsync
класса Stream
:
public virtual ValueTask ReadAsync(
Memory buffer,
CancellationToken cancellationToken);
просто делегирует работу методу Socket.ReceiveAsync
. Повышение эффективности метода сокета, в плане работы с памятью, повышает эффективность и метода NetworkStream
.
Non-generic ValueTask
Ранее я несколько раз отмечал, что первоначальной целью ValueTask
, в .NET Core 2.0, была оптимизация случаев синхронного завершения методов с «непустым» результатом. Это значит, что не было необходимости в не типизируемом ValueTask
: в случаях синхронного завершения методы используют синглтон через свойство Task.CompletedTask
, так же, неявным образом, поступает среда выполнения для async Task
-методов.
Но, с появлением возможности избегать лишних аллокаций и при асинхронном выполнении, необходимость в не типизированном ValueTask
сновf стала актуальна. По этой причине, в .NET Core 2.1 мы ввели не типизируемые ValueTask
и IValueTaskSource
. Они являются аналогами соответствующих обобщенных типов, и используются тем же образом, но для методов с пустым (void
) возвратом.
Реализация IValueTaskSource / IValueTaskSource
У большинства разработчиков не возникнет необходимости реализовывать эти интерфейсы. Да и их реализация — не простая задача. Если же вы решите, что вам необходима реализовать их самостоятельно, то, внутри .NET Core 2.1, существует несколько реализаций которые могут послужить примерами:
Для упрощения это задачи (реализации IValueTaskSource / IValueTaskSource
), мы планируем представить в .NET Core 3.0 тип ManualResetValueTaskSourceCore
. Эта структура будет инкапсулировать всю необходимую логику. Экземпляр ManualResetValueTaskSourceCore
можно будет использовать в другом объекте, реализующем IValueTaskSource
и / или IValueTaskSource
, и делегировать ему большую часть работы. Вы можете больше узнать об этом по ссылке ttps://github.com/dotnet/corefx/issues/32664.
Правильная модель использования ValueTasks
Даже при поверхностном рассмотрении видно что ValueTask
and ValueTask
более ограниченны чем Task
и Task
. И это нормально, даже желательно, ведь их основная цель — это ожидание завершения асинхронного выполнения.
В частности, существенные ограничения возникают в следствии того, что ValueTask
и ValueTask
могут агрегировать переиспользуемые объекты. В общем, следующие операции *НИКОГДА не должны выполняться при использовании ValueTask
/ ValueTask
* (позволю себе переформулировать через «Никогда не»*):
- Никогда не используйте один и тот же объект
ValueTask
/ValueTask
многократно
Мотивация: Экземпляры Task
и Task
никогда не переходят из «завершенного» состояния в «незавершенное», их мы можем использовать для ожидания результата столько раз сколько захотим — после завершения мы всегда будем получать один и тот же результат. Напротив, так как ValueTask
/ ValueTask
, могут выступать обертками над переиспользуемыми объектами, а это значит, что их состояние, может изменяться, т.к. состояние переиспользуемых объектов меняется по определению — переходить от «завершенного» в «незавершенное» и обратно.
- Никогда не ожидайте
ValueTask
/ValueTask<TResult>
в конкурентном режиме.
Мотивация: Обернутый объект ожидает работать только с одним обратным вызовом, от единственного потребителя за раз, и попытка конкурентного ожидания может легко привести к состоянию гонки и к «тонким» программным ошибкам. Конкурентное ожидания, это один из вариантов, описанного выше многократного ожидания. Отметим, что Task
/ Task
допускают любое число конкурентных ожиданий.
- Никогда не используйте
.GetAwaiter().GetResult()
до завершения операции.
Мотивация: Реализации IValueTaskSource
/ IValueTaskSource
не должны поддерживать блокировку до завершения операции. Блокировка, по сути, приводит к состоянию гонки, вряд ли это будет ожидаемое поведение, со стороны потребителя. В то время как Task
/ Task
позволяют сделать это, тем самым заблокировать вызывающий поток до завершения операции.
Но что, если, все же, вам нужно сделать одну из описанных выше операций, а вызываемый метод возвращает экземпляры ValueTask
/ ValueTask
? На такие случаи ValueTask
/ ValueTask
предоставляют метод .AsTask()
. Посредством вызова этого метода, вы получите экземпляр Task
/ Task
, и уже с ним сможете выполнить нужную операцию. Повторно исопльзовать исходный объект, после вызова .AsTask()
, недопустимо.
Короткое правило гласит: При работе с экземпляром ValueTask
/ ValueTask
вы должны, либо ожидать (await
) его напрямую (или, если нужно с .ConfigureAwait(false)
), либо вызвать .AsTask()
, и больше никогда не использовать исходный объект ValueTask
/ ValueTask
.
// Given this ValueTask-returning method…
public ValueTask SomeValueTaskReturningMethodAsync();
…
// GOOD
int result = await SomeValueTaskReturningMethodAsync();
// GOOD
int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false);
// GOOD
Task t = SomeValueTaskReturningMethodAsync().AsTask();
// WARNING
ValueTask vt = SomeValueTaskReturningMethodAsync();
... // storing the instance into a local makes it much more likely it'll be misused,
// but it could still be ok
// BAD: awaits multiple times
ValueTask vt = SomeValueTaskReturningMethodAsync();
int result = await vt;
int result2 = await vt;
// BAD: awaits concurrently (and, by definition then, multiple times)
ValueTask vt = SomeValueTaskReturningMethodAsync();
Task.Run(async () => await vt);
Task.Run(async () => await vt);
// BAD: uses GetAwaiter().GetResult() when it's not known to be done
ValueTask vt = SomeValueTaskReturningMethodAsync();
int result = vt.GetAwaiter().GetResult();
Есть еще один дополнительный, «продвинутый», шаблон использования, который некоторые программисты могут решиться применить (надеюсь только после аккуратных измерений, с обоснованием пользы от его применения).
У объектов ValueTask
/ ValueTask
имеются свойства, которые позволяют узнать о текущем состоянии операции. К примеру, свойство IsCompleted
вернет true
, если операция завершена (успешно или с исключением, не важно), в противном случае — false
, а IsCompletedSuccessfully
вернет true
только в случае успешного завершения. Для самых «горячих путей» исполнения, где разработчик хочет, например, избежать дополнительных накладных расходов, неустранимых при асинхронном выполнении, можно использовать вышеуказанные свойства. Так мы можем проверить состояние выполнения и решить надо ли использовать await
/ .AsTask()
или же можно вызывать .Result
напрямую. К примеру, реализация класса SocketsHttpHandler
в .NET Core 2.1, имеет дело с методом .ReadAsync
соединения, который возвращает ValueTask
. Если операция завершается синхронно, нам более не надо волноваться о возможности, например, её отмены. Но если операция будет выполняться асинхронно, то мы хотим иметь возможность обработать запрос отмены т.к. он разорвет соединение. Т.к. это высоконагруженная часть кода, и профилирование показало небольшое различие, код, по существу, был структурирован следующим образом:
int bytesRead;
{
ValueTask readTask = _connection.ReadAsync(buffer);
if (readTask.IsCompletedSuccessfully)
{
bytesRead = readTask.Result;
}
else
{
using (_connection.RegisterCancellation())
{
bytesRead = await readTask;
}
}
}
Здесь вышеописанный шаблон допустим, т.к. ValueTask
, ни в случае вызова .Result
, ни в случае await
, ни где после не используется.
Должны ли все новые асинхронные API возвращать ValueTask / ValueTask?
Если коротко, то нет. Выбор по умолчанию остается за Task
/ ValueTask
.
Как было показано ранее, объекты типов Task
/ Task
проще в плане корректного использования. По этой причине, пока выигрыш в производительности не превысит «выигрыша» в удобстве/простоте использования, лучше отдавать предпочтение Task
/ Task
. Есть, так же, незначительные расходы связанные с использованием ValueTask
вместо Task
: например, при микропрофилировании видно, что await
работает немного быстрее с Task
чем с ValueTask
. Таким образом, если вы можете использовать кэширование задач (например, ваш API возвращает Task
или Task
), то, с точки зрения производительности, лучше использовать Task
(Task
). Кроме того, ValueTask
/ ValueTask
занимают несколько машинных слов. Это значит, что при вызове async-метода, в создаваемом при этом экземпляре конечного автомата появляются дополнительные поля для хранения объектов ValueTask
/ ValueTask
, что слегка увеличивает общий размер занимаемой памяти.
Как бы там ни было, ValueTask
/ ValueTask
это отличный выбор, если:
- вы полагаете, что клиент вашего API будет выполнять ожидание напрямую,
- вашему API важно избегать дополнительных операций выделения памяти в куче, и
- вы ожидаете что, либо операции будут часто завершаться синхронно, либо же у вас есть возможность эффективно использовать пулы, на случай асинхронного выполнения.
Не забывайте, что при добавлении квалификаторов abstract
/ virtual
к методу, или при определении интерфейсов, нужно задуматься будут ли описанные выше соображения справедливы для реализаций / переопределений метода?
Что дальше?
В базовых библиотеках .NET, мы продолжим встречать новые API, возвращающие Task
/ Task
. Но, так же, мы увидим и новые API c ValueTask
/ ValueTask
, там где это будет уместно. Одним из ключевых примеров последнего является новый интерфейс IAsyncEnumerator
, поддержку которого планируется начать с .NET Core 3.0. Интерфейс IEnumerator
представляет метод MoveNext
, который возвращает булево значение. Асинхронный аналог — IAsyncEnumerator
предоставляет метод MoveNextAsync
. Когда мы начали проектировать эту фичу, мы думали использовать в качестве возвращаемого типа Task
, который может быть очень эффективен при использовании кэша, в случаях синхронного завершения. Все же, учитывая как сильно будут распространены асинхронные перечисления, учитывая что они будут основаны на интерфейсах, у которых будет множество реализаций (некоторые из которых могут быть сильно обеспокоены вопросом производительности), а так же, учитывая тот факт, что большинство клиентов будут использовать эти перечисления через await foreach
-синтаксис, мы выбрали, в качестве возвращаемого значения метода MoveNextAsync
, тип ValueTask
. Это позволяет остаться столь же быстрым при синхронном завершении, и, в тоже время, позволяет оптимизировать реализации с «повторно используемыми» объектами, понижая количество аллокаций на куче в случаях асинхронных вызовов. Фактически, компилятор C# пользуется этим при реализации асинхронных итераторов, чтобы сделать их максимально эффективными с точки зрения работы с памятью.