Найти тему
using Dev

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

ValueTasks

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

Можно кэшировать некоторые Task<TResult> файлы. Например, Task<bool>очень распространено, и там можно кэшировать только две значимые вещи: a, Task<bool>когда Result true и одна, когда Result false. Или, хотя мы не хотели бы пытаться кэшировать четыре миллиарда 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<TResult>ValueTask<TResult> ). Начал свою жизнь как дискриминационный союз между a TResult и a Task<TResult>. В конце концов, не обращая внимания на все навороты, вот и все, что есть (или, скорее, было), либо немедленный результат, либо обещание результата в какой-то момент в будущем:

-2

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

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

Вот тут-то System.Threading.Tasks.Sources.IValueTaskSource<TResult>и появляется картина:

-3

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

-4

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

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

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

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

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

Как это исправить????

Источник to be continued...