[Перевод] ValueTask<TResult> — почему, зачем и как?

Предисловие к переводу

В отличие от научных статей, статьи данного типа сложно переводить «близко к тексту», приходится проводить довольно сильную адаптацию. По этой причине приношу свои извинения, за некоторую вольность, с моей стороны, в обращении с текстом исходной статьи. Я руководствуюсь лишь одной целью — сделать перевод понятным, даже если он, местами, сильно отклоняется от исходной статьи. Буду благодарен за конструктивную критику и правки / дополнения к переводу.


Введение

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

f47b3cblzfyn77xgh1odpwfgy5i.jpeg


Task

Task служит нескольким целям, но основная из них это «promise» — объект, представляющий возможность ожидать завершение какой-либо операции. Вы инициируете операцию и получаете Task. Этот Task будет завершен, когда завершиться сама операция. При этом, есть три варианта:


  1. Операция завершается синхронно, в потоке инициатора. Например, при выполнении доступа к некоторым данным, которые уже находятся в буфере.
  2. Операция выполняется асинхронно, но успевает завершиться к тому моменту, когда инициатор получит Task. К примеру, когда выполняется быстрый доступ к данным, которые еще не были буферизированы
  3. Операция выполняется асинхронно, и завершается после того как инициатор получил 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 continuation, 
        object state, 
        short token, 
        ValueTaskSourceOnCompletedFlags flags);
    TResult GetResult(short token);
}

GetStatus предназначен для использования в свойстве ValueTask.IsCompleted/IsCompletedSuccessfully — позволяет узнать завершилась ли операция, или нет (успешно или нет). 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 это отличный выбор, если:


  1. вы полагаете, что клиент вашего API будет выполнять ожидание напрямую,
  2. вашему API важно избегать дополнительных операций выделения памяти в куче, и 
  3. вы ожидаете что, либо операции будут часто завершаться синхронно, либо же у вас есть возможность эффективно использовать пулы, на случай асинхронного выполнения.

Не забывайте, что при добавлении квалификаторов 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# пользуется этим при реализации асинхронных итераторов, чтобы сделать их максимально эффективными с точки зрения работы с памятью.

97f1d3cf0e2a6bf007066eb60a789c31.png

© Habrahabr.ru