[Перевод] Почему, зачем и когда нужно использовать ValueTask
Этот перевод появился благодаря хорошему комментарию 0×1000000.
В .NET Framework 4 появилось пространство System.Threading.Tasks, а с ним и класс Task. Этот тип и порождённый от него Task
Task
Task выступает в разных ролях, но основная — это «обещание» (promise), объект, представляющий возможное завершение некоторой операции. Вы инициируете операцию и получаете для неё объект Task, который будет выполнен, когда операция завершится, что может произойти в синхронном режиме как составная часть инициализации операции (например, получение данных, которые уже в буфере), в асинхронном режиме с выполнением в момент, когда вы получаете Task (получение данных не из буфера, но очень быстро), или в асинхронном режиме, но после того, как Task уже у вас (получение данных по с удалённого ресурса). Так как операция может завершиться асинхронно, вы или блокируете поток выполнения, ожидая результата (что часто делает бессмысленным асинхронность вызова), или создаёте функцию обратного вызова (callback), которая будет активизирована после завершения операции. В .Net 4 создание callbackа реализовано методами ContinueWith объекта Task, которые явно демонстрируют эту модель, принимая функцию-делегат (delegate), чтобы запустить её после исполнения Task:
SomeOperationAsync().ContinueWith(task =>
{
try
{
TResult result = task.Result;
UseResult(result);
}
catch (Exception e)
{
HandleException(e);
}
});
Но в .NET Framework 4.5 и C# 5 объекты Task могут быть просто вызваны оператором await, что делает простым получение результата асинхронной операции, и генерированным кодом, который оптимизирован для вышеупомянутых вариантов, правильно отработает во всех случаях завершения операции в синхронном режиме, быстром асинхронном или асинхронном с выполнением callbacka:
TResult result = await SomeOperationAsync();
UseResult(result);
Task является очень гибким классом и это даёт ряд преимуществ. Например, вы можете выполнить await несколько раз для любого количества потребителей одновременно. Вы можете положить его в коллекцию (dictionary) для повторных await в будущем, чтобы использовать его как кеш результатов асинхронных вызовов. Вы можете заблокировать выполнение, ожидая завершения Task, если такое понадобится. И вы можете написать и применить разнообразные операции над объектами Task (иногда их называют «комбинаторами»), например, «когда любая» («when any») для асинхронного ожидания первого завершения из нескольких Task.
Но эта гибкость становится лишней в наиболее часто встречающемся случае: просто вызвать асинхронную операцию и дождаться выполнения задачи:
TResult result = await SomeOperationAsync();
UseResult(result);
Здесь нам не понадобится ждать выполнения несколько раз. Нам не нужно обеспечить конкуррентность ожиданий. Нам не нужно выполнять синхронную блокировку. Мы не будем писать комбинаторы. Мы просто ждём выполнения promise асинхронной операции. В конце концов, это так, как мы пишем синхронный код (например, TResult result = SomeOperation ();), и это обычным образом переводится на язык async/await.
Более того, у Task есть потенциальная слабая сторона, особенно когда создаётся большое количество его экземпляров, а большая пропускная способность и производительность являются ключевыми требованиями — Task является классом. Это означает, что любая операция, которой понадобился Task, вынуждена создавать и размещать объект, а чем больше объектов создаётся, тем больше работы для сборщика мусора (GC), и на эту работу расходуются ресурсы, которые мы могли бы потратить на что-то более полезное.
Среда выполнения и системные библиотеки помогают смягчать эту проблему во многих ситуациях. Например, если мы напишем такой метод:
public async Task WriteAsync(byte value)
{
if (_bufferedCount == _buffer.Length)
{
await FlushAsync();
}
_buffer[_bufferedCount++] = value;
}
как правило, в буфере будет достаточно свободного пространства, и операция выполнится синхронно. Когда это произойдёт, не нужно ничего делать с Task, который должен быть возвращён, так как возвращаемое значение отсутствует, это использование Task как эквивалент синхронного метода, возвращающего пустое значение (void). Поэтому среда может просто кешировать один необобщённый (non-generic) Task и использовать его снова и снова как результат выполнения для любого async метода, который завершается синхронно (этот кешированный синглтон можно получить через Task.CompletedTask). Или, например, вы пишете:
public async Task MoveNextAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
return _bufferedCount > 0;
}
и в общем случае ожидаете, что данные уже в буфере, так что метод просто проверит значение _bufferedCount, увидит, что оно больше 0, и вернёт true; и только если данных в буфере ещё нет, нужно выполнить асинхронную операцию. И так как есть только два возможных результата типа Boolean (true и false), существует только два возможных Task объекта, которые нужны для представления этих результатов, среда может кешировать эти объекты и возвращать их с соответствующим значением без выделения памяти. Только в случае асинхронного завершения методу понадобится создать новый Task, потому что его будет нужно вернуть до того, как станет известен результат операции.
Среда обеспечивает кеширование и для некоторых других типов, но нереально кешировать все возможные типы. Например, следующий метод:
public async Task ReadNextByteAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
if (_bufferedCount == 0)
{
return -1;
}
_bufferedCount--;
return _buffer[_position++];
}
также будет часто выполняться синхронно. Но в отличие от варианта с результатом типа Boolean этот метод возвращает Int32, который имеет порядка 4 миллиардов значений, и кеширование всех вариантов Task
Многие методы библиотеки пытаются сгладить это путём обеспечения собственного кеша. Например, перегрузка в.NET Framework 4.5 метода MemoryStream.ReadAsync всегда завершается синхронно, так как читает данные из памяти. ReadAsync возвращаетTask
И всё же есть множество других случаев, когда операция выполняется синхронно, но объект Task
ValueTask и синхронное выполнение
Всё это потребовало реализации в .NET Core 2.0 нового типа, который доступен в предыдущих версиях .NET в пакете NuGet System.Threading.Tasks.Extensions: ValueTask
ValueTask
Исходя из этого, метод наподобие MemoryStream.ReadAsync, но возвращающий ValueTask
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 и асинхронное выполнение
Возможность написать async метод, который способен завершиться синхронно без необходимости дополнительного размещения для результата, это большая победа. Вот почему ValueTask
Однако, когда мы работаем с сервисами с очень высокой пропускной способностью, мы по-прежнему хотим избежать выделения памяти насколько вообще это возможно, а это означает уменьшение и устранение выделения памяти также и по маршруту асинхронного исполнения.
В модели await для любой операции, завершающейся асинхронно, нам необходима способность вернуть объект, который представляет возможное завершение операции: вызывающему необходима переадресация callbackа, который будет инициирован по завершению операции, и это требует наличия уникального объекта в куче, который может послужить как канал передачи для этой конкретной операции. Это, в то же время, не означает ничего будет ли этот объект использован повторно после завершения операции. Если этот объект может быть использован повторно, API может организовать кеш для одного или нескольких таких объектов, и применять его для последовательных операций, в смысле не использовать один и тот же объект для нескольких промежуточных async операций, но использовать для неконкуррентного доступа.
В .NET Core 2.1 класс ValueTask
public interface IValueTaskSource
{
ValueTaskSourceStatus GetStatus(short token);
void OnCompleted(Action
Метод GetStatus используется, чтобы реализовать свойства подобные ValueTask
Большинству разработчиков этот интерфейс не нужен: методы просто возвращают объект ValueTask
Существует несколько примеров такого API в .NET Core 2.1. Наиболее известные методы — это Socket.ReceiveAsync и Socket.SendAsync с новыми перегрузками, добавленными в 2.1, например
public ValueTask ReceiveAsync(Memory buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);
Эта перегрузка возвращает ValueTask
int result = …;
return new ValueTask(result);
При асинхронном завершении она может использовать объект из пула, который реализует интерфейс:
IValueTaskSource vts = …;
return new ValueTask(vts);
Реализация Socket поддерживает один такой объект в пуле для приёма, и один для передачи, так как не может быть для каждого направления более одного объекта, ожидающего выполнения в один момент времени. Эти перегрузки не выделяют памяти, даже в случае асинхронного исполнения операции. Это поведение проявляется далее в классе NetworkStream.
Например, в .NET Core 2.1 Stream предоставляет:
public virtual ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken);
который переопределяется в NetworkStream. Метод NetworkStream.ReadAsync просто использует метод Socket.ReceiveAsync, так что выигрыш в Socket транслируется в NetworkStream, и NetworkStream.ReadAsync фактически тоже не выделяет памяти.
Необобщённый ValueTask
Когда ValueTask
Тем не менее, с получением асинхронных операций без выделения памяти использование необобщённого ValueTask снова стало актуальным. В .NET Core 2.1 мы представили необобщённые ValueTask и IValueTaskSource. Они обеспечивают прямые эквиваленты для обобщённых версий, для аналогичного использования, только с пустым возвращаемым значением.
Реализация IValueTaskSource/IValueTaskSource
Большинство разработчиков не должно реализовывать эти интерфейсы. К тому же это не так уж легко. Если вы решите сделать это, несколько реализаций в .NET Core 2.1 могут послужить отправной точкой, например:
- AwaitableSocketAsyncEventArgs
- AsyncOperation
- DefaultPipeReader
Чтобы сделать это легче, в .NET Core 3.0 мы планируем представить всю необходимую логику, включённую в тип ManualResetValueTaskSourceCore
Паттерны применения ValueTasks
На первый взгляд область применения ValueTask и ValueTask
Однако, так как они могут оборачивать объекты, которые повторно используются, существуют значительные ограничения по их применению в сравнении с Task и Task
- Повторное ожидание ValueTask/ValueTask
Объект результата может уже быть утилизирован и использоваться в другой операции. Напротив, Task/Task никогда не переходит из завершённого состояния в незавершённое, поэтому вы можете повторно ожидать его столько раз, сколько потребуется, и получать один и тот же результат каждый раз. - Параллельное ожидание ValueTask/ValueTask
Объект результата ожидает обработки только одним callbackом от одного потребителя в один момент времени, и попытка его ожидания из разных потоков одновременно может легко привести к гонкам и трудноуловимым ошибкам программы. Кроме того, это также более специфичный случай предыдущей недопустимой операции «повторное ожидание». В сравнении с этим, Task/Task обеспечивает любое количество параллельных awaitов. - Использование .GetAwaiter ().GetResult (), когда операция ещё не завершилась Реализация IValueTaskSource/IValueTaskSource
не нуждается в поддержке блокировки до окончания операции, и, скорее всего, не сделает этого, так что такая операция определённо приведёт к гонкам и вероятно будет выполнена не так, как ожидает вызывающий метод. Task/Task блокирует вызывающий поток пока задача не будет выполнена.
Если получили ValueTask или ValueTask
Короче говоря, правило таково: при применении ValueTask/ValueTask
// Дан такой метод, возвращающий ValueTask
public ValueTask SomeValueTaskReturningMethodAsync();
...
// GOOD
int result = await SomeValueTaskReturningMethodAsync();
// GOOD
int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false);
// GOOD
Task t = SomeValueTaskReturningMethodAsync().AsTask();
// WARNING
ValueTask vt = SomeValueTaskReturningMethodAsync();
// сохранение экземпляра локально делает это потенциально небезопасным,
// но может быть безвредным
// BAD: await несколько раз
ValueTask vt = SomeValueTaskReturningMethodAsync();
int result = await vt;
int result2 = await vt;
// BAD: await параллельно (и по определению несколько раз)
ValueTask vt = SomeValueTaskReturningMethodAsync();
Task.Run(async () => await vt);
Task.Run(async () => await vt);
// BAD: использование GetAwaiter().GetResult(), если неизвестно о завершении
ValueTask vt = SomeValueTaskReturningMethodAsync();
int result = vt.GetAwaiter().GetResult();
Есть ещё один продвинутый паттерн, который программисты могут применять, надеюсь, только после аккуратного измерения и получения существенных преимуществ. Классы ValueTask/ValueTask
int bytesRead;
{
ValueTask readTask = _connection.ReadAsync(buffer);
if (readTask.IsCompletedSuccessfully)
{
bytesRead = readTask.Result;
}
else
{
using (_connection.RegisterCancellation())
{
bytesRead = await readTask;
}
}
}
Должен ли каждый новый метод асинхронного API возвращать ValueTask/ValueTask?
Если ответить кратко: нет, по умолчанию стоит по-прежнему выбирать Task/Task
Как подчёркивать выше, Task и Task
И всё-таки ValueTask/ValueTask
Что дальше с ValueTask и ValueTask?
Для системных библиотек .NET мы будем продолжать работать с методами, возвращающими Task/Task