Найти тему
using Dev

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

Ввод Task

В .NET Framework 4.0 появился тип System.Threading.Tasks.Task. По своей сути, a Task - это просто структура данных, которая представляет собой конечное завершение некоторой асинхронной операции (другие фреймворки называют подобный тип ”promise" или “future”). A Task создается для представления некоторой операции, и затем, когда операция, которую он логически представляет, завершается, результаты сохраняются в нем Task. Достаточно просто. Но ключевая функция, которую предоставляет Task, которая делает его более полезным, чем IAsyncResult, заключается в том, что он включает в себя понятие продолжения. Эта функция означает, что вы можете подойти к любому Task и попросить асинхронно получать уведомления по завершении, при этом сама задача обрабатывает синхронизацию, чтобы гарантировать, что продолжение вызывается независимо от того, завершена ли задача уже, еще не завершена или завершается одновременно с запросом уведомления. Почему это так эффективно? Что ж, если вы помните наше обсуждение старого шаблона APM, было две основные проблемы.

  1. Вам приходилось реализовывать пользовательскую IAsyncResult реализацию для каждой операции: не было встроенной IAsyncResult реализации, которую каждый мог бы просто использовать для своих нужд.
  2. Перед вызовом метода Begin вы должны были знать, что вы хотите сделать после его завершения. Это усложняет реализацию комбинаторов и других обобщенных подпрограмм для использования и составления произвольных асинхронных реализаций.

В отличие от Task, это совместное представление позволяет перейти к асинхронной операции после того, как вы уже инициировали операцию, и обеспечить продолжение после того, как вы уже инициировали операцию… вам не нужно предоставлять это продолжение для метода, который инициирует операцию. Каждый, у кого есть асинхронные операции, может создавать Task, и каждый, кто использует асинхронные операции, может использовать Task, и ничего нестандартного не нужно делать, чтобы соединить их: Task становится языком общения, позволяющим производителям и потребителям асинхронных операций общаться. И это изменило .NET. Подробнее об этом чуть позже…

А пока давайте лучше поймем, что это на самом деле означает. Вместо того, чтобы погружаться в сложный код для Task, мы поступим педагогически и просто реализуем простую версию. Это не должна быть отличная реализация, скорее, она достаточно функционально полна, чтобы помочь понять суть того, что такое Task, которая, в конце концов, на самом деле является просто структурой данных, которая управляет координацией настройки и приема сигнала завершения. Мы начнем всего с нескольких полей:

-2

Нам нужно поле, чтобы узнать, завершена ли задача (_completed), и нам нужно поле для хранения любой ошибки, которая привела к сбою задачи (_error); если бы мы также реализовывали универсальный метод MyTask<TResult>, там также было бы private TResult _result поле для хранения успешного результата операции. Пока что это очень похоже на нашу обычную IAsyncResult реализацию ранее. Но теперь о различии - _continuation поле. В этой простой реализации мы поддерживаем только одно продолжение, но этого достаточно для пояснительных целей (в реальности Task используется object поле, которое может быть либо отдельным объектом продолжения, либо List<> группой объектов продолжения). Это делегат, который будет вызван по завершении задачи.

Как отмечалось, одним из фундаментальных достижений в Task по сравнению с предыдущими моделями была возможность выполнять работу по продолжению (обратный вызов) после инициирования операции. Нам нужен метод, позволяющий это сделать, поэтому давайте добавим ContinueWith:

-3

Если задача уже отмечена как выполненная к моменту ContinueWith вызова, ContinueWith просто поставьте выполнение делегата в очередь. В противном случае метод сохраняет делегат, так что продолжение может быть поставлено в очередь по завершении задачи (он также сохраняет нечто, называемое ExecutionContext, а затем использует это при последующем вызове делегата, но пока не беспокойтесь об этой части… мы доберемся до этого). Достаточно просто.

Затем нам нужно иметь возможность пометить MyTask как завершенную, что означает, что любая асинхронная операция, которую она представляет, завершена. Для этого мы представим два метода: один для обозначения успешного завершения (“SetResult”), а другой для обозначения завершения с ошибкой (“SetException”):

-4

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

Наконец, нам нужен способ распространять любое исключение, которое могло возникнуть в задаче (и, если бы это было общее исключение MyTask<T>, возвращать его _result); для облегчения определенных сценариев мы также разрешаем этому методу блокировать ожидание завершения задачи, что мы можем реализовать в терминах ContinueWith (продолжение просто сигнализирует ManualResetEventSlim, что вызывающий затем блокирует ожидание завершения).

-5

И это, в основном, все. Теперь, чтобы быть уверенным, реальный Task намного сложнее, с гораздо более эффективной реализацией, с поддержкой любого количества продолжений, с множеством ручек о том, как это должно себя вести (например, должны ли продолжения ставиться в очередь, как это делается здесь, или они должны вызываться синхронно как часть завершения задачи), с возможностью хранить несколько исключений, а не только одно, со специальными знаниями об отмене, с множеством вспомогательных методов для выполнения обычных операций (например, Task.Run который создает Task для представления делегата, поставленного в очередь для вызова в пуле потоков), и так далее. Но ни в чем из этого нет никакой магии; по сути, это просто то, что мы видели здесь.

Вы также можете заметить, что my simple MyTask имеет общедоступные SetResult/SetException методы непосредственно в нем, тогда как Task этого нет. На самом деле, у Task есть такие методы, они просто внутренние, с System.Threading.Tasks.TaskCompletionSource типом, служащим отдельным “производителем” для задачи и ее завершения; это было сделано не из технической необходимости, а как способ исключить методы завершения из вещи, предназначенной только для потребления. Затем вы можете раздать задачу Task, не беспокоясь о том, что она будет выполнена у вас из-под носа; сигнал завершения - это деталь реализации того, что создало задачу, а также оставляет за собой право завершить ее, сохранив TaskCompletionSource при себе. (CancellationToken и CancellationTokenSource следуйте аналогичному шаблону: CancellationToken это просто структурная оболочка для CancellationTokenSource, обслуживающая только общедоступную область, связанную с потреблением сигнала отмены, но без возможности его создания, что является возможностью, ограниченной для всех, у кого есть доступ к CancellationTokenSource.)

Конечно, мы можем реализовать для этого комбинаторы и помощники, MyTask аналогичные тому, что Task предоставляет. Хотите простого MyTask.WhenAll? Поехали:

-6

Хотите MyTask.Run? Получайте:

-7

Как насчет MyTask.Delay? Конечно:

-8

Вы поняли идею.

С появлением Task все предыдущие шаблоны async в .NET ушли в прошлое. Везде, где ранее была реализована асинхронная реализация с использованием шаблона APM или шаблона EAP, были представлены новые Task возвращающие методы.

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