Так как оригинальная статья довольно объемная, я взял на себя смелость разбить ее на несколько независимых частей, более легких для перевода и восприятия.
Disclaimer: Я не являюсь профессиональным переводчиком, перевод подготовлен скорее для себя и коллег. Я буду благодарен за любые исправления и помощь в переводе, статья очень интересная давайте сделаем её доступной на русском языке.
Часть 3: Появление Tasks (Асинхронная модель на основе задач (TAP)
-
Итераторы C# в помощь
-
Async/await: Внутреннее устройство
Преобразования компилятора
SynchronizationContext и ConfigureAwait
Поля в State Machine
-
Заключение
...и 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>. Мы можем взаимодействовать с ними различными способами, представляя произвольные асинхронные операции и подключая продолжения для обработки завершения этих асинхронных операций. И да, мы можем делать это до или после завершения операции.
Но... эти продолжения все еще являются обратными вызовами!
Мы все еще вынуждены использовать стиль продолжения-прохождения для кодирования нашего асинхронного потока управления!!!
Это все еще очень трудно сделать правильно!!!
Как мы можем это исправить????
Green21
Имхо. Не смотря на всю мою любовь к C#, все-таки async/await лучше начинать учить с JS, коллбэкс и промисов)). А потом проводить аналогии с Task-ами в C#.
s207883
Так и в c# можно передать колбэк и получить обратно "промис"
Gromilo
С точки зрения "пиши при вызове await" никакой проблемы с изучением нет.
Не помню, когда в работе мне нужно было что-то сложнее чем Task.WhenAll и Task.WhenAny.