Асинхронное программирование с использованием ключевых слов async и await
Модель асинхронного программирования на основе задач (TAP) предоставляет абстракцию асинхронного кода. Вы пишете код как последовательность операторов, как обычно. Вы можете читать этот код, как если бы каждая инструкция завершалась до начала следующей. Компилятор выполняет множество преобразований, так как некоторые из этих инструкций могут начать работу и вернуть Task, представляющий текущую работу.
Это цель этого синтаксиса: включение кода, который считывает как последовательность инструкций, но выполняется в гораздо более сложном порядке на основе выделения внешних ресурсов и завершения задач. Это аналогично тому, как люди дают инструкции для процессов, которые включают асинхронные задачи. В этой статье вы будете использовать пример инструкций по созданию завтрака, чтобы узнать, как async и await ключевые слова упрощают причину кода, включающего ряд асинхронных инструкций. Можно написать инструкции аналогично следующему списку, чтобы объяснить, как приготовить завтрак.
- Налить чашку кофе.
- Нагрейте кастрюлю, а затем жарите два яйца.
- Поджарить три куска бекона.
- Сделать два тоста.
- Намазать тосты маслом и джемом.
- Налить стакан апельсинового сока.
Если у вас есть кулинарный опыт, вы бы выполняли эти инструкции асинхронно. Сначала вы бы поставили сковородку на огонь, а затем занялись бы беконом. Потом бы поставили тосты, а вслед за этим принялись бы за яичницу. На каждом этапе процесса необходимо запустить задачу, а затем обратить внимание на другие задачи, которые требуют вашего внимания.
Приготовление завтрака представляет собой хороший пример асинхронной непараллельной работы. Один пользователь (или поток) может обрабатывать все эти задачи. Продолжая аналогию завтрака, один человек может заработать завтрак асинхронно, запустив следующую задачу до завершения первой задачи. Готовка продолжается вне зависимости от того, следит ли за ней кто-либо. Как только вы начали греть сковороду для яичницы, можно заняться обжаркой бекона. Когда бекон будет жариться, можно поместить хлеб в тостер.
Для параллельного алгоритма потребовалось бы несколько поваров (или потоков). Один готовит яйца, один — бекон и т. д. Каждый из них будет заниматься только одной задачей. Каждый кук (или нить) будет заблокирован синхронно ожидая, пока бекон будет готов к перевернуть, или всплывающее всплывающее уведомление.
Теперь рассмотрим эти же инструкции, написанные на C#.

Синхронно подготовленный завтрак занял примерно 30 минут, так как сумма каждой задачи составляет общую сумму.
Компьютеры не рассматривают эти инструкции так же, как люди. Компьютер будет задерживаться над каждой инструкцией до момента, когда работа будет завершена, прежде чем перейдет к следующему оператору. Вряд ли такой завтрак вас устроит. Последующие задачи не будут запущены до завершения предыдущих задач. Потребуется гораздо больше времени для приготовления завтрака, к тому же часть уже остынет еще до подачи.
Если требуется, чтобы компьютер асинхронно выполнил инструкции выше, необходимо писать асинхронный код.
Эти проблемы важны для программ, которые вы пишете уже сегодня. При написании клиентских программ требуется, чтобы пользовательский интерфейс реагировал на ввод данных пользователем. Приложения не должны блокировать телефон при скачивании данных из Интернета. При написании серверных программ не стоит блокировать потоки. Эти потоки могут обслуживать другие запросы. Использование синхронного кода в ситуации, когда существуют асинхронные альтернативы, мешает масштабированию с минимальными затратами. Вы платите за эти заблокированные потоки.
Успешные современные приложения требуют использования асинхронного кода. Без поддержки языком при написании асинхронного кода требуются обратные вызовы, события завершения или другие способы, заслоняющие исходное назначение кода. Преимуществом синхронного кода является то, что эти пошаговые действия упрощают проверку и анализ. Традиционные асинхронные модели заставляют сосредоточиваться на асинхронности кода, а не на фундаментальных действиях в нем.
Не блокировать, а использовать await
Приведенный выше код демонстрирует дурную практику: использование синхронного кода для выполнения асинхронных операций. В таком виде код блокирует выполняющий поток, не позволяя делать другие действия. Он не будет прерван, пока задачи выполняются. Все равно что стоять и смотреть на тостер, пока поджаривается хлеб. Пока тост не готов, вы всех игнорируете.
Давайте начнем менять этот код, чтобы не блокировать поток во время выполнения задачи. Ключевое слово await позволяет обойтись без блокировки для запуска задачи, а затем продолжить выполнение, когда задача завершается. Простая асинхронная версия кода для приготовления завтрака будет выглядеть так:
Общее затраченное время примерно такое же, как у начальной синхронной версии. Этот код можно улучшить, используя ряд ключевых возможностей асинхронного программирования.
Тексты методов FryEggsAsync , FryBaconAsync и ToastBreadAsync были обновлены так, чтобы возвращать Task<Egg> , Task<Bacon> и Task<Toast> , соответственно. Методы переименованы и теперь содержат суффикс «Async». Их реализации показаны в составе окончательной версии далее в этой статье.
Метод Main возвращается Task , несмотря на отсутствие return выражения— это по проектированию. Дополнительные сведения см. в описании оценки асинхронной функции, возвращающей void.
Этот код не блокируется при приготовлении яиц или бекона. Этот код, однако, не запускает других задач. По-прежнему придется поместить тост в тостер и смотреть на него, пока он не выскочит. Но по крайней мере можно отвечать всем, кто хочет вашего внимания. В ресторане, где будет размещаться несколько заказов, повар сможет начать готовить другой завтрак, пока первый готовится.
Теперь поток завтрака не блокируется в ожидании любой запущенной задачи, которая еще не завершена. Для некоторых приложений это изменение — все, что требуется. Приложение с графическим интерфейсом будет отвечать пользователю после этого изменения. Тем не менее в этом сценарии нам нужно больше. Нам не требуется последовательное выполнение каждой из задач компонента. Лучше запускать каждую из задач компонента, не ожидая завершения предыдущей задачи.
Одновременный запуск задач
Во многих случаях требуется запускать сразу несколько независимых задач. Затем, когда каждая задача завершается, можно продолжить другую работу, которая уже готова к этому. В нашей аналогии — так завтрак готовится быстрее. Вы также приготовите все примерно в одно и то же время. Вы получите горячий завтрак.
System.Threading.Tasks.Task и связанные типы — это классы, позволяющие делать выводы о задачах, которые находятся в процессе выполнения. Это позволяет писать код, более похожий на то, как вы создаете завтрак. Вы начинаете готовить яйца, бекон и тосты примерно в одно и то же время. Так как каждый из них требует действий, вы обратили свое внимание на эту задачу, позаботитесь о следующем действии, а затем подождите что-то другое, которое требует вашего внимания.
Вы начинаете задачу и удерживаете объект Task, представляющий работу. Вы вызываете await для каждой задачи, прежде чем начать работу с ее результатами.
Давайте внесем эти изменения в код для приготовления завтрака. Первым делом сохраним задачи для отдельных операций при их запуске, чтобы не ждать их:
Затем вы можете переместить инструкции await для бекона и яиц в конец метода, сразу перед подачей завтрака:

Асинхронное приготовление завтрака заняло примерно 20 минут. Это позволило сэкономить время, так как некоторые задачи можно было выполнять параллельно.
Предыдущий код работает лучше. Запуск всех асинхронных задач выполняется за один раз. Вы ожидаете каждую задачу только в том случае, когда требуются результаты. Приведенный выше код может быть похож на код в веб-приложении, который выполняет запросы к разным микрослужбам, а затем объединяет результаты в одну страницу. Вы отправляете все запросы сразу, а затем вызываете await , чтобы соединить все задачи и создать веб-страницу.
Сочетаемость задач
У вас все готово для завтрака в одно и то же время, за исключением тостов. Приготовление тоста — композиция асинхронной операции (поджарить хлеб) и синхронной операции (добавить масло и джем). Обновление этого кода иллюстрирует важную концепцию:
композиция асинхронной операции, за которой следует синхронная задача, является асинхронной операцией. Говоря иначе, если какая-либо часть операции является асинхронной, то и вся операция является асинхронной.
Приведенный выше код показал, что можно использовать объекты Task или Task<TResult> для хранения выполняемых задач. Вы вызываете await для каждой задачи, прежде чем использовать ее результат. Следующим шагом является создание методов, которые представляют сочетание другой работы. Перед подачей завтрака требуется дождаться задачи, представляющей поджарку хлеба перед добавлением масла и джема. Вы можете представить эту работу следующим кодом:
Предыдущий метод имеет async модификатор в сигнатуре. Он сообщает компилятору, что этот метод содержит инструкцию await ; она содержит асинхронные операции. Этот метод представляет задачу, в рамках которой поджаривается хлеб, а затем добавляется масло и джем. Этот метод возвращает Task<TResult>, представляющий сочетание этих трех операций. Теперь вид основного блока кода будет таким:
Предыдущее изменение показывает важную методику для работы с асинхронным кодом. Составные задачи можно создавать, разделяя операции в новом методе, который возвращает задачу. Вы можете выбрать, когда следует ожидать выполнения созданной задачи. Одновременно можно запускать другие задачи.
Асинхронные исключения
До этого момента вы неявно предполагали, что все эти задачи были выполнены успешно. Асинхронные методы создают исключения, точно так же, как и синхронные методы. В целом поддержка исключений и обработки ошибок в асинхронном коде преследует те же цели, что и поддержка асинхронного кода в целом: необходимо написать код, который выглядит как последовательность синхронных инструкций. Когда задачи не могут быть успешно завершены, они выдают исключения. Клиентский код может перехватывать эти исключения, когда запущенная задача ожидается ( awaited ). Например, предположим, что тостер загорается во время приготовления тоста. Это можно смоделировать, изменив метод ToastBreadAsync следующим образом:
При компиляции предыдущего кода будет выдано предупреждение о наличии недостижимого кода. Это сделано намеренно, поскольку после того, как тостер загорится, дальнейшие операции не будут выполняться обычным образом.
Запустите приложение после внесения этих изменений, и вы получите следующий результат:
Вы заметите, что довольно много задач завершено между тем, когда тостер загорается и наблюдается исключение. Если задача, которая выполняется асинхронно, создает исключение, эта задача завершается ошибкой. Созданное исключение находится в свойстве Task.Exception объекта Task. Задачи, завершившиеся с ошибкой, выдают исключение, когда эти задачи ожидаются.
Существует два важных механизма, работу которых нужно понимать: как исключение хранится в задаче, завершившейся с ошибкой, и как оно распаковывается и выдается повторно, когда код ожидает задачу, завершившуюся с ошибкой.
Когда асинхронный код выдает исключение, это исключение хранится в Task . Свойство Task.Exception имеет значение System.AggregateException, потому что во время асинхронного выполнения может быть выдано несколько исключений. Все выданные исключения добавляются в коллекцию AggregateException.InnerExceptions. Если это свойство Exception имеет значение null, то создается новое исключение AggregateException , и выданное исключение становится первым элементом в коллекции.
Чаще всего для задачи, завершившейся с ошибкой, свойство Exception содержит только одно исключение. Когда код ожидает ( awaits ) задачу, завершившуюся сбоем, первое исключение в коллекции AggregateException.InnerExceptions выдается повторно. Поэтому в выходных данных этого примера мы видим InvalidOperationException вместо AggregateException . Извлечение первого внутреннего исключения делает работу с асинхронными методами настолько похожей на работу с синхронными методами, насколько это возможно. Если ваш сценарий может создать несколько исключений, вы можете проверить свойство Exception в коде.
Перед тем как продолжить, закомментируйте эти две строки в методе ToastBreadAsync . Ведь вы не хотите, чтобы у вас появилась еще одна проблема:
Эффективное ожидание задач
Ряд инструкций await в конце приведенного выше кода можно улучшить с помощью методов класса Task . Один из этих API — WhenAll, который возвращает Task; она завершается после завершения всех задач в списке аргументов, как показано в следующем коде:
Другой вариант — использовать WhenAny, который возвращает Task<Task> значение, которое завершается после завершения любого из его аргументов. Можно ожидать возвращенной задачи, зная, что она уже завершена. В следующем коде показано, как использовать WhenAny для ожидания первой задачи, чтобы затем обработать ее результат. После обработки результата завершенной задачи удалим ее из списка задач, передаваемого в WhenAny .
После всех этих изменений окончательная версия кода выглядит так:

Окончательная версия асинхронного приготовления завтрака заняла примерно 15 минут, так как в этом случае некоторые задачи выполнялись параллельно. Также одновременно отслеживалось несколько задач из кода, и действия выполнялись только тогда, когда это было необходимо.
Этот итоговый код выполняется асинхронно. Он более точно отражает, как пользователь будет готовить завтрак. Сравните предыдущий код с первым примером кода в этой статье. Основные действия по-прежнему очевидны при прочтении. Этот код можно прочитать так же, как указания по приготовлению завтрака в начале этой статьи. Возможности языка для async и await делают возможными преобразования, которые любой человек производит, выполняя эти инструкции: запуск задач без блокировки в ожидании их завершения.
Aсинхронное программирование
Нередко программа выполняет такие операции, которые могут занять продолжительное время, например, обращение к сетевым ресурсам, чтение-запись файлов, обращение к базе данных и т.д. Такие операции могут серьезно нагрузить приложение. Особенно это актуально в графических (десктопных или мобильных) приложениях, где продолжительные операции могут блокировать интерфейс пользователя и негативно повлиять на желание пользователя работать с программой, или в веб-приложениях, которые должны быть готовы обслуживать тысячи запросов в секунду. В синхронном приложении при выполнении продолжительных операций в основном потоке этот поток просто бы блокировался на время выполнения операции. И чтобы продолжительные операции не блокировали общую работу приложения, в C# можно задействовать асинхронность.
Асинхронность позволяет вынести отдельные задачи из основного потока в специальные асинхронные методы и при этом более экономно использовать потоки. Асинхронные методы выполняются в отдельных потоках. Однако при выполнении продолжительной операции поток асинхронного метода возвратится в пул потоков и будет использоваться для других задач. А когда продолжительная операция завершит свое выполнение, для асинхронного метода опять выделяется поток из пула потоков, и асинхронный метод продолжает свою работу.
Ключевыми для работы с асинхронными вызовами в C# являются два оператора: async и await , цель которых — упростить написание асинхронного кода. Они используются вместе для создания асинхронного метода.
Асинхронный метод обладает следующими признаками:
В заголовке метода используется модификатор async
Метод содержит одно или несколько выражений await
В качестве возвращаемого типа используется один из следующих:
Асинхронный метод, как и обычный, может использовать любое количество параметров или не использовать их вообще. Однако асинхронный метод не может определять параметры с модификаторами out , ref и in .
Также стоит отметить, что слово async , которое указывается в определении метода, НЕ делает автоматически метод асинхронным. Оно лишь указывает, что данный метод может содержать одно или несколько выражений await .
Рассмотрим простейший пример определения и вызова асинхронного метода:
Здесь прежде всего определен обычный метод Print, который просто выводит некоторую строку на консоль. Для имитации долгой работы в нем используется задержка на 3 секунд с помощью метода Thread.Sleep() . То есть условно Print — это некоторый метод, который выполняет некоторую продолжительную операцию. В реальном приложении это могло бы быть обращение к базе данных или чтение-запись файлов, но для упрощения понимания он просто выводит строку на консоль.
Также здесь определен асинхронный метод PrintAsync() . Асинхронным он является потому, что имеет в определении перед возвращаемым типом модификатор async , его возвращаемым типом является Task, и в теле метода определено выражение await .
Стоит отметить, что явным образом метод PrintAsync не возвращает никакого объекта Task, однако поскольку в теле метода применяется выражение await , то в качестве возвращаемого типа можно использовать тип Task.
Оператор await предваряет выполнение задачи, которая будет выполняться асинхронно. В данном случае подобная операция представляет выполнение метода Print:
По негласным правилам в названии асинхроннных методов принято использовать суффикс Async — Print Async () , хотя в принципе это необязательно делать.
И затем в программе (в данном случае в методе Main) вызывается этот асинхронный метод.
Посмотрим, какой у программы будет консольный вывод:
Разберем поэтапно, что здесь происходит:
Запускается программа, а точнее метод Main, в котором вызывается асинхронный метод PrintAsync.
Метод PrintAsync начинает выполняться синхронно вплоть до выражения await.
Выражение await запускает асинхронную задачу Task.Run(()=>Print())
Пока выполняется асинхронная задача Task.Run(()=>Print()) (а она может выполняться довольно продожительное время), выполнение кода возвращается в вызывающий метод — то есть в метод Main.
Когда асинхронная задача завершила свое выполнение (в случае выше — вывела строку через три секунды), продолжает работу асинхронный метод PrintAsync, который вызвал асинхронную задачу.
После завершения метода PrintAsync продолжает работу метод Main.
Асинхронный метод Main
Стоит учитывать, что оператор await можно применять только в методе, который имеет модификатор async . И если мы в методе Main используем оператор await , то метод Main тоже должен быть определен как асинхронный. То есть предыдущий пример фактически будет аналогичен следующему:
Задержка асинхронной операции и Task.Delay
В асинхронных методах для остановки метода на некоторое время можно применять метод Task.Delay() . В качестве параметра он принимает количество миллисекунд в виде значения int, либо объект TimeSpan, который задает время задержки:
Причем метод Task.Delay сам по себе представляет асинхронную операцию, поэтому к нему применяется оператор await.
Преимущества асинхронности
Выше приведенные примеры являются упрощением, и вряд ли их можно считать показательным. Рассмотрим другой пример:
Данный код является синхронным и выполняет последовательно три вызова метода PrintName. Поскольку для имитации продолжительной работы в методе установлена задержка на три секунды, то общее выполнение программы займет не менее 9 секунд. Так как каждый последующий вызов PrintName будет ждать пока завершится предыдущий.
Изменим в программе синхронный метод PrintName на асинхронный:
Вместо метода PrintName теперь вызывается три раза PrintNameAsync. Для имитации продолжительной работы в методе установлена задержка на 3 секунды с помощью вызова Task.Delay(3000) . И поскольку при вызовае каждого метода применяется оператор await, который останавливает выполнение до завершения асинхронного метода, то общее выполнение программы опять же займет не менее 9 секунд. Тем не менее теперь выполнение асинхронных операций не блокирует основной поток.
Теперь оптимизируем программу:
В данном случае задачи фактически запускаются при определении. А оператор await применяется лишь тогда, когда нам нужно дождаться завершения асинхронных операций — то есть в конце программы. И в этом случае общее выполнение программы займет не менее 3 секунд, но гораздо меньше 9 секунд.
Опеределение асинхронного лямбда-выражения
Асинхронную операцию можно определить не только с помощью отдельного метода, но и с помощью лямбда-выражения:
Async/await в C#: концепция, внутреннее устройство, полезные приемы
Доброго времени суток. В этот раз поговорим на тему, в которой начинал разбираться каждый уважающий себя адепт языка C# — асинхронное программирование с использованием Task или, в простонародье, async/await. Microsoft проделали хорошую работу — ведь для того, чтобы использовать асинхронность в большинстве случаев нужно лишь знание синтаксиса и никаких других подробностей. Но если лезть вглубь, тема довольно объемная и сложная. Ее излагали многие, каждый в своем стиле. Есть очень много классных статей по этой теме, но все равно существует масса заблуждений вокруг нее. Постараемся исправить положение и разжевать материал настолько, насколько это возможно, не жертвуя ни глубиной, ни пониманием.
Рассматриваемые темы/главы:
- Концепция асинхронности — преимущества асинхронности и мифы о «заблокированном» потоке
- TAP. Синтаксис и условия компиляции — необходимые условия для написания компилирующегося метода
- Работа с применением TAP — механика и поведение программы в асинхронном коде (освобождения потоков, запуск задач и ожидание их завершения)
- За кулисами: машина состояний — обзор преобразований компилятора и сгенерированных им классов
- Истоки асинхронности. Устройство стандартных асинхронных методов — асинхронные методы для работы с файлами и сетью изнутри
- Классы и приемы при работе с TAP — полезные приемы, которые могут помочь с управлением и ускорением программы с применением TAP
Концепция асинхронности
Асинхронность сама по себе далеко не нова. Как правило, асинхронность подразумевает выполнение операции в стиле, не подразумевающем блокирование вызвавшего потока, то есть запуск операции без ожидания ее завершения. Блокирование — это не такое зло, как его описывают. Можно встретить утверждения, что заблокированные потоки зря расходуют процессорное время, работают медленнее и вызывают дождь. Последнее кажется маловероятным? На самом деле предыдущие 2 пункта такие же.
На уровне планировщика ОС, когда поток находится в состоянии «блокирован», ему не будет выделяться драгоценное процессорное время. Вызов планировщика, как правило, приходится на операции вызывающие блокировку, прерывания по таймеру и другие прерывания. То есть когда, например, контроллер диска завершит операцию чтения и инициирует соответствующее прерывание, запустится планировщик. Он будет решать, запускать поток, который был блокирован этой операцией, или какой-то другой с более высоким приоритетом.
Медленная работа кажется еще более абсурдной. Ведь по факту работа выполняется одна и та же. Только на выполнение асинхронной операции добавляются еще небольшие накладные расходы.
Вызов дождя — это вообще что-то не из этой области.
Основная проблема блокирования — неразумное потребление ресурсов компьютера. Даже если мы забудем о времени на создание потока и будем работать с пулом потоков, то на каждый заблокированный поток расходуется лишнее место. Ну и есть сценарии, где определенную работу может осуществлять только один поток (например, UI поток). Соответственно, не хотелось бы, чтобы он был занят задачей, которую может выполнить другой поток, жертвуя выполнением эксклюзивных для него операций.
Асинхронность — понятие весьма обширное и может достигаться многими путями.
В истории .NET можно выделить следующие:
- EAP (Event-based Asynchronous Pattern) — как следует из названия, поход основан на событиях, которые срабатывают по завершении операции и обычного метода, вызывающего эту операцию
- APM (Asynchronous Programming Model) — основан на 2 методах. Метод BeginSmth возвращает интерфейс IAsyncResult. Метод EndSmth принимает IAsyncResult (если к моменту вызова EndSmth операция не завершена, поток блокируется)
- TAP (Task-based Asynchronous Pattern) — тот самый async/await (если говорить строго, то эти слова появились уже после появления подхода и типов Task и Task<TResult>, но async/await значительно улучшил эту концепцию)
Task-based asynchronous pattern. Синтаксис и условия компиляции
Стандартный асинхронный метод в стиле TAP написать очень просто.
Для этого нужно:
- Чтобы возвращаемое значение было Task, Task<T> или void (не рекомендуется, рассмотрено далее). В C# 7 пришли Task-like типы (рассмотрены в последней главе). В C# 8 к этому списку добавляется еще IAsyncEnumerable<T> и IAsyncEnumerator<T>
- Чтобы метод был помечен ключевым словом async, а внутри содержал await. Это ключевые слова идут в паре. При этом если метод содержит await, обязательно его помечать async, обратное неверно, но бессмысленно
- Для приличия соблюдать конвенцию о суффиксе Async. Разумеется, компилятор за ошибку считать это не будет. Если вы ну очень приличный разработчик, то можете добавлять перегрузки с CancellationToken (рассмотрен в последней главе)
Было упомянуто, что метод должен содержать ключевое слово await. Оно (слово) указывает на необходимость асинхронного ожидания выполнения задачи, которую представляет тот объект задачи, к которому оно применяется.
Объект задачи, также имеет определенные условия, чтобы к нему можно было применить await:
- Ожидаемый тип должен иметь публичный (или internal) метод GetAwaiter(), это может быть и метод расширения. Этот метод возвращает объект ожидания
- Объект ожидания должен реализовать интерфейс INotifyCompletion, который обязывает реализовать метод void OnCompleted(Action continuation). Также он должен иметь экземплярные свойство bool IsCompleted, метод void GetResult(). Может быть как структурой, так и классом.
Работа с применением TAP
Сложно идти в дебри не понимая, как что-то должно работать. Рассмотрим TAP с точки зрения поведения программы.
По терминологии: рассматриваемый асинхронный метод, чей код будет рассматриваться, я буду называть асинхронный метод, а вызываемые асинхронные методы внутри него я буду называть асинхронная операция.
Возьмем наипростейший пример, в качестве асинхронной операции возьмем Task.Delay, который осуществляет задержку на указанное время, не блокируя поток.
Выполнение метода с точки зрения поведения происходит так.
- Выполняется весь код, предшествующий вызову асинхронной операции. В данном случае это метод BeforeCall
- Выполняется вызов асинхронной операции. На данном этапе поток не освобождается и не блокируется. Данная операция возвращает результат — упомянутый объект задачи (как правило Task), который сохраняется в локальную переменную
- Выполняется код после вызова асинхронной операции, но до ожидания (await). В примере — AfterCall
- Ожидание завершения на объекте задачи (который сохранили в локальную переменную) — await task.
Если асинхронная операция к этому моменту завершена, то выполнение продолжается синхронно, в том же потоке.
За кулисами. Машина состояний
На самом деле наш метод преобразовывается компилятором в метод заглушку, в которой происходит инициализация сгенерированного класса — машины состояний. Далее она (машина) запускается, а из метода возвращается объект Тask, используемый на шаге 2.
Особый интерес представляет метод MoveNext машины состояний. Данный метод выполняет то, что было до преобразования в асинхронном методе. Он разбивает код на части между каждым вызовом await. Каждая часть выполняется при определенном состоянии машины. Сам метод MoveNext присоединяется к объекту ожидания в качестве продолжения. Сохранение состояния гарантирует выполнение именно той его части, которая логически следовала за ожиданием.
Как говорится, лучше 1 раз увидеть, чем 100 раз услышать, поэтому настоятельно рекомендую ознакомиться с примером ниже. Я немного переписал код, улучшил именование переменных и щедро закомментировал.
Заостряю внимание на фразе «к этому моменту не выполнился синхронно». Асинхронная операция может пойти и по синхронному пути выполнения. Главное условие для того, чтобы текущий асинхронный метод выполнялся синхронно, то есть не меняя поток — это завершенность асинхронной операции на момент проверки IsCompleted.
Истоки асинхронности. Устройство стандартных асинхронных методов
Мы рассмотрели то, как выглядит метод, использующий async и await и что происходит за кулисами. Данная информация нередка. Но важно понимать и природу асинхронных операций. Потому что как мы видели в машине состояний в коде вызываются асинхронные операции, разве что их результат обрабатывается хитрее. Однако что происходит внутри самих асинхронных операций? Вероятно, то же самое, но это не может происходить до бесконечности.
Важной задачей является понимание природы асинхронности. При попытках понять асинхронность наблюдается чередование состояний «теперь понятно» и «теперь снова непонятно». И данное чередование будет до тех пор, пока не станет понятен исток асинхронности.
При работе с асинхронностью мы оперируем задачами. Это совсем не то же самое, что поток. Одна задача может выполняться многими потоками, а один поток может выполнять много задач.
Как правило, асинхронность начинается с метода, который возвращает Task (например), но не помечен async, соответственно не использует await внутри. Такой метод не терпит никаких компиляторных изменений, выполняется как есть.
Итак, рассмотрим несколько корней асинхронности.
- Task.Run, new Task(..).Start(), Factory.StartNew и ему подобные. Самый простой способ начать асинхронное выполнение. Данные способы просто создают новый объект задачи, передавая в качестве одного из параметров делегат. Задача передается планировщику, который дает ее на выполнение одному из потоков пула. Возвращается готовая задача, которую можно ожидать. Как правило, такой подход используется для начала вычислений (CPU-bound) в отдельном потоке
- TaskCompletionSource. Вспомогательный класс, который помогает контролировать объект задачи. Создан для тех, кто не может выделить делегат под выполнение и использует более сложные механизмы контроля завершенности. Имеет очень простое API — SetResult, SetError и тд, которые соответствующим образом обновляют задачу. Данная задача доступна через свойство Task. Возможно, внутри вы будете создавать потоки, иметь сложную логику по их взаимодействию или завершение по событию. Чуть больше деталей об этом классе будет в последнем разделе
Файлы
Важное замечание — при желании работать с файлами асинхронно требуется указать при создании FileStream useAsync = true.
В файлах все устроено нетривиально и запутанно. Класс FileStream объявлен как partial. И помимо него существует еще 6 partial дополнений, зависящих от платформы. Так, в Unix для асинхронного доступа в произвольный файл, как правило, используется синхронная операция в отдельном потоке. В Windows существуют системные вызовы для асинхронной работы, которые, разумеется, используются. Это приводит к различиям в работе на разных платформах. Исходники.
Стандартное поведение при записи или чтении — производить операцию синхронно, если буфер позволяет и стрим не занят другой операцией:
1. Стрим не занят другой операцией
В классе Filestream есть объект, унаследованный от SemaphoreSlim параметрами (1, 1) — то есть а-ля критическая секция — фрагмент кода, защищенный этим семафором, может выполнятся в одно время только одним потоком. Данный семафор используется как при чтении, так и при записи. То есть невозможно одновременно производить и чтение, и запись. При этом блокировки на семафоре не происходит. На нем вызывается метод this._asyncState.WaitAsync(), который возвращает объект задачи (блокировки или ожидания при этом нет, оно было бы, если бы к результату метода применили ключевое слово await). Если данный объект задачи не завершен — то есть семафор захвачен, то к возвращенному объекту ожидания присоединяется продолжение (Task.ContinueWith), в котором выполняется операция. Если же объект свободен, то нужно проверить следующее
2. Буфер позволяет
Тут уже поведение зависит от характера операции.
Для записи — проверяется, чтобы размер данных для записи + позиция в файле были меньше, чем размер буфера, который по умолчанию — 4096 байт. То есть мы должны писать 4096 байт с начала, 2048 байт со смещением в 2048 и тд. Если это так, то операция проводится синхронно, в противном случае присоединяется продолжение (Task.ContinueWith). В продолжении используется обычный синхронный системный вызов. При заполнении буфера он синхронно пишется на диск.
Для чтения — проверяется, достаточно ли данных в буфере для того, чтобы вернуть все необходимые данные. Если нет, то, опять же, продолжение (Task.ContinueWith) с синхронным системным вызовом.
Кстати, тут есть интересная деталь. В случае, если одна порция данных займет весь буфер, они будут записаны напрямую в файл, без участия буфера. При этом, есть ситуация, когда данных будет больше, чем размер буфера, но они все пройдут через него. Такое случается, если в буфере уже есть что-то. Тогда наши данные разделятся на 2 порции, одна заполнит буфер до конца и данные запишутся в файл, вторая будет записана в буфер, если влазит в него или напрямую в файл, если не влазит. Так, если мы создадим стрим и запишем в него 4097 байт то они сразу появятся в файле, без вызова Dispose. Если же мы запишем 4095, то в файле ничего не будет.
Под Windows алгоритм использования буфера и записи напрямую очень похож. Но существенное различие наблюдается в непосредственно в асинхронных системных вызовах записи и чтения. Если говорить без углубления в системные вызовы, то существует такая структура Overlapped. В ней есть важное нам поле — HANDLE hEvent. Это событие c ручным сбросом, которое переходит в сигнальное состояние по завершении операции. Возвращаемся к реализации. Запись напрямую, как и запись буфера используют асинхронные системные вызовы, которые используют вышеупомянутую структуру как параметр. При записи создается объект FileStreamCompletionSource — наследник TaskCompletionSource, в котором как раз указан IOCallback. Он вызывается свободным потоком из пула, когда операция завершается. В колбэке структура Overlapped разбирается и соответствующим образом обновляется объект Task. Вот и вся магия.
Сложно описать все, что я увидел разбираясь в исходниках. Мой путь лежал от HttpClient до Socket и до SocketAsyncContext для Unix. Общая схема такая же, как и с файлами. Для Windows используется упомянутая структура Overlapped и операция выполняется асинхронно. В Unix операции с сетью также используют функции обратного вызова.
И небольшое пояснение. Внимательный читатель заметит, что при использовании асинхронных вызовов между вызовом и колбэком некая пустота, которая каким-то образом работает с данными. Здесь стоит дать уточнение для полноты картины. На примере файлов — непосредственно вычислительными операции с диском производит контроллер диска, именно он дает сигналы о перемещении головок на нужный сектор и тд. Процессор же в это время свободен. Общение с диском происходит посредством портов ввода/вывода. В них указывается тип операции, расположение данных на диске и тд. Далее контроллер и диск занимаются выполнением этой операции и по завершению работы они генерируют прерывание. Соответственно, асинхронный системный вызов только вносит информацию в порты ввода/вывода, а синхронный еще и дожидается результатов, переводя поток в состояние блокировки. Данная схема не претендует на абсолютную точность (не об этом статья), но дает концептуальное понимание работы.
Теперь ясна природа процесса. Но у кого-то может возникнуть вопрос, а что делать с асинхронностью? Ведь невозможно вечно писать async над методом.
Во-первых. Приложение может быть сделано как служба. При этом точка входа — Main — пишется с нуля вами. До недавних пор Main не мог быть асинхронным, в 7 версии языка добавили такую возможность. Но ничего коренным образом оно не меняет, просто компилятор генерирует обычный Main, а из асинхронного делается просто статический метод, который вызывается в Main и синхронно ожидается его завершение. Итак, вероятнее всего у вас есть какие-то долгоживущие действия. Почему-то в этот момент многие начинают раздумывать как создавать потоков под это дело: через Task, ThreadPool или вообще Thread вручную, ведь в чем-то разница должна быть. Ответ прост — разумеется Task. Если вы используете подход TAP, то не надо мешать его с созданием потоков вручную. Это сродни использования HttpClient для почти всех запросов, а POST осуществлять самостоятельно через Socket.
Во-вторых. Веб приложения. Каждый поступающий запрос порождает вытягивание нового потока из ThreadPool для обработки. Пул, конечно, большой, но не бесконечный. В случае, когда запросов много, потоков на всех может не хватать, и все новые запросы будут ставиться в очередь на обработку. Такая ситуация называется голоданием. Но в случае использования асинхронных контроллеров, как обсуждалось ранее, поток возвращается в пул и может быть использован для обработки новых запросов. Таким образом существенно увеличивается пропускная способность сервера.
Мы рассмотрели асинхронный процесс с самого начала и до самого конца. И вооружившись пониманием всей этой асинхронности, противоречащей человеческой натуре, рассмотрим некоторые полезные приемы при работе с асинхронным кодом.
Полезные классы и приемы при работе с TAP
Статическое многообразие класса Task.
У класса Task есть несколько полезных статических методов. Ниже будут приведены основные из них.
- Task.WhenAny(..) — комбинатор, принимает IEnumerable/params объектов задач и возвращает объект задачи, который завершиться по завершении первой завершившейся из переданных задач. То есть позволяет дожидаться одной из нескольких запущенных задач
- Task.WhenAll(..) — комбинатор, принимает IEnumerable/params объектов задач и возвращает объект задачи, который завершиться по завершении всех переданных задач
- Task.FromResult<T>(T value) — возвращает это же значение, обернутое в завершенную задачу. Зачастую требуется при реализации существующих интерфейсов с асинхронными методами
- Task.Delay(..) — асинхронно ждет указанное время
- Task.Yield() — планирует продолжение. Как упоминалось выше, асинхронный метод может закончится и синхронно. В случае, если вызван этот метод, его продолжение будет выполнено асинхронно
ConfigureAwait
Естественно, самая популярная «продвинутая» особенность. Данный метод принадлежит классу Task и позволяет указать, необходимо ли нам выполнять продолжение в том же контексте, где была вызвана асинхронная операция. По умолчанию, без использования этого метода, контекст запоминается и продолжение ведется в нем с помощью упомянутого метода Post. Однако, как мы говорили, Post — весьма дорогое удовольствие. Поэтому, если производительность на 1-м месте, а мы видим, что продолжение не будет, скажем, обновлять UI, можно указать на объекте ожидания .ConfigureAwait(false). Это означает, что нам БЕЗРАЗЛИЧНО, где будет выполнено продолжение.
Теперь о проблеме. Как говорится страшно не незнание, а ложное знание.
Как-то довелось наблюдать код веб-приложения, где каждый асинхронный вызов был украшен сие ускорителем. Это не имеет никакого эффекта, кроме визуального отвращения. Стандартное веб-приложение ASP.NET Core не имеет каких-то уникальных контекстов (если вы их сами не напишете, конечно). Таким образом, метод Post там и так не вызывается.
TaskCompletionSource<T>
Класс, позволяющий легко управлять объектом Task. Класс имеет широкие возможности, но наиболее полезен, когда мы хотим обернуть в задачу некоторое действие, конец которого происходит по событию. Вообще, класс был создан для адаптации старых асинхронных методов под TAP, но как мы видели, используется он не только для этого. Небольшой пример работы с данным классом:
Данный класс создает асинхронную обертку для получения имени файла, к которому в текущей папке производился доступ.
CancellationTokenSource
Позволяет отменить асинхронную операцию. Общая схема напоминает использование TaskCompletionSource. Сначала создается var cts = new CancellationTokenSource(), который, кстати, IDisposable, затем в асинхронные операции передается cts.Token. Далее, следуя какой-то вашей логике, при определенных условиях вызывается метод cts.Cancel(). Это также может подписка на событие или что угодно другое.
Использование CancellationToken является хорошей практикой. При написании своего асинхронного метода, который делает некоторую работу в фоне, допустим в бесконечном while, можно просто вставить одну строку в тело цикла: cancellationToken.ThrowIfCancellationRequested(), которая выброит исключение OperationCanceledException. Это исключение трактуется как отмена операции и не сохраняется как исключение внутри объекта задачи. Также Свойство IsCanceled на объекте Task станет true.
LongRunning
Зачастую случаются ситуации, особенно при написании служб, когда вы создаете несколько задач, которые будут работать на протяжении всей жизни службы или просто весьма долго. Как мы помним, использование пула потоков обоснованно накладными расходами на создание потока. Однако если поток создается редко (да даже раз в час), то данные расходы нивелируются и можно смело создать отдельные потоки. Для этого при создании задачи можно указать специальную опцию:
Task.Factory.StartNew(action, TaskCreationOptions.LongRunning)
Да и вообще советую посмотреть на все перегрузки Task.Factory.StartNew, там есть много способов гибко настроить выполнение задачи под конкретные нужды.
Исключения
В связи с недетерминированной природой выполнения асинхронного кода вопрос об исключениях является очень актуальным. Было бы обидно, если бы вы не могли перехватить исключение и оно выбрасывалось в левом потоке, убивая процесс. Для перехвата исключения в одном потоке и возбуждения его в создан класс ExceptionDispatchInfo. Чтобы захватить исключение, используется статический метод ExceptionDispatchInfo.Capture(ex), возвращающий ExceptionDispatchInfo. Ссылку на этот объект можно передать в любой поток, который затем вызовет метод Throw() для его выброса. Сам выброс происходит НЕ в месте вызова асинхронной операции, а в месте использования оператора await (если метод помечен как async, в противном случае компилятор не будет совершать преобразований, а значит метод работает как простой метод — исключения никто перехватывать и перевыбрасывать не будет). А как известно, await применить к void нельзя. Таким образом, исключение возбудится в не подконтрольном нам потоке и словлено не будет. А это почти 100% приведет к краху приложения (всегда существуют грязные хаки). И тут мы приходим к практике того, что мы должны использовать Task или Task<T>, но не void.
И еще. У планировщика есть событие TaskScheduler.UnobservedTaskException, которое срабатывает, когда выбрасывается UnobservedTaskException. Это исключение выбрасывается при сборке мусора, когда GC пытается собрать объект задачи, в котором имеется необработанное исключение.
IAsyncEnumerable
До C# 8 и .NET Core 3.0 не было возможности использовать блок-итератор (yield) в асинхронном методе, что усложняло жизнь и заставляло из такого метода возвращать Task<IEnumerable<T>>, т.е. не было способа проитерироваться по коллекции до полного ее получения. Теперь такая возможность есть. Подробнее о ней можно узнать здесь. Для этого тип возвращаемого значения должен быть IAsyncEnumerable<T> (или IAsyncEnumerator<T>). Для прохода по такой коллекции следует использовать цикл foreach с ключевым словом await. Также на результате операции могут быть вызваны методы WithCancellation и ConfigureAwait, указывающие используемый CancelationToken и необходимость продолжения в том же контексте.
Как и положено, все выполняется настолько лениво, насколько это возможно.
Ниже представлен пример и вывод, который он дает.
Time after calling: 0
Task run: 1
element: 1
Time: 1033
Task run: 2
element: 2
Time: 3034
Task run: 3
element: 3
Time: 6035
ThreadPool
Данный класс активно используется при программировании с TAP. Поэтому дам минимальные подробности его реализации. Внутри себя ThreadPool имеет массив очередей: по одной на каждый поток + одна глобальная. При добавлении новой работы в пул учитывается поток, который инициировал добавление. В случае, если это поток из пула, работа ставится в собственную очередь этого потока, если это был другой поток — в глобальную. Когда потоку выбирается работа, сначала смотрится его локальная очередь. Если она пуста, поток берет задания из глобальной. Если же и та пуста — начинает красть у остальных. Также никогда не стоит полагаться на порядок выполнения работ, потому что, собственно, порядка то и нет. Количество потоков в пуле по умолчанию зависит от множества факторов, включая размер адресного пространства. Если запросов на выполнение больше, чем количество доступных потоков, запросы ставятся в очередь.
Потоки в пуле потоков — background-потоки (свойство isBackground = true). Данный вид потоков не поддерживает жизнь процесса, если все foreground-потоки завершились.
Системный поток наблюдает за состоянием wait handle. Когда операция ожидания заканчивается, переданный колбэк выполняется потоком из пула (вспоминаем файлы в Windows).
Task-like тип
Упомянутый ранее, данный тип (структура или класс) может быть использован в качесве возвращаемого значения из асинхронного метода. С этим типом должен быть связан тип билдера с помощью атрибута [AsyncMethodBuilder(..)]. Данный тип должен обладать упомянутыми ранее характеристиками для возможности применять к нему ключевое слово await. Может быть непараметризированным для методов не возвращающих значение и параметризированным — для тех, которые возвращают.
Сам билдер — класс или структура, каркас которой показан в примере ниже. Метод SetResult имеет параметр типа T для task-like типа, параметризированного T. Для непараметризированных типов метод не имеет параметров.
Далее будет описан принцип работы с точки зрения пишущего свой Task-like тип. Большинство это уже было описано при разборе кода, сгенерированного компилятором.
Все эти типы компилятор использует для генерации машины состояний. Компилятор знает, какие билдеры использовать для известных ему типов, здесь же мы сами указываем, что будет использовано при кодогенерации. Если машина состояний — структура, то произойдет ее упаковка при вызове SetStateMachine, билдер может закэшировать упакованную копию при необходимости. Билдер должен вызвать stateMachine.MoveNext в методе Start или после его вызова, чтобы начать выполнение и продвинуть машину состояний. После вызова Start, из метода будет возвращено значение свойство Task. Рекомендую вернуться к методу заглушке и просмотреть эти действия.
Если машина состояний успешно отрабатывает, вызывается метод SetResult, иначе SetException. Если машина состояний достигает await, выполняется метод GetAwaiter() task-like типа. Если объект ожидания реализует интерфейс ICriticalNotifyCompletion и IsCompleted = false, машина состояний использует builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine). Метод AwaitUnsafeOnCompleted должен вызвать awaiter.OnCompleted(action), в action должен быть вызов stateMachine.MoveNext когда объект ожидания завершится. Аналогично для интерфейса INotifyCompletion и метода builder.AwaitOnCompleted.
Как использовать это — решать вам. Но советую подумать раз этак 514 прежде чем применить это в продакшене, а не для баловства. Ниже приведен пример использования. Я набросал всего-лишь прокси для стандартного билдера, который выводит на консоль, какой метод был вызван и в какое время. Кстати, асинхронный Main() не хочет поддерживать кастомный тип ожидания (полагаю не один продакшен проект был безнадежно испорчен из-за этого промаха Microsoft). При желании, вы можете модифицировать прокси-логер, использовав нормальный логер и логируя больше информации.
Start
Method: Create; 2019-10-09T17:55:13.7152733+03:00
Method: Start; 2019-10-09T17:55:13.7262226+03:00
Method: AwaitUnsafeOnCompleted; 2019-10-09T17:55:13.7275206+03:00
Property: Task; 2019-10-09T17:55:13.7292005+03:00
Method: SetResult; 2019-10-09T17:55:14.7297967+03:00
Stop