Как на самом деле работает Async/Await в C# (Часть 4)

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Так как оригинальная статья довольно объемная, я взял на себя смелость разбить ее на несколько независимых частей, более легких для перевода и восприятия.

Disclaimer: Я не являюсь профессиональным переводчиком, перевод подготовлен скорее для себя и коллег. Я буду благодарен за любые исправления и помощь в переводе, статья очень интересная давайте сделаем её доступной на русском языке.

  1. Часть 1: В самом начале…

  2. Часть 2: Асинхронная модель на основе событий (EAP)

  3. Часть 3: Появление Tasks (Асинхронная модель на основе задач (TAP)

  4. Часть 4: ...и ValueTasks

  5. Итераторы C# в помощь

    1. Async/await: Внутреннее устройство

      1. Преобразования компилятора

      2. SynchronizationContext и ConfigureAwait

      3. Поля в State Machine

  6. Заключение

...и ValueTasks

Task и по сей день остается рабочей лошадкой для асинхронности в .NET, новые методы, возвращающие Task и Task<TResult>, появляются в каждом релизе и регулярно используются во всей экосистеме. Однако Task — это класс, а это значит, что его создание связано с выделением ресурсов. По большей части, одно дополнительное выделение для долгоживущей асинхронной операции — это ничтожно мало и не окажет существенного влияния на производительность для всех операций, кроме самых чувствительных к производительности.

Однако, как было отмечено ранее, синхронное завершение асинхронных операций является довольно распространенным явлением. Stream.ReadAsync был введен для возврата Task<int>, но если вы читаете, скажем, из BufferedStream, есть большая вероятность того, что многие из ваших операций чтения будут завершены синхронно, поскольку вам просто нужно извлечь данные из буфера в памяти, а не выполнять системные вызовы и реальный ввод-вывод. Необходимость выделять дополнительный объект только для возврата таких данных вызывает сожаление (обратите внимание, что так было и с APM). Для не дженерик методов, возвращающих Task, метод может просто вернуть синглтон уже завершенной задачи, и на самом деле один такой синглтон предоставляется Task в виде Task.CompletedTask. Но для Task<TResult> невозможно кэшировать Task для каждого возможного TResult. Что мы можем сделать, чтобы ускорить такое синхронное завершение?

Можно кэшировать некоторые Task<TResult>. Например, Task<bool> очень распространен, и есть только две значимые вещи, которые можно кэшировать: Task, когда результат истинен, и один, когда результат ложен. Или, хотя мы не хотели бы пытаться кэшировать четыре миллиарда Task<int> для каждого возможного результата Int32, маленькие значения Int32 встречаются очень часто, поэтому мы могли бы кэшировать несколько, скажем, от -1 до 8. Или для произвольных типов достаточно распространенным значением является default, поэтому мы можем кэшировать Task<TResult>, где Result будет default(TResult) для каждого соответствующего типа.

И на самом деле, Task.FromResult делает это сегодня (в последних версиях .NET), используя небольшой кэш таких многократно используемых синглтонов Task<TResult> и возвращая один из них, если это необходимо, или выделяя новый Task<TResult> для точного предоставленного значения результата. Другие схемы могут быть созданы для обработки других достаточно распространенных случаев. Например, при работе с Stream.ReadAsync достаточно часто приходится вызывать его несколько раз на одном и том же потоке, все с одинаковым количеством байт, разрешенных для чтения. И вполне обычно, что реализация может полностью удовлетворить этот запрос на подсчет. Это означает, что Stream.ReadAsync может неоднократно возвращать одно и то же значение результата int. Чтобы избежать многократного выделения ресурсов в таких сценариях, несколько типов Stream (например, MemoryStream) будут кэшировать последний Task<int>, который они успешно вернули, и если следующее чтение завершится также синхронно и успешно с тем же результатом, он может просто вернуть тот же Task<int> снова, а не создавать новый. Но как насчет других случаев? Как можно избежать такого распределения для синхронных завершений в ситуациях, когда накладные расходы на производительность действительно имеют значение?

Вот тут-то и появляется ValueTask<TResult> (более подробное рассмотрение ValueTask также доступно). ValueTask<TResult> начал свою жизнь как дискриминированный союз между TResult и Task<TResult>. В конце концов, не обращая внимания на все эти «колокольчики и свистки», это все, чем он является (или, скорее, был), либо немедленным результатом, либо обещанием результата в какой-то момент в будущем:

public readonly struct ValueTask<TResult>
{
   private readonly Task<TResult>? _task;
   private readonly TResult _result;
   ...
}

Тогда метод может вернуть такую ValueTask<TResult> вместо Task<TResult>, и за счет наличия лучшего возвращаемого типа и меньшей неопределенности избежать выделения Task<TResult>, если TResult был известен к тому моменту, когда его нужно было вернуть.

Однако существуют супер-пупер экстремальные высокопроизводительные сценарии, когда вы хотите иметь возможность избежать выделения Task даже в случае асинхронного завершения. Например, Socket находится в самом низу сетевого стека, а SendAsync и ReceiveAsync на сокетах находятся на супер горячем пути для многих сервисов, причем как синхронные, так и асинхронные завершения очень распространены (большинство отправлений завершаются синхронно, а многие получения завершаются синхронно из-за того, что данные уже были буферизованы в ядре). Разве не было бы здорово, если бы на данном Socket мы могли сделать такую отправку и получение свободными от выделения ресурсов, независимо от того, завершаются ли операции синхронно или асинхронно?

Именно здесь в дело вступает System.Threading.Tasks.Sources.IValueTaskSource:

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

Интерфейс IValueTaskSource позволяет реализации предоставлять свой собственный объект-подложку для ValueTask, позволяя объекту реализовать такие методы, как GetResult для получения результата операции и OnCompleted для подключения продолжения операции. При этом ValueTask претерпел небольшое изменение в своем определении: его поле Task<TResult>? заменено на object? _obj:

public readonly struct ValueTask<TResult>
{
   private readonly object? _obj;
   private readonly TResult _result;
   ...
}

Если поле _task было либо Task, либо null, то поле _obj теперь также может быть IValueTaskSource. Как только Task помечен как завершенный, все, он останется завершенным и никогда не перейдет обратно в состояние незавершенности. Напротив, объект, реализующий IValueTaskSource, имеет полный контроль над реализацией и может свободно переходить двунаправленно между завершенным и незавершенным состояниями, но так как контракт ValueTask заключается в том, что данный экземпляр может быть потреблен только один раз, поэтому по конструкции он не должен наблюдать изменения в базовом экземпляре после потребления (именно поэтому существуют такие правила анализа, как CA2012). Это позволяет таким типам как Socket накапливать экземпляры IValueTaskSource для использования при повторных вызовах. Socket кэширует до двух таких экземпляров, один для чтения и один для записи, так как в 99,999% случаев одновременно в процессе работы находится не более одного приема и одной отправки.

Я упомянул ValueTask<TResult>, но не ValueTask. Если речь идет только об избежании выделения для синхронного завершения, то не дженерк ValueTask (представляющий операции без результата, void операции) имеет мало преимуществ в производительности, поскольку то же условие можно представить с помощью Task.CompletedTask. Но как только нам становится важна возможность использования пула базовых объектов для избежания выделения в случае асинхронного завершения, это также имеет значение и для не дженерика. Таким образом, когда появился IValueTaskSource<TResult>, появились IValueTaskSource и ValueTask.

Итак, у нас есть Task, Task<TResult>, ValueTask и ValueTask<TResult>. Мы можем взаимодействовать с ними различными способами, представляя произвольные асинхронные операции и подключая продолжения для обработки завершения этих асинхронных операций. И да, мы можем делать это до или после завершения операции.

Но... эти продолжения все еще являются обратными вызовами!

Мы все еще вынуждены использовать стиль продолжения-прохождения для кодирования нашего асинхронного потока управления!!!

Это все еще очень трудно сделать правильно!!!

Как мы можем это исправить????

Источник: https://habr.com/ru/articles/729296/


Интересные статьи

Интересные статьи

Ирландия — это очень красивая и очень маленькая страна, которая мало у кого ассоциируется с IT. А между прочим, из-за невысоких налогов здесь довольно много компаний, которые связаны с технологиями и ...
В прошлых частях цикла мы:- рассмотрели базовые концепты работы с многопоточностью в JavaScript на примере среды Node.js;- научились формировать общую очередь и каналы обмена данными и сигналами, чтоб...
Практически вся наша еда содержит в себе какой-либо окрашивающий элемент, и он совершенно необязательно был добавлен в нее искусственно. Как раз таки наоборот, самые окрашивающие продукты - натуральны...
В предыдущей публикации мы рассмотрели некоторые базовые вопросы относительно потоков и пулов потоков и готовы двигаться дальше. Давайте проведём эксперимент и найдём правильный объём работы для пула ...
В статье рассмотрено понятие «соединение» для TCP и UDP протоколов в ядре операционной системы Linux на примере работы оборудования MikroTik. Дополнительно рассматриваются особенности работы техноло...