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 секунд.
Опеределение асинхронного лямбда-выражения
Асинхронную операцию можно определить не только с помощью отдельного метода, но и с помощью лямбда-выражения:
C#: асинхронные функции
В C# 5.0 добавлены ключевые слова await и async для поддержки асинхронного программирования — стиля программирования, когда длительные по времени выполнения функции проделывают всю или большую часть своей работы после возврата контроля в точку вызова. В противоположность этому при обычном — синхронном — программировании длительные функции блокируют вызвавший их код пока не завершат свое выполнение. Асинхронное программирование предполагает параллелизм (параллельное выполнение нескольких операций), когда длительные операции выполняются параллельно с вызвавшим их кодом. Параллельное выполнение асинхронных функций реализуется либо через многопоточность (для операций, зависящих только от быстродействия процессора — compute-bound), либо через механизм обратного вызова (для операций, ограниченных скоростью ввода/вывода данных — I/O-bound).
Например, следующий метод выполняется синхронно, является длительным по времени выполнения и зависит от быстродействия процессора (compute-bound):
Этот метод блокирует вызвавший его код на несколько секунд пока продолжается его выполнение. Затем результат вычисления возвращается в точку вызова:
В CLR предусмотрен класс Task<TResult> (в System.Threading.Tasks ) для реализации концепции операций, завершающихся в будущем. Для операций, зависящих от быстродействия процессора, можно создать экземпляр Task<TResult> вызвав Task.Run и передав ему в качестве параметра делегат. CLR запустит этот делегат в отдельном потоке параллельно с вызвавшим кодом:
Этот метод асинхронный, так как возвращает контроль над выполнением в точку вызова сразу же пока сам выполняется параллельно. Однако необходим специальный механизм, позволяющий вызвавшему коду определить, что должно случиться когда операция завершиться и результат станет доступным. Класс Task<TResult> реализует это с помощью метода GetAwaiter , который позволяет добавить в точку вызова отложенное продолжение:
Отложенное продолжение представляет собой делегат, который будет вызван после того как асинхронный метод будет завершен. Чтобы получить результат выполнения асинхронного метода внутри делегата необходимо вызвать метод GetResult .
Ключевые слова await и async
Ключевое слово await упрощает синтаксис добавления отложенного продолжения:
Соответственно пример из предыдущего раздела можно переписать так:
Также необходимо добавить родительскому методу модификатор async :
Модификатор async заставляет компилятор воспринимать await как ключевое слово, а не как идентификатор типа (это сделано с цель обратной совместимости с кодом, написанным до появления C# 5.0). Модификатор async может быть применен только к методу (или лямбда выражению) возвращаемому void либо Task или Task<TResult> . Модификатор async (также как модификатор unsafe ) не является частью сигнатуры метода или его метаданных.
Методы с модификатором async называются асинхронными функциями, поскольку в действительности выполняются асинхронно. Встретив выражение с ключевым словом await , выполнение возвращается в точку вызова (также как при встрече yield return в итераторе). Но перед возвращением создается отложенное продолжение для ожидания завершения задания. Когда отложенное задание завершиться, выполнение вернется назад в метод и продолжит его выполнение с того момента, на котором оно остановилось. Если отложенное задание завершиться с ошибкой, будет выброшено исключение, в противном случае возвращенное значение будет считаться результатом выполнения выражения с ключевым словом await .
В пределах асинхронной функции компилятор ожидает встретить либо выражение с ключевым слово await , которое и рассматривается как отложенное задание, либо любой объект с методом GetAwaiter , возвращающим отложенный объект (awaitable object) — объект, реализующий интерфейс INotifyCompletion.OnCompleted и содержащий метод GetResult (возвращающий соответствующий результат), а также свойство bool IsCompleted , проверяющее завершенность синхронного выполнения.
Помимо обобщенного класса Task<TResult> можно использовать и не обобщенный класс Task , который не возвращает никакого значения:
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
How and when to use ‘async’ and ‘await’
From my understanding one of the main things that async and await do is to make code easy to write and read — but is using them equal to spawning background threads to perform long duration logic?
I’m currently trying out the most basic example. I’ve added some comments inline. Can you clarify it for me?
![]()
![]()
26 Answers 26
When using async and await the compiler generates a state machine in the background.
Here’s an example on which I hope I can explain some of the high-level details that are going on:
OK, so what happens here:
Task<int> longRunningTask = LongRunningOperationAsync(); starts executing LongRunningOperation
Independent work is done on let’s assume the Main Thread (Thread then await longRunningTask is reached.
Now, if the longRunningTask hasn’t finished and it is still running, MyMethodAsync() will return to its calling method, thus the main thread doesn’t get blocked. When the longRunningTask is done then a thread from the ThreadPool (can be any thread) will return to MyMethodAsync() in its previous context and continue execution (in this case printing the result to the console).
A second case would be that the longRunningTask has already finished its execution and the result is available. When reaching the await longRunningTask we already have the result so the code will continue executing on the very same thread. (in this case printing result to console). Of course this is not the case for the above example, where there’s a Task.Delay(1000) involved.
![]()
From my understanding one of the main things that async and await do is to make code easy to write and read.
They’re to make asynchronous code easy to write and read, yes.
Is it the same thing as spawning background threads to perform long duration logic?
// I don’t understand why this method must be marked as ‘async’.
The async keyword enables the await keyword. So any method using await must be marked async .
// This line is reached after the 5 seconds sleep from DoSomethingAsync() method. Shouldn’t it be reached immediately?
No, because async methods are not run on another thread by default.
// Is this executed on a background thread?
You may find my async / await intro helpful. The official MSDN docs are also unusually good (particularly the TAP section), and the async team put out an excellent FAQ.
Explanation
Here is a quick example of async / await at a high level. There are a lot more details to consider beyond this.
Note: Task.Delay(1000) simulates doing work for 1 second. I think it’s best to think of this as waiting for a response from an external resource. Since our code is waiting for a response, the system can set the running task off to the side and come back to it once it’s finished. Meanwhile, it can do some other work on that thread.
In the example below, the first block is doing exactly that. It starts all the tasks immediately (the Task.Delay lines) and sets them off to the side. The code will pause on the await a line until the 1 second delay is done before going to the next line. Since b , c , d , and e all started executing at almost the exact same time as a (due to lack of the await), they should finish at roughly the same time in this case.
In the example below, the second block is starting a task and waiting for it to finish (that is what await does) before starting the subsequent tasks. Each iteration of this takes 1 second. The await is pausing the program and waiting for the result before continuing. This is the main difference between the first and second blocks.
Example
Extra info regarding SynchronizationContext
Note: This is where things get a little foggy for me, so if I’m wrong on anything, please correct me and I will update the answer. It’s important to have a basic understanding of how this works but you can get by without being an expert on it as long as you never use ConfigureAwait(false) , although you will likely lose out on some opportunity for optimization, I assume.
There is one aspect of this which makes the async / await concept somewhat trickier to grasp. That’s the fact that in this example, this is all happening on the same thread (or at least what appears to be the same thread in regards to its SynchronizationContext ). By default, await will restore the synchronization context of the original thread that it was running on. For example, in ASP.NET you have an HttpContext which is tied to a thread when a request comes in. This context contains things specific to the original Http request such as the original Request object which has things like language, IP address, headers, etc. If you switch threads halfway through processing something, you could potentially end up trying to pull information out of this object on a different HttpContext which could be disastrous. If you know you won’t be using the context for anything, you can choose to «not care» about it. This basically allows your code to run on a separate thread without bringing the context around with it.
How do you achieve this? By default, the await a; code actually is making an assumption that you DO want to capture and restore the context:
If you want to allow the main code to continue on a new thread without the original context, you simply use false instead of true so it knows it doesn’t need to restore the context.
After the program is done being paused, it will continue potentially on an entirely different thread with a different context. This is where the performance improvement would come from — it could continue on on any available thread without having to restore the original context it started with.
Is this stuff confusing? Hell yeah! Can you figure it out? Probably! Once you have a grasp of the concepts, then move on to Stephen Cleary’s explanations which tend to be geared more toward someone with a technical understanding of async / await already.