[Перевод] Почему, зачем и когда нужно использовать ValueTask

Этот перевод появился благодаря хорошему комментарию 0×1000000.

image

В .NET Framework 4 появилось пространство System.Threading.Tasks, а с ним и класс Task. Этот тип и порождённый от него Task долго дожидались, пока их признают стандартами в .NET в роли ключевых аспектов модели асинхронного программирования, которая была представлена в C# 5 с его операторами async/await. В этой статье я расскажу о новых типах ValueTask/ValueTask, разработанных для улучшения производительности асинхронных методов в случаях, когда издержки на выделение памяти нужно принимать во внимание.


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 потребует сотни гигабайт памяти. Среда обеспечивает небольшой кеш для Task, но сильно ограниченного набора значений, например, если этот метод завершится синхронно (данные уже в буфере) с возвращаемым значением 4, это будет кешированный Task, но если возвращается значение 42, то нужно будет создать новый Task, подобно вызову Task.FromResult (42).

Многие методы библиотеки пытаются сгладить это путём обеспечения собственного кеша. Например, перегрузка в.NET Framework 4.5 метода MemoryStream.ReadAsync всегда завершается синхронно, так как читает данные из памяти. ReadAsync возвращаетTask, где результат типа Int32 показывает сколько байт было прочитано. Этот метод часто используется в цикле, часто с одним и тем же требуемым количеством байтов при каждом вызове, и часто эта потребность удовлетворяется в полном объёме. Так что для повторных вызовов ReadAsync обоснованно ожидать, что Task будет синхронно возвращаться с таким же значением, как и в прошлом вызове. Поэтому MemoryStream создаёт кеш для одного объекта, который вернулся в последнем успешном вызове. И в следующем вызове, если результат повторится, он вернёт кешированный объект, а если нет, создаст новый с Task.FromResult, сохранит в кеш и вернёт его.

И всё же есть множество других случаев, когда операция выполняется синхронно, но объект Task вынужденно создаётся.


ValueTask и синхронное выполнение

Всё это потребовало реализации в .NET Core 2.0 нового типа, который доступен в предыдущих версиях .NET в пакете NuGet System.Threading.Tasks.Extensions: ValueTask.
ValueTask создан в .NET Core 2.0 как структура, способная обернуть как TResult, так и Task. Это означает, что её можно возвращать из async метода, и, если этот метод выполнится синхронно и успешно, никакого объекта в куче размещать не надо: вы можете просто инициализировать эту структуру ValueTask значением TResult и вернуть. Только в случае асинхронного выполнения объект Task будет размещён, а ValueTask обернёт его (чтобы минимизировать размер структуры и оптимизировать случай успешного исполнения, async метод, который завершается с неподдерживаемым исключением, также будет размещать Task, так что ValueTask так же просто обернёт Task, а не будет таскать с собой дополнительное поле для хранения Exception).

Исходя из этого, метод наподобие 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 был добавлен в .NET Core 2.0, и новые методы, которые вероятно будут использоваться в приложениях, требующих производительности, теперь объявляются с возвращением ValueTask вместо Task. Например, когда мы добавили новую перегрузку ReadAsync класса Stream в .NET Core 2.1, для того чтобы иметь возможность передавать Memory вместо byte[], мы возвращаем в нём тип ValueTask. В таком виде объекты Stream (в которых очень часто метод ReadAsync исполняется синхронно, как в ранее приведённом примере для MemoryStream) могут использоваться со значительно меньшим выделением памяти.

Однако, когда мы работаем с сервисами с очень высокой пропускной способностью, мы по-прежнему хотим избежать выделения памяти насколько вообще это возможно, а это означает уменьшение и устранение выделения памяти также и по маршруту асинхронного исполнения.
В модели await для любой операции, завершающейся асинхронно, нам необходима способность вернуть объект, который представляет возможное завершение операции: вызывающему необходима переадресация callbackа, который будет инициирован по завершению операции, и это требует наличия уникального объекта в куче, который может послужить как канал передачи для этой конкретной операции. Это, в то же время, не означает ничего будет ли этот объект использован повторно после завершения операции. Если этот объект может быть использован повторно, API может организовать кеш для одного или нескольких таких объектов, и применять его для последовательных операций, в смысле не использовать один и тот же объект для нескольких промежуточных async операций, но использовать для неконкуррентного доступа.
В .NET Core 2.1 класс ValueTaskбыл доработан для поддержки подобной работы с пулами и повторного использования. Вместо того, чтобы просто оборачивать TResult или Task, доработанный класс может оборачивать новый интерфейс IValueTaskSource. Этот интерфейс обеспечивает основную функциональность, которая требуется для сопровождения асинхронной операции объектом ValueTask так же, как это делает Task:

public interface IValueTaskSource
{
    ValueTaskSourceStatus GetStatus(short token);
    void OnCompleted(Action continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags);
    TResult GetResult(short token);
}

Метод GetStatus используется, чтобы реализовать свойства подобные ValueTask.IsCompleted, которое возвращает информацию выполняется асинхронная операция или завершена, и как завершена (успешно или нет). Метод OnCompleted используется ожидающим объектом для присоединения callbackа, чтобы продолжить выполнение с точки await, когда операция завершится. А метод GetResult нужен для получения результата операции, так что после окончания операции вызвавший метод может получить объект TResult или передать любое исключение, которое было выброшено.

Большинству разработчиков этот интерфейс не нужен: методы просто возвращают объект ValueTask, который может быть создан как обёртка объекта, реализующего этот интерфейс, и вызывающий метод будет останется в неведении. Этот интерфейс для разработчиков, которым требуется избежать выделения памяти при использовании API, критичного к производительности.

Существует несколько примеров такого API в .NET Core 2.1. Наиболее известные методы — это Socket.ReceiveAsync и Socket.SendAsync с новыми перегрузками, добавленными в 2.1, например

public ValueTask ReceiveAsync(Memory buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);

Эта перегрузка возвращает ValueTask. Если операция завершается синхронно, она может просто вернуть 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 появился в .NET Core 2.0, в нём оптимизирован был только случай синхронного выполнения, для того чтобы исключить размещение объекта Task, если значение TResult уже готово. Это означало, что необобщённый класс ValueTask был не нужен: для случая синхронного выполнения синглтон Task.CompletedTask мог быть просто возвращён из метода, и это делалось средой неявно в async методах, возвращающих Task.

Тем не менее, с получением асинхронных операций без выделения памяти использование необобщённого ValueTask снова стало актуальным. В .NET Core 2.1 мы представили необобщённые ValueTask и IValueTaskSource. Они обеспечивают прямые эквиваленты для обобщённых версий, для аналогичного использования, только с пустым возвращаемым значением.


Реализация IValueTaskSource/IValueTaskSource

Большинство разработчиков не должно реализовывать эти интерфейсы. К тому же это не так уж легко. Если вы решите сделать это, несколько реализаций в .NET Core 2.1 могут послужить отправной точкой, например:


  • AwaitableSocketAsyncEventArgs
  • AsyncOperation
  • DefaultPipeReader

Чтобы сделать это легче, в .NET Core 3.0 мы планируем представить всю необходимую логику, включённую в тип ManualResetValueTaskSourceCore, структуру, которая может быть встроена в другой объект, который реализует IValueTaskSource и/или IValueTaskSource, чтобы можно делегировать в эту структуру основную часть функциональности. Об этом можно больше узнать из https://github.com/dotnet/corefx/issues/32664 в репозитории dotnet/corefx.


Паттерны применения ValueTasks

На первый взгляд область применения ValueTask и ValueTask намного более ограничена, чем Task и Task. Это хорошо, и даже ожидаемо, так как основной способ их применения — просто использование с оператором await.

Однако, так как они могут оборачивать объекты, которые повторно используются, существуют значительные ограничения по их применению в сравнении с Task и Task, если отклониться от обычного способа простого await. В общих случаях, следующие операции никогда не должны выполняться с ValueTask/ValueTask:


  • Повторное ожидание ValueTask/ValueTask Объект результата может уже быть утилизирован и использоваться в другой операции. Напротив, Task/Task никогда не переходит из завершённого состояния в незавершённое, поэтому вы можете повторно ожидать его столько раз, сколько потребуется, и получать один и тот же результат каждый раз.
  • Параллельное ожидание ValueTask/ValueTask Объект результата ожидает обработки только одним callbackом от одного потребителя в один момент времени, и попытка его ожидания из разных потоков одновременно может легко привести к гонкам и трудноуловимым ошибкам программы. Кроме того, это также более специфичный случай предыдущей недопустимой операции «повторное ожидание». В сравнении с этим, Task/Task обеспечивает любое количество параллельных awaitов.
  • Использование .GetAwaiter ().GetResult (), когда операция ещё не завершилась Реализация IValueTaskSource/IValueTaskSource не нуждается в поддержке блокировки до окончания операции, и, скорее всего, не сделает этого, так что такая операция определённо приведёт к гонкам и вероятно будет выполнена не так, как ожидает вызывающий метод. Task/Task блокирует вызывающий поток пока задача не будет выполнена.

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

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

// Дан такой метод, возвращающий 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 имеют несколько свойств, которые сообщают о текущем состоянии операции, например, свойство IsCompleted возвращает true, если операция выполнилась (то есть, больше не выполняется и завершилась успешно или не успешно), а свойство IsCompletedSuccessfully возвращает true, только если завершилась успешно (при ожидании и получении результата не выбросило исключения). Для самых напряжённых потоков выполнения, там, где разработчик хочет избежать затрат, которые появляются при асинхронном режиме, эти свойства могут быть проверены перед операцией, которая фактически разрушит объект ValueTask/ValueTask, например await, .AsTask (). Например, в реализации SocketsHttpHandler в .NET Core 2.1 код выполняет чтение из соединения и получает 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, и до тех пор, пока требования производительность не перевешивают требования практичности, Task и Task предпочтительны. Кроме того, есть небольшие затраты, связанные с возвращением ValueTask вместо Task, то есть, микробенчмарки показывают, что await Task выполняется быстрее, чем await ValueTask. Так что, если вы используете кеширование задач, например, ваш метод возвращает Task или Task, для производительности стоит остаться с Task или Task. Объекты ValueTask/ValueTask занимают в памяти несколько слов, поэтому, когда они ожидаются, и поля для них резервируются в вызывающей async метод машине состояний, они будут занимать в ней больше памяти.

И всё-таки ValueTask/ValueTask будут отличным выбором когда: а) вы ожидаете, что вызывать ваш метод будут только с await, б) затраты на выделение памяти критичны для вашей разработки, в) синхронное выполнение будет происходить часто, или вы сможете эффективно повторно использовать объекты при асинхронном выполнении. При добавлении абстрактных, виртуальных или интерфейсных методов вы должны рассмотреть будут ли так же выполняться эти условия при перегрузке/реализации этих методов.


Что дальше с ValueTask и ValueTask?

Для системных библиотек .NET мы будем продолжать работать с методами, возвращающими Task/Task, но методы, возвращающие ValueTask/ValueTask, так же будут добавляться там, где это необходимо. Один ключевой пример таких методов — это новый IAsyncEnumerator, который должен появиться в .NET Core 3.0. IEnumerator имеет метод MoveNext, который возвращает bool, и его асинхронный аналог IAsyncEnumerator предоставляет метод MoveNextAsync. Когда мы начали его разрабатывать, думали, что он должен возвращать Task, который может быть очень эффективен кешированными задачами при частом завершении в синхронном режиме. Однако, в виду того, как разнообразны могут быть асинхронные перечислимые типы, как разнообразны могут быть реализации этого интерфейса (и для некоторых из них очень критичны проблемы производительности и выделения памяти), и что основным способом их использования будет await в конструкции foreach, мы остановились на варианте с ValueTask. Это позволит операциям с синхронным завершением выполняться быстро, а для асинхронного завершения будет доступна возможность экономии памяти. И на самом деле компилятор C# использует эти преимущества, когда создаёт асинхронные итераторы, реализуя их без выделения памяти, если это возможно.

© Habrahabr.ru