Async c что это

Async/await в C#: концепция, внутреннее устройство, полезные приемы

Доброго времени суток. В этот раз поговорим на тему, в которой начинал разбираться каждый уважающий себя адепт языка C# — асинхронное программирование с использованием Task или, в простонародье, async/await. Microsoft проделали хорошую работу — ведь для того, чтобы использовать асинхронность в большинстве случаев нужно лишь знание синтаксиса и никаких других подробностей. Но если лезть вглубь, тема довольно объемная и сложная. Ее излагали многие, каждый в своем стиле. Есть очень много классных статей по этой теме, но все равно существует масса заблуждений вокруг нее. Постараемся исправить положение и разжевать материал настолько, насколько это возможно, не жертвуя ни глубиной, ни пониманием.

Рассматриваемые темы/главы:

  1. Концепция асинхронности — преимущества асинхронности и мифы о «заблокированном» потоке
  2. TAP. Синтаксис и условия компиляции — необходимые условия для написания компилирующегося метода
  3. Работа с применением TAP — механика и поведение программы в асинхронном коде (освобождения потоков, запуск задач и ожидание их завершения)
  4. За кулисами: машина состояний — обзор преобразований компилятора и сгенерированных им классов
  5. Истоки асинхронности. Устройство стандартных асинхронных методов — асинхронные методы для работы с файлами и сетью изнутри
  6. Классы и приемы при работе с TAP — полезные приемы, которые могут помочь с управлением и ускорением программы с применением TAP

Концепция асинхронности

Асинхронность сама по себе далеко не нова. Как правило, асинхронность подразумевает выполнение операции в стиле, не подразумевающем блокирование вызвавшего потока, то есть запуск операции без ожидания ее завершения. Блокирование — это не такое зло, как его описывают. Можно встретить утверждения, что заблокированные потоки зря расходуют процессорное время, работают медленнее и вызывают дождь. Последнее кажется маловероятным? На самом деле предыдущие 2 пункта такие же.

На уровне планировщика ОС, когда поток находится в состоянии «блокирован», ему не будет выделяться драгоценное процессорное время. Вызов планировщика, как правило, приходится на операции вызывающие блокировку, прерывания по таймеру и другие прерывания. То есть когда, например, контроллер диска завершит операцию чтения и инициирует соответствующее прерывание, запустится планировщик. Он будет решать, запускать поток, который был блокирован этой операцией, или какой-то другой с более высоким приоритетом.

Медленная работа кажется еще более абсурдной. Ведь по факту работа выполняется одна и та же. Только на выполнение асинхронной операции добавляются еще небольшие накладные расходы.

Вызов дождя — это вообще что-то не из этой области.

Основная проблема блокирования — неразумное потребление ресурсов компьютера. Даже если мы забудем о времени на создание потока и будем работать с пулом потоков, то на каждый заблокированный поток расходуется лишнее место. Ну и есть сценарии, где определенную работу может осуществлять только один поток (например, UI поток). Соответственно, не хотелось бы, чтобы он был занят задачей, которую может выполнить другой поток, жертвуя выполнением эксклюзивных для него операций.

Асинхронность — понятие весьма обширное и может достигаться многими путями.
В истории .NET можно выделить следующие:

  1. EAP (Event-based Asynchronous Pattern) — как следует из названия, поход основан на событиях, которые срабатывают по завершении операции и обычного метода, вызывающего эту операцию
  2. APM (Asynchronous Programming Model) — основан на 2 методах. Метод BeginSmth возвращает интерфейс IAsyncResult. Метод EndSmth принимает IAsyncResult (если к моменту вызова EndSmth операция не завершена, поток блокируется)
  3. TAP (Task-based Asynchronous Pattern) — тот самый async/await (если говорить строго, то эти слова появились уже после появления подхода и типов Task и Task<TResult>, но async/await значительно улучшил эту концепцию)

Task-based asynchronous pattern. Синтаксис и условия компиляции

Стандартный асинхронный метод в стиле TAP написать очень просто.

Для этого нужно:

  1. Чтобы возвращаемое значение было Task, Task<T> или void (не рекомендуется, рассмотрено далее). В C# 7 пришли Task-like типы (рассмотрены в последней главе). В C# 8 к этому списку добавляется еще IAsyncEnumerable<T> и IAsyncEnumerator<T>
  2. Чтобы метод был помечен ключевым словом async, а внутри содержал await. Это ключевые слова идут в паре. При этом если метод содержит await, обязательно его помечать async, обратное неверно, но бессмысленно
  3. Для приличия соблюдать конвенцию о суффиксе Async. Разумеется, компилятор за ошибку считать это не будет. Если вы ну очень приличный разработчик, то можете добавлять перегрузки с CancellationToken (рассмотрен в последней главе)

Было упомянуто, что метод должен содержать ключевое слово await. Оно (слово) указывает на необходимость асинхронного ожидания выполнения задачи, которую представляет тот объект задачи, к которому оно применяется.

Объект задачи, также имеет определенные условия, чтобы к нему можно было применить await:

  1. Ожидаемый тип должен иметь публичный (или internal) метод GetAwaiter(), это может быть и метод расширения. Этот метод возвращает объект ожидания
  2. Объект ожидания должен реализовать интерфейс INotifyCompletion, который обязывает реализовать метод void OnCompleted(Action continuation). Также он должен иметь экземплярные свойство bool IsCompleted, метод void GetResult(). Может быть как структурой, так и классом.

Работа с применением TAP

Сложно идти в дебри не понимая, как что-то должно работать. Рассмотрим TAP с точки зрения поведения программы.

По терминологии: рассматриваемый асинхронный метод, чей код будет рассматриваться, я буду называть асинхронный метод, а вызываемые асинхронные методы внутри него я буду называть асинхронная операция.

Возьмем наипростейший пример, в качестве асинхронной операции возьмем Task.Delay, который осуществляет задержку на указанное время, не блокируя поток.

Выполнение метода с точки зрения поведения происходит так.

  1. Выполняется весь код, предшествующий вызову асинхронной операции. В данном случае это метод BeforeCall
  2. Выполняется вызов асинхронной операции. На данном этапе поток не освобождается и не блокируется. Данная операция возвращает результат — упомянутый объект задачи (как правило Task), который сохраняется в локальную переменную
  3. Выполняется код после вызова асинхронной операции, но до ожидания (await). В примере — AfterCall
  4. Ожидание завершения на объекте задачи (который сохранили в локальную переменную) — await task.

Если асинхронная операция к этому моменту завершена, то выполнение продолжается синхронно, в том же потоке.

За кулисами. Машина состояний

На самом деле наш метод преобразовывается компилятором в метод заглушку, в которой происходит инициализация сгенерированного класса — машины состояний. Далее она (машина) запускается, а из метода возвращается объект Тask, используемый на шаге 2.

Особый интерес представляет метод MoveNext машины состояний. Данный метод выполняет то, что было до преобразования в асинхронном методе. Он разбивает код на части между каждым вызовом await. Каждая часть выполняется при определенном состоянии машины. Сам метод MoveNext присоединяется к объекту ожидания в качестве продолжения. Сохранение состояния гарантирует выполнение именно той его части, которая логически следовала за ожиданием.

Как говорится, лучше 1 раз увидеть, чем 100 раз услышать, поэтому настоятельно рекомендую ознакомиться с примером ниже. Я немного переписал код, улучшил именование переменных и щедро закомментировал.

Заостряю внимание на фразе «к этому моменту не выполнился синхронно». Асинхронная операция может пойти и по синхронному пути выполнения. Главное условие для того, чтобы текущий асинхронный метод выполнялся синхронно, то есть не меняя поток — это завершенность асинхронной операции на момент проверки IsCompleted.

Истоки асинхронности. Устройство стандартных асинхронных методов

Мы рассмотрели то, как выглядит метод, использующий async и await и что происходит за кулисами. Данная информация нередка. Но важно понимать и природу асинхронных операций. Потому что как мы видели в машине состояний в коде вызываются асинхронные операции, разве что их результат обрабатывается хитрее. Однако что происходит внутри самих асинхронных операций? Вероятно, то же самое, но это не может происходить до бесконечности.

Важной задачей является понимание природы асинхронности. При попытках понять асинхронность наблюдается чередование состояний «теперь понятно» и «теперь снова непонятно». И данное чередование будет до тех пор, пока не станет понятен исток асинхронности.

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

Как правило, асинхронность начинается с метода, который возвращает Task (например), но не помечен async, соответственно не использует await внутри. Такой метод не терпит никаких компиляторных изменений, выполняется как есть.

Итак, рассмотрим несколько корней асинхронности.

  1. Task.Run, new Task(..).Start(), Factory.StartNew и ему подобные. Самый простой способ начать асинхронное выполнение. Данные способы просто создают новый объект задачи, передавая в качестве одного из параметров делегат. Задача передается планировщику, который дает ее на выполнение одному из потоков пула. Возвращается готовая задача, которую можно ожидать. Как правило, такой подход используется для начала вычислений (CPU-bound) в отдельном потоке
  2. 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 есть несколько полезных статических методов. Ниже будут приведены основные из них.

  1. Task.WhenAny(..) — комбинатор, принимает IEnumerable/params объектов задач и возвращает объект задачи, который завершиться по завершении первой завершившейся из переданных задач. То есть позволяет дожидаться одной из нескольких запущенных задач
  2. Task.WhenAll(..) — комбинатор, принимает IEnumerable/params объектов задач и возвращает объект задачи, который завершиться по завершении всех переданных задач
  3. Task.FromResult<T>(T value) — возвращает это же значение, обернутое в завершенную задачу. Зачастую требуется при реализации существующих интерфейсов с асинхронными методами
  4. Task.Delay(..) — асинхронно ждет указанное время
  5. 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

Understanding Async, Avoiding Deadlocks in C#

You ran into some deadlocks, you are trying to write async code the proper way or maybe you’re just curious. Somehow you ended up here, and you want to fix a deadlock or improve your code.

I’ll try to keep this concise and practical, and for further reading check out the related articles. To write proper async C# code and avoid deadlocks you need to understand a few concepts.

Setting up good practices can help avoiding common issues, but sometimes that’s not enough, and that’s when you need to understand what’s happening below the abstraction layers.

You should already be familiar with async code, there are many articles that discuss on how to use it, but not many explain how they work. If you’re not familiar at all I recommend at least reading something about it. Ideally you should already have some experience using async functions.

Tasks or Threads?

Tasks have nothing to do with Threads and this is the cause of many misconceptions, especially if you have in your might something like “well a Task is like a lightweight Thread”. Task is not thread. Task does not guarantee parallel execution. Task does not belong to a Thread or anything like that. They are two separate concepts and should be treated as such.

Task represents some work that needs to be done. A Task may or may not be completed. The moment when it completes can be right now or in the future.

The equivalent in some many languages is the Promise. A Task can be completed just like how a Promise can be fulfilled. A Task can be faulted just like how a Promise can be rejected. This is the only thing that a Task does, it keeps track whether a some work has been completed or not.

If the Task is completed and not faulted then the continuation task will be scheduled. Faulted state means that there was an exception. Tasks have an associated TaskScheduler which is used to schedule a continuation Task, or any other child Tasks that are required by the current Task.

Threads are a completely different story. Threads just as in any OS represent execution of code. Threads keep track what you execute and where you execute. Threads have a call stack, store local variables, and the address of the currently executing instruction. In C# each thread also has an associated SynchronizationContext which is used to communicate between different types of threads.

C# uses Threads to run some code and mark some Tasks as being completed. For performance reasons there is usually more than one thread. So Threads execute Tasks… simple you might think… but that’s not the whole picture. The whole picture looks look something like this:

Threads execute Tasks which as scheduled by a TaskScheduler.

What Does await Really Do?

Let’s start with an example. This is how you would properly implement an I/O bound operation. The application needs to request some data from a server. This does not use much CPU, so to use resources efficiently we use the async methods of HttpClient.

The proper async / await version:

The code example should be obvious what it does if you are at least a bit familiar with async/await. The request is done asynchronously and the thread is free to work on other tasks while the server responds. This is the ideal case.

But how does async await manage to do it? It’s nothing special, just a little bit of syntactic sugar over the following code. The same async await can be achieved by using ContinueWith and Unwrap.

The following code example does the same thing, with small differences.

ContinueWith / Unwrap version (this is still async):

Really, that’s all what async/await does! It will schedules tasks for execution and once a task is done another task is scheduled. It creates something like a chain of tasks.

Everything you do with async and await end up in an execution queue. Each Task is queued up using a TaskScheduler which can do anything it wants with your Task. This is where things get interesting, the TaskScheduler depends on context you are currently in.

Code that might work in some contexts…

Let’s look at the same DownloadString function, but this time it’s implemented in a bad way. This might still work in some cases.

This type of code should be avoided, they should never be used in libraries that can be called from different contexts.

The following example is a sync version which achieves the same thing, but in a very, very different way. It blocks the thread. We’re getting to unsafe territory. It’s radically different from the code above and should never be considered an equivalent implementation.

Sync version, blocks the thread, not safe:

The code above will also download the string, but it will block the calling Thread while doing so, and it that thread is a threadpool thread, then it will lead to a deadlock if the workload is high enough. Let’s see what it does in more detail:

  1. Calling HttpClient.GetAsync(url) will create the request, it might run some part of it synchronously, but at some point it reaches the part where it needs to offload the work to the networking API from the OS.
  2. This is where it will create a Task and return it in an incomplete state, so that you can schedule a continuation.
  3. But instead you have the Result property, which will blocks the thread until the task completes. This just defeated the whole purpose of async, the thread can no longer work on other tasks, it’s blocked until the request finishes.

The problem is that if you blocked the threads which are supposed to work on the Tasks, then there won’t be a thread to complete a Task.

This depends on context, so it’s important to avoid writing this type of code in a library where you have no control over the execution context.

  • If you are calling from UI thread, you will deadlock instantly, as the task is queued for the UI thread which gets blocked when it reaches the Result property.
  • If called from threadpool thread then a theadpool thread is blocked, which will lead to a deadlock if the work load is high enough. If all threads are blocked in the threadpool then there will be nobody to complete the Task.
  • But this case will work if you’re calling from a main or dedicated thread. (which does not belong to threadpool and does not have syncronization context)

Let’s look an example which is just as bad, but can work fine in other cases.

Sync version, defeats the purpose, blocks the calling thread and definitely not safe:

The code above also blocks the caller, but it dispatches the work to the threadpool. Task.Run forces the execution to happen on the threadpool. So if called from a different thread than a threadpool thread, this is actually pretty okay way to queue work for the threadpool.

  • If you have a classic ASP.NET application or a UI application, you can call async functions from sync function using this method, then update the UI based on the result, with the caveat that this blocks the UI or IIS managed thread until the work is done. In case of the IIS thread this is not a huge problem as the request cannot complete until the work is not done, but in case of a UI thread this would make the UI unresponsive.
  • If this code is called from a threadpool thread, then again it will lead to a deadlock if the work load is high enough because it’s blocking a threadpool thread which might be necessary for completing the task. Best is to avoid writing code like this, especially in context of library where you have no control over the context your code gets called from.

And now let’s look a the final version, which does horrible things…

Deadlock version. Dont write this:

Well code above is a bit of an exaggeration, just to prove a point. It’s the worst possible thing that you can do. The code above will deadlock no matter what context you are calling from because it schedules tasks for the threadpool and then it blocks the threadpool thread. If called enough times in parallel, it will exhaust the threadpool, and your application will hang… indefinitely. In which case the best thing you can do is a memory dump and restart the application.

What Causes a Deadlock?

Task.Wait() does. That would be the end of story but sometimes it cannot be avoided, and it’s not the only case. Deadlock might also be cause by other sort of blocking code, waiting for semaphore, acquiring as lock. The advice in general is simple. Don’t block in async code. If possible this is the solution. There are many cases where this is not possible to do and that’s where most problems come from.

Here is an example from our own codebase.

Yes! This causes a deadlock!

Look at the code above. Try to understand it. Try to guess the intent, the reason why it’s written like this. Try to guess how the code could fail. It doesn’t matter who wrote it, anyone could have written this. I wrote code like this that’s how I know it deadlocks.

The problem the developer is facing that the API they are supposed to call is async only, but the function they are implementing is sync. The problem can be avoided altogether by making the method async as well. Problem solved.

But, it turns out that you need to implement a sync interface and you are supposed to implement using API which has async only functions.

The execution is wrapped inside a Task.Run, this will schedule the task on the threadpool the block the calling thread. This is okay, as long as the calling thread is not a threadpool thread. If the calling thread is from the threadpool then the following disaster happens: A new task is queued to the end of the queue, and the threadpool thread which would eventually execute the Task is blocked until the Task is executed.

Okay so we don’t wrap in inside a Task.Run, we get the following version:

This still causes a deadlock!

Well it got rid of an extra layer of task, which is good and the task is scheduled for the current context. What does this mean? This means that the code will deadlock if threadpool is already exhaused or instantly deadlock if called from UI thread, so it solves nothing. At the root of the problem is the .Result property.

So at this point you might think, is there a solution for this? The answer is complicated. In library code there is no easy solution as you cannot assume under what context your code is called. The best solution is to only call async code from async code, blocking sync APIs from sync methods, don’t mix them. The application layer on top has knowledge of the context it’s running in and can choose the appropriate solution. If called from a UI thread it can schedule the async task for the threadpool and block the UI thread. If called from threadpool then you might need to open additional threads to make sure that there is something to finish. But if you include transition like this from sync to async code inside a library, then the calling code won’t be able to do control the execution and your library will fail in with some applications or frameworks.

Library code should be written without any assumption of synchronization context or framework which calls from. If you need to support both blocking sync and async interface, then you must implement the function twice, for both versions. Don’t even think about calling them from each other for code reuse. You have 2 options, either make your function blocking sync, and use blocking sync APIs to implement it, or make your function async and use async APIs to implement it. In case you need both you can and should implement both separately. I recommend ditching blocking sync entirely and just using async.

Other solutions include writing your own TaskScheduler or SyncronizationContext, so that you have control over the execution of tasks. There are plenty of articles on this, if you have free time, give it a try, it’s a good exercise and you’ll gain deeper insight than any article can provide.

SyncronizationContext? TaskScheduler?

These control how your tasks are executed. These will determine what you can do and can not do when calling async functions. All that async functions do is to schedule a Task for the current context. The TaskScheduler may schedule the execution in any way it pleases. You can implement your own TaskScheduler and do whatever you want with it. You can implement your own SyncronizationContext as well and schedule from there.

The SyncronizationContext is a generic way of queuing work for other threads. The TaskScheduler is an abstraction over this which handles the scheduling and execution of Tasks.

When you create a task by default C# will use TaskScheduler.Current to enqueue the new task. This will use the TaskScheduler of the current task, but if there is no such thing then checks if there is a synchronization context associated with the current thread and uses that to schedule execution of tasks using SynchronizationContext.Post, but if there is no such thing then it will use the TaskScheduler.Default which will schedule work in a queue that gets executed using the thread pool.

Those are a lot of complicated things to consider at the same time, so let’s break it down into several common cases:

  • In console applications by default you don’t have a synchronization context, but you have a main thread. Tasks will be queued using the default TaskScheduler and will be executed on the thread pool. You can freely block your main thread it will just stop executing.
  • If you create a custom thread, by default you dont have a syncronization context, it’s just like having a console application. Tasks get executed on the thread pool and you can block your custom thread.
  • If you are in a thread pool thread, then all following tasks are also executed on the thread pool thread, but if you have blocking code here then the threadpool will run out of threads, and you will deadlock.
  • If you are in a desktop UI thread, you have a synchronization context, and by default tasks are queued for execution on the UI thread. Queued tasks are executed one by one. If you block the UI thread there is nothing left to execute tasks and you have a deadlock.
  • If you’re writing a dotnet core web application, you’re basically running everything on the thread pool. Any blocking code will block the thread pool and any .Result will lead to a deadlock.
  • If you’re writing a ASP.NET web application, then you have theads managed by IIS which will allocate one for each request. Each of these threads has its own syncronization context. Tasks get scheduled for these threads by default. You need to manually schedule for the threadpool for parallel execution. If you call .Result on a task which is enqueued for the request thread, you will instantly deadlock.
  • If you’re writing a library, you have no idea what code is calling your code, and mixing async code with sync code, or calling .Result will almost certainly make an application deadlock. Never mix async and sync code in a library.

How to Write Good Async Code?

Until now we talked about good cases, bad cases and cases that work in some cases. But what about some practices to follow? It depends. It’s not easy to enforce common practices because how async/await works depends on the context. But these should be followed in library code.

  • Only call async code only from async code. (dont mix sync with async)
  • Never block in async code. (never .Result, never lock)
  • If you need a lock, use SemaphoreSlim.WaitAsync()
  • Use async/await when dealing with Tasks, instead of ContinueWith/Unwrap, it makes the code cleaner.
  • It’s okay to provide both sync and async version of API, but never call one from the other. (this is one of the rare cases when code duplication is acceptable)

Understanding all the concepts that relate to async can take some time. Until you do that, here is a cheat sheet that gives you what you can do and cannot do in each context. This is not a comprehensive list and that the deadlock categorization is more towards a strict side which means that you it might still work in some cases but will deadlock in production. There can be other types of blocking code like Thread.Sleep or Semaphore.WaitOne but these will not cause a deadlock on it’s own, but will increase chance of deadlocking if there is a .Result somewhere.

Debugging Methodology

You have a deadlock in your code? Great! The important part is to identify it. It can be from any Task.Result or Task.Wait or possibly other blocking code. It’s like searching for a needle in a haystack.

Memory Dumps Help a Lot!

If you find your application in a deadlocked state, take a memory dump of your application. Azure has tools for this on portal, if not there are plenty of guides for this. This will capture the state of your application. DebugDiag 2 Analysis can automatically analyze the memory dump. You need to the stack trace on the threads to see where the code is blocked. Upon code review you will find a statement there which blocks the current thread. You need to remove the blocking statement to fix the deadlock.

Reproducing the Deadlock

The other approach is to reproduce the deadlock. The method you can use here is stress testing, launch many threads in parallel and see if the application survives. However this might not be able to reproduce problems, especially if the async tasks complete fast enough. A better approach is to limit the concurrency of the thread pool, when the application starts to 1. This means that if you have any bad async code where a threadpool thread would block then it definitely will block. This second approach of limiting concurrency is also better for performance. Visual Studio is really slow if there are a lot of threads or tasks in you application.

Further Reading

An introduction can be useful for anyone that just started out async programming in C# “Asynchronous programming” and “Async in depth”

Stephen Cleary’s article is oriented on the same subject and describes how SyncronizationContext affects pretty much anything you do in .NET Stephen Cleary (February 2011), “Parallel Computing — It’s All About the SynchronizationContext”

The TaskScheduler class is incredibly well documented by Microsoft, it sheds some light on the behavior of await/async. MSDN, “TaskScheduler Class”

Dennis Doomen’s coding guidelines are well maintained. It’s a good read with a lot of good practices in a relatively short document. Dennis Doomen (April 2018), “Coding Guidelines for C# 5.0, 6.0 and 7.0” (github source)

Some Corrections:

As it has been pointed out, my examples don’t work they are supposed to, this is because I have simplified them too much in order to get my ideas across more easily. (I hope it worked!)

When you call an async method which is simple enough, it might work even if its wrongly used. Also, in general the classic .NET framework is more forgiving due to it having dedicated threads that you can block. You will experience this when porting from the forgiving .NET framework to the more harsh .NET Core if the original project has badly written async code that “worked at the time”.

Some of the examples rely on having a high load, this is something that’s hard to test and usually happens when it’s too late: in production.

In one of the comment I’ve added a pull request to properly reproduce some of the issues in the example by creating an async method which is a bit more complex than the one in my example:

See the comments, read the related articles, and test everything for yourself that you don’t believe, if possible, simulate high load and limit thread count in thread pool to reproduce my results more easily.

C# async await explained

C# async await explained

In 2012, C#5 was released. This version introduced two new keywords async and await . At that time CPU clock speed reached an upper limit imposed by physical laws. But chip makers started to deliver CPU with several cores that can run tasks in parallel. Thus C# needed a away to ease asynchronous programming.

The async and await keywords make asynchronous programming almost too easy. Many programmers use them often without really understanding the runtime workflow. This is a great thing, they can focus more on the business of their applications and less on asynchronous details. But some disconcerting behaviors might (and will) happen. Thus it is preferable that one understands the logic behind async and await and what can influence it. This is the goal of the present article.

Calling an async method

Here is a small C# program that illustrates the async and await keywords. Two tasks A and B runs simultaneously. Task A runs within a method marked as async , while B is executed after calling the async method.

Here is the result:

C# async await on console

Before explaining in details how the two occurrences of the keyword async modify the workflow, let’s make some remarks:

  • The method with the modifier async is named MethodAAsync() . The method name suffix Async is not mandatory but widely used, also in the .NET Base Class Library (BCL). This suffix can be ignored in methods with common name like Button1_Click() or Main() . By the way Main() is also marked with async in the code above, more on this point later.
  • In the async method MethodAAsync() , once the keyword await is meet for the first time the remaining of the task is actually executed by some random threads obtained from the runtime thread pool.
  • As a consequence the call to the async method MethodAAsync() is not blocking the main thread. First it prints A0 on the console and then returns to run the task B synchronously while the task A continues on other threads.
  • This is why the async method MethodAAsync() returns a Task<int> named taskA . This task represents the remaining course of MethodAAsync() that will print A1, A2, A3, A4 and then returns an integer result.
  • Thread 1 and then Thread 4 and 7 are involved to run task A. Each time the keyword await is executed, one cannot predict the pool thread that will be used to run the code remaining. Keep in mind that this behavior results from running within a console application context where there is no SynchronizationContext (this will be explained in a later section).
  • Similarly, in the main method the code after await taskA; is executed on a random pool thread. Here it appears to be the same thread that executed the last part of MethodAAsync() .

First let’s explain the easy role of the async keyword. Then we’ll have a closer look at the influence of the await keyword.

The easy role of the async keyword

It is important to note that only the keyword await does mysterious things here. async is just here to decorate a method to tell the C# compiler that this method contains at least one await keyword. The C# compiler could be smart enough to detect that a method contains an await keyword. However async was introduced both for readability and for backward compatibility to avoid breaking existing code that used await as a variable name:

C# async keyword just a decorator

Consequently, an async method with no await keyword is executed synchronously. A warning is emitted in this situation.

C# async method with no await is synchronous

From now keep in mind that the keyword async is just a decorator that tells the C# compiler that the method contains at least one occurrence of the await keyword. By the way, since the main method also contains the await keyword it must also be declared as async and also returns a Task . A main method can be declared as async since C# 7.1.

Understanding the await workflow

In the short program above there are two occurrences of the keyword await , in the Main() method and in the MethodAAsync() method. We now know that await can only be mentioned within a method with the modifier async . Also in both places the keyword await is immediately followed by a Task or Task<TResult> object. To understand the await workflow there are 3 points to carefully take account:

  • 1) The caller point of view: Once the keyword await is met for the first time in an async method, the currently executing thread immediately returns. This is why we say that a call to an async method is not blocking. It means that when a thread is calling an async method, it might not use its result immediately. Instead it got a promise of result, which is the Task<TResult> object returned. The thread can do some work (task B here) and then await on the task later when it finally needs the result. By the way the similar javascript construct is called a promise.
  • 2) The awaited asynchronous task: await is called on a task object, that is not the task returned by the async method.
    • The task might be started at that point as in await Task.Delay(100); that simulates a computation intensive task or an I/O bound task. It could be replaced with something like await Task.Run(() => < . computation intensive task running on a pool thread. >); .
    • Or the task might already be running, as in the await taskA; in the Main() method.
    • In the async method MethodAAsync() the code after the await keyword is the remaining loops and then the code that returns the result.
    • In the async Main() method, the code after the await keyword is ConsoleWriteLine($»The result of taskA is «); followed by Console.ReadKey(); .

    What’s often not well understood is that there are really 2 tasks involved in an async method:

    • The task following the await keyword that runs the CPU bound or I/O bound code.
    • The task returned by the async method that represents the remaining code to run upon the awaited task termination.

    In fact in this short program above, there are much more than 2 tasks involved at runtime! These few lines of code are more subtile than they look because in MethodAAsync() , the keyword await is met in each loop and each time await Task.Delay(100); simulates a new task. As a consequence at each loop a new task is created to run the remaining code once the task Task.Delay(100); terminates. So taskA returned by MethodAAsync() is concretely a chain of tasks and each loop can be ran by a different thread. We can see in the console output that the pool threads with IDs 7 and 4 are involved to run sub-tasks of taskA . Notice that the first loop that prints A0 executed by the main thread is not a part of taskA .

    The magic behind the C# await keyword

    Now that we detailed the await keyword workflow we can measure how powerful it is. Some magic does occur under the hood to resume the execution once the task finishes. Let’s have a look at the thread stack trace after await taskA; in the main method.

    C# await stack trace

    The simple line await taskA; leads the C# compiler to generate a lot of code to pilot the runtime. Identifiers like AsyncState. and MoveNext() shows that a state machine is created for us to let the magic of code continuation happens seamlessly. Here is the assembly decompiled with ILSpy. We can see that a class is generated by the compiler for each usage of the await keyword:

    ILSpy C# async await

    Here is a call graph generated by NDepend of the methods of the Task Parallel Library (TPL) called by the generated code. To obtain such graph with methods and fields generated by the compiler, the following setting must be disabled first: NDepend > Project Properties > Analysis > Merge Code Generated by Compiler into Application Code

    C# async await TPL methods call graph

    The details of what the C# compiler generates when it meets the keyword await is outside the scope of this article but you can deep dive in it in this Microsoft article. Just keep in mind that the code executed after an await keyword can eventually be executed by a random thread and that a lot of code that calls the TPL is generated to make this happen. Let’s explain how the random thread is chosen by he runtime.

    The SynchronizationContext

    So far we only demonstrated code executed in the context of a console application. The context in which some asynchronous code runs actually influences its workflow a lot. For example let’s run the same code in the context of a WPF application. Since it is convenient to keep the console output to show results of our experiments, let’s set the output type of our WPF assembly to Console Application, so a console is shown when the WPF app starts.

    WPF project output console application

    Now let’s execute the exact same code from within a WPF button click event handler:

    Here is the surprising result: the main thread is used to run everything! And task A loops are postponed after task B loops (except the first one).

    WPF C# async await SynchronizationContext

    This is totally different than what we had with our console application. The key is that in a WPF context (and also in a Winforms context) there is a synchronization context object, that can be obtained through SynchronizationContext.Current .

    WPF SynchronizationContext

    There is no synchronization context in a console application.

    Console SynchronizationContext

    The WPF and Winforms SynchronizationContext behavior

    In the precedent WPF execution there is no pool thread involved because there is no real asynchronous processing: remember we use await Task.Delay(100); to simulate it. Here is the output if we do some real processing instead:

    WPF C# await real processing

    Why do we need a SynchronizationContext in WPF and Winforms scenarios? : In WPF there is a main UI thread that manages the UI (and a hidden thread that does the rendering) and in Winforms there is also a UI thread that does both the managing of controls and the rendering. When the UI thread gets too busy, the UI becomes unresponsive and the user gets nervous. This is why in both cases it is essential to run computation intensive task on a pool thread and not on the UI thread. This is why both WPF and Winforms have their own synchronization contexts, in order to resume by default on the UI thread to harness the result of an asynchronous operations that just terminated. Typically the result is used to refresh some controls. To do so, these synchronization contexts are relying on the internal infrastructure of the WPF and the Winforms platforms.

    What is the runtime workflow in both WPF examples above? : In both WPF results above, we can see that first A0 is displayed and then task B is ran entirely (B0 … B4) until task A can resume with (A1 … A4). Remember that in task B we have Task.Delay(50).Wait(); that first simulates a task and then wait for its termination. This is a blocking call equivalent to Thread.Sleep(50); unlike await Task.Delay(100) in task A that is not blocking. This means that the UI thread is kept busy with the task B until it finishes. Only upon task B termination, the UI thread gets available again and the WPF synchronization context can resume task A on it.

    Disabling the WPF and Winforms SynchronizationContext behavior with task.ConfigureAwait(false)

    This WPF and Winforms asynchronous contexts’ default behavior of resuming on the main UI thread after an asynchronous call can be discarded by calling the method ConfigureAwait(false) on the task in the await call. The value false is set to the parameter ConfigureAwait(bool continueOnCapturedContext) . By default this well-named parameter is set to true . With ConfigureAwait(false) called in a WPF or Winforms context, we go back to the console behavior where a random thread from the pool is used to resume after the await call. In a UI application you might wish to avoid preempting the UI thread when harnessing the result of an asynchronous operation to preserve the UI responsiveness that is conditioned by the amount of work done on the UI thread. Of course this only makes sense if the UI is not refreshed from the result.

    WPF C# async await ConfigureAwait

    In the execution result above, only the await usage in the MethodAAsync() method is done with ConfigureAwait(false) , not the await usage in the Button_Click() method. This is why the main thread is used to print «The result of taskA is 123» , because of the WPF synchronization context behavior still enabled here.

    No SynchronizationContext in ASP.NET Core

    Let’s notice that there is no synchronization context within an ASP.NET Core application. This was an important change because ASP.NET had an AspNetSynchronizationContext as discussed in this stackoverflow Q/A. On his blog, Stephen Cleary explains that the decision to discard AspNetSynchronizationContext was taken to obtain more simplicity and performance.

    Finally let’s note that you can create custom synchronization context as explained on this github page, although you won’t likely do so.

    Асинхронность в C#. Разрушение легенд

    Влад Фурдак, старший разработчик в компании DataArt, написал и опубликовал на сайте DOU.UA статью, посвященную асинхронному программированию на C#, а именно — нюансам работы с TAP (Task-based Asynchronous Pattern) — паттерном асинхронного программирования, основанным на задачах. Представляем эту статью вашему вниманию.

    Асинхронность в C#

    Статья довольно обширная и разбита на пять разделов:

    1. Асинхронность: как и зачем это использовать.
    2. Взгляд вовнутрь через популярные заблуждения.
    3. Проблемный код и лучшие практики.
    4. Сторонние библиотеки и тулинг.
    5. Что еще почитать/посмотреть.

    I. Асинхронность: как и зачем это использовать

    Что такое асинхронность и зачем она нужна?

    Все внешние устройства, не работающие на одной шине с микропроцессором, — сетевые адаптеры, видеокарты, хранилища данных — возвращают результат своей работы не сразу. Следовательно, нам выбирать: либо наш поток выполнения будет останавливаться и ожидать результат операции, либо выполнять какой-то другой код. Таким образом, код, написанный с неблокирующим (асинхронным) ожиданием результата, потребляет меньше ресурсов и является более производительным на дистанции.

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

    Однако многопоточность и асинхронность можно также классифицировать по типам многозадачности, как ее формы:

      многозадачность — вид многозадачности, когда система выделяет каждой задаче некоторый квант времени — реализуется через механизм потоков и Task, выполняющий свой код в многопоточных контекстах. многозадачность — вид многозадачности, когда система выделяет задаче время до тех пор, пока задача не завершится. Похоже на асинхронные вызовы в однопоточном контексте синхронизации, например UI-поток WinForms или работу движка V8 для выполнения JavaScript.

    Изучая асинхронные подходы в .NET, я плохо понимал, как все устроено изнутри. Это не позволяло решать ряд проблем, связанных с асинхронностью.Также слышал разные истории коллег, которые сталкивались с аналогичными проблемами и не всегда знали, как их решить: например, дедлоки или «нелогичное» поведение асинхронного кода. В статье рассмотрим механику работы и лучшие практики TAP: как это устроено изнутри и какие ошибки лучше не совершать.

    В .NET-фреймворке исторически сложилось несколько более старых паттернов организации асинхронного кода:

    • APM (IAsyncResult, они же коллбеки) (.NET 1.0).
    • EAP — события, этот паттерн все видели в WinForms (.NET 2.0).
    • TAP (Task Asynchronous Pattern) — класс Task и его экосистема (.NET 4.0).

    Сейчас рекомендованный и самый мощный паттерн — TAP. В C# 5 он был дополнен механизмом async/await, помогающим избежать блокирующего исполнения кода, в более новых версиях языка появилось еще несколько нововведений.

    Вообще, говоря про асинхронность и проблемы, которые она решает, нужно упомянуть те самые блокировки, от которых мы хотим избавиться. Существует два типа возможности занять поток:

    • CPU Bound — блокировка, когда поток занят непосредственно вычислениями. Здесь необходимо позаботиться о том, чтобы длинная операция не блокировала потоки пула потоков .NET (ThreadPool), а работала отдельно и синхронизировала возврат результата.
    • IO Bound — блокировка, ожидание результата от устройств ввода-вывода — тут асинхронный подход имеет максимальный эффект, так как, по сути, мы занимаемся ожиданием, и наши потоки могут выполнять пустую работу.

    Async/Await идеально решает проблему IO Bound, с CPU Bound можно использовать средства Parallel или неявного создания отдельных потоков, но об этом позже.

    Какая бывает асинхронность?

    Лично я для себя условно разбил асинхронные подходы на три группы, включив реализации из JavaScript и Golang для примеров.

    Какая бывает асинхронность

    JavaScript, как язык, еще имеет дополнительные средства, генераторы, которых нет в C#, для организации асинхронных операций.

    В C# бэкенд разработке нативно меньше реактивных подходов. Основными методами являются либо запуск и менеджмент объектов Task и их неблокирующего ожидания с помощью await, либо коллбеки. Реактивность же чаще используется в UI-разработке.

    Однако можно использовать и имплементацию библиотеки Rx под C# для работы с источником событий как с потоком (стримом) и реакций на них.

    В этой же статье мы поговорим о нативных способах работы с асинхронностью в C#.

    TAP (Task Asynchronous Pattern)

    Сам паттерн состоит из двух частей: набора классов из пространства имен System.Threading.Tasks и конвенций написания своих асинхронных классов и методов.

    Что нам дает асинхронный подход в контексте TAP:

    1. Реализации фасадов по согласованной работе с задачами, а именно:
      • Запуск задач.
      • Отмена задач.
      • Отчет о прогрессе.
      • Комбинация цепочек задач, комбинаторы.
      • Неблокирующие ожидания (механизм async/await).
    2. Конвенции по именованию и использованию асинхронных методов:
      • В конец добавляем постфикс Async.
      • В аргументы метода можем передавать или не передавать CancellationToken & IProgress имплементацию.
      • Возвращаем только запущенные задачи.

    Если хотите подойти к изучению более фундаментально, посмотрите whitepaper на 40 страниц от Microsoft, как это работает. Скачать документ можно тут.

    Как создать и запустить задачу

    Условно я разделил возможные пути создания задач на четыре группы:

    возможные пути создания задач

    1. Фабрики запущенных задач. Run — более легкая версия метода StartNew с установленными дополнительными параметрами по умолчанию. Возвращает созданную и запущенную задачу. Самый популярный способ запуска задач. Оба метода вызывают скрытый от нас Task.InternalStartNew. Возвращают объект Task.
    2. Фабрики завершенных задач. Иногда нужно вернуть результат задачи без необходимости создавать асинхронную операцию. Это может пригодиться в случае подмены результата операции на заглушку при юнит-тестировании или при возврате заранее известного/рассчитанного результата.
    3. Конструктор. Создает незапущенную задачу, которую вы можете далее запустить. Я не рекомендую использовать этот способ. Старайтесь использовать фабрики, если это возможно, чтобы не писать дополнительную логику по запуску.
    4. Фабрики-таскофикаторы. Помогают либо произвести миграцию с других асинхронных моделей в TAP, либо обернуть логику ожидания результата в вашем классе в TAP. Например, FromAsync принимает методы паттерна APM в качестве аргументов и возвращает Task, который оборачивает более ранний паттерн в новый.

    Кстати, библиотеки в .NET, в том числе и механизм async/await, организуют работу по установке результата либо исключения для таск с помощью TaskCompletionSource.

    Будьте внимательны, если создаете задачу через конструктор класса: по умолчанию она не будет запущена.

    Как отменить задачу

    За отмену задач отвечает класс CancellationTokenSource и порождаемый им CancellationToken.
    Работает это приблизительно так:

    1. Создается экземпляр CancellationTokenSource (cts).
    2. cts.Token отправляется параметром в задачу (ассоциируется с ней).
    3. При необходимости отмены задачи для экземпляра CancellationTokenSource вызывается метод Cancel().
    4. Внутри кода задачи на токене вызывается метод ThrowIfCancellationRequested(), который выбрасывает исключение в случае, если в CancellationTokenSource произошла отмена. Если токен был ассоциирован с задачей при создании, исключение будет перехвачено, выполнение задачи остановлено (так как исключение), ей будет выставлен статус Cancelled. В противном случае задача перейдет в статус Faulted.

    Также возможно прокинуть cts в методы, уже реализованные в .NET, у них внутри будет своя логика по обработке отмены.

    Кстати, конструктор CancellationTokenSource может принимать значение таймаута, после которого метод Cancel будет вызван автоматически.

    Асинхронные контроллеры в ASP.NET могут инжектить экземпляр CancellationToken прямо в метод контроллера, вызываться же отмена токена будет по разрыву соединения с сервером. Это позволит значительно упростить инфраструктуру поддержки обрыва ненужных запросов. Если этот токен будет вовремя обрывать операции, результата которых уже не ждут, производительность может заметно повыситься. Далее два примера согласованной отмены.

    Пример #1 кода согласованной отмены:

    В этом случае мы получаем в консоль:

    Token works
    A task was canceled. TaskCanceledException

    Пример #2

    В случае же работы с опросом токена исключение будет иное

    (такой же код инициализации, как и выше)

    В этом случае мы получаем в консоль:

    Token works
    The operation was canceled.OperationCanceledException

    Обратите внимание, что Task.Delay выбросит TaskCanceledException, а не OperationCanceledException.

    Более детально о согласованной отмене можно почитать тут.

    Как следить за прогрессом выполнения

    TAP содержит специальный интерфейс для использования в своих асинхронных классах — IProgress<T>, где T — тип, содержащий информацию о прогрессе, например int. Согласно конвенциям, IProgress может передаваться как последние аргументы в метод вместе с CancellationToken. В случае если вы хотите передать только что-то из них, в паттерне существуют значения по умолчанию: для IProgress принято передавать null, а для CancellationToken — CancellationToken.None, так как это структура.

    Как синхронизировать задачи

    Существуют такие способы объединять задачи в логические цепочки друг за другом или же ожидать группы задач по определенному принципу:

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

    Всего у TaskContinuationOptions 15 значений, и они могут комбинироваться.ContinueWith оборачивает задачу еще в одну задачу, создавая Task<Task<… >>. Но не стоит злоупотреблять или имплементировать сложную логику, основанную на этом методе.

    Более подробно об особенностях такого поведения и подводных камнях можно почитать в блоге Stephen Cleary.

    Как извлечь результат из задачи

    До появления await извлекать результат из задач можно было такими блокирующими способами:

    • t.Result(); — возврат результата / выброс исключения AggregateException.
    • t.Wait(); — ожидание выполнения задачи, выброс исключения AggregateException.
    • t.GetAwaiter().GetResult(); — возврат результата / выброс оригинального исключения — служебный метод компилятора, поэтому использовать его не рекомендуется. Используется механизмом async/await.

    После появления async/await рекомендованной техникой стал оператор await, производящий неблокирующее ожидание. То есть если await добрался до незавершенной задачи, выполнение кода в потоке будет прервано и продолжится только с завершением задачи.

    await t; — возврат результата / выброс оригинального исключения.

    Следует заметить, что для t.GetAwaiter().GetResult(); и await будет выброшено только первое исключение, аналогично манере поведения обычного синхронного кода.

    Выброс исключения в вызывающий потоктоже результат.

    Почему исключения задач завернуты в AggregateException? Допустим, задача стала результатом работы комбинатора задач (например, Task.WhenAll). Он вернет задачу, которая станет завершенной только после завершения всех переданных ей задач. Значит, исключений может быть много, поэтому они будут завернуты в AggregateException.

    Философия async/await

    Основная идея async/await в том, чтобы писать асинхронный код в синхронной манере и не задумываться, как это работает. Но в этом и основной подводный камень — незнание начинки может породить неожиданные сайд-эффекты, о которых мы не задумывались.

    Логически представляет собой следующий код:

    где LongOperation1, LongOperation2, LongOperation3 — принимают аргумент и коллбек-функцию, выполняющуюся по завершении и принимающую результат операции.

    Добавив немного инфраструктуры, мы бы изобрели самый старый асинхронный паттерн, APM.

    Но за удобства, которые предоставляет await, нужно платить тем, что большинство инфраструктурной работы остается за кулисами. В разделе II более детально рассмотрим ту самую закулисную работу, чтобы понимать опасные места этого механизма.

    Как использование async/await дополняет работу с TAP

    Все, что могло ожидаться, блокируя поток, теперь может ожидаться, не блокируя поток, например:

    Что нового появилось в TAP начиная с C# 5

    C# 5.0 / .NET 4.5
    • async/await;
    • Progress<T>.
    • await в Catch/Finally блоках, в C# 5 так делать было нельзя;
    • Упрощенный синтаксис Task.Run(DoThings) вместо Task.Run(() => DoThings()).
    C# 7.0 — 7.3
    • ValueTask<T> — структура-таск, повышающая производительность в некоторых случаях;
    • async Main method для консольного приложения.
    C# 8 / .NET Standard 2.1 — (.NET Core 3, Mono 6.4)
    • AsyncStreams — асинхронные стримы, пока недоступны в .NET Framework, только для платформ, входящих в .NET Standard 2.1 +. Если вкратце — дают возможность на уровне языка реализовывать неблокирующие ожидания между чтениями из потока.

    II. Взгляд вовнутрь через популярные заблуждения

    Людям свойственно выбирать для сложных вещей самое простое объяснение, часто в реальной жизни это статистически оправдано. Однако технологии не всегда построены очевидным для нас способом, и «простое» объяснение может ввести нас в заблуждение.

    Task — это облегченный Thread

    Самое распространенное заблуждение среди начинающих разработчиков. Класс Task не имеет прямого отношения к потокам операционной системы. Условно, в полномочия Task входит:

    • Обслуживание статуса (выполнена, выполняется, отменена, ошибка) логической задачи — списка инструкций, объединенного в метод либо анонимный метод.
    • Фабричные статические методы по запуску логических задач с установкой параметров исполнения, также конструктор, позволяющий вручную создать задачу и затем ее запустить.
    • Создание инфраструктуры по согласованной отмене логической задачи, поддержки цепочек вызова задач.
    • Извлечение результата либо исключения в вызывающий поток.

    Если вы программировали на JavaScript, то аналогом Task является объект Promise.

    Лично я вижу класс Task как реализацию таких паттернов.

    • Фасад: Task не управляет выполнением задач и не имеет стратегии их планирования в потоки, это скорее интерфейс-абстракция, имеющая билдеры (ContinueWith), статические методы-фабрики создания задач и вариант создания задачи с помощью конструктора.
    • DTO (Data transfer object): Task отвечает за перенос состояния выполнения и результата связанного с ним кода. Причем установкой результата или исключения Task на низком уровне занимается TaskCompletionSource.

    За планирование выполнения кода в потоках отвечает класс TaskScheduler, который имеет две реализации:

    • ThreadPoolTaskScheduler (неявно установлен для всех задач);
    • SynchronizationContextTaskScheduler.

    Вы вправе написать собственный TaskScheduler, реализовав стратегию использования потоков и планирования в них кода, переданного в задачи.

    • ThreadPoolTaskScheduler выполняет код в потоках из ThreadPool. В виде исключения существует использование опции при создании задачи LongRunningTask — для таких задач ThreadPoolTaskScheduler создает отдельный поток.
    • SynchronizationContextTaskScheduler использует поведение текущего контекста синхронизации (установленного для потока либо по умолчанию). Контекст синхронизации является наследником класса SynchronizationContext. Получить этот TaskScheduler можно с помощью вызова TaskScheduler.FromSynchronizationContext(); в текущем потоке.

    Async await — синтаксический сахар

    Это утверждение отчасти верно, но только отчасти. Механизм async/await действительно не имеет реализации в CLR и разворачивается компилятором в довольно сложную конструкцию, указывающую, какую именно часть метода вызывать (стейт машина). Но вы не сможете реализовать async/await через механизм, например, тасок. Async/await — не синтаксический сахар вокруг тасок, это отдельный механизм, использующий класс Task для переноса состояния выполняющегося куска кода.

    Await запускает операцию асинхронно

    Оператор Await не запускает операцию асинхронно, он либо:

    • вызывает метод синхронно, если возвращенная им задача уже была завершена;
    • производит неблокирующее ожидание (отпускает поток) результата задачи, возвращая управление из метода вверх по иерархии await’ов, когда мы дошли до вложенного await, который возвращает незавершенную Task.

    Результатом операции await может быть либо возврат результата из связанной с ним задачи, либо выброс исключения. Кстати, в случае с задачами, порожденными комбинаторами задач, будет выброшено только первое исключение, даже если результирующая задача накопила их несколько. Это обусловлено природой оператора await — сделать асинхронный код таким же простым, как синхронный. Если вы хотите получить все исключения — обратитесь к переменной типа Task, которую вы эвейтили.

    Кстати, Task не единственный класс, который может работать с оператором await. С ним может работать любой класс, реализующий метод GetAwaiter(), в случае с Task — TaskAwaiter.

    Продолжение метода после await будет выполнено в пуле потоков

    Это утверждение верно, но не всегда. Я выше упомянул класс SynchronizationContext, так вот, он необходим для механизма работы async/await. Наследники класса SynchronizationContext устанавливаются той средой, где выполняется код, в свойствах потока.

    Для ASP.NET Core, Console Application, созданных вручную потоков — SynchronizationContext не будет выставлен явно. Это означает, что async/await будет использовать ThreadPool SynchronizationContext (контекст по умолчанию), запуская продолжение методов в случае, если возвращаемая ими задача не завершена, в ThreadPool.

    В ASP.NET (старом) установлен однопоточный AspNetSynchronizationContext, присоединяющий продолжение методов в тот же поток, из которого выполнялась их первая часть.

    То же самое и для WinForms-приложений: UI-поток имеет установленный WindowsFormsSynchronizationContext, планирующий продолжение только в единственный UI-поток.

    Можете провести простой тест. Если вы запустите Task из метода-обработчика события UI-контрола в WinForms-приложении, он выполнится в пуле потоков. Однако если вы сделаете это с помощью Task.Factory.StartNew и передадите ему в параметр TaskScheduler — TaskScheduler.FromCurrentSynchronizationContext, то задача выполнится в UI-потоке.

    Кстати, метод configureAwait, вызываемый на классе Task, возвращает пропатченный TaskAwait’er, в котором сбрасывается текущий контекст синхронизации и заново устанавливается по умолчанию. В этом случае продолжение отработает в пуле потоков.

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

    Будет очень неожиданно, если кто-нибудь додумается синхронно (t.Result / t.Wait() ) получить результат из асинхронного метода вашей библиотеки в однопоточном контексте синхронизации (WinForms, ASP.NET). Единственный поток будет заблокирован незаконченной задачей, а закинуть в него продолжение задачи и завершить эту же самую задачу вы не сможете. И получите классический дедлок.

    Все вышеописанное можно подытожить в таблице:

    Флаг async без вызовов await внутри никак не поменяет метод

    Это не так. Async — флаг компиляции, он — не часть сигнатуры метода и может не быть объявлен в интерфейсах. Видя метод как async, компилятор уже все равно создаст из него state-машину, пускай даже с одним состоянием. Исходя из этого оставлять методы с async без await внутри — плохая практика.

    Async await и ContinueWith у Task — одно и то же

    Логически они действительно похожи, однако в реализации — совершенно разные вещи. Await не имеет отношения к ContinueWith, и более того, ломает его. Эти два механизма ничего не знают друг о друге, поэтому поведение такого кода будет довольно странным:

    В консоли мы получим:

    Такое поведение обусловлено особенностью механизма async/await — после прерывания метода из него возвращается незавершенная задача, что интерпретируется механизмом ContinueWith как завершение метода.

    Далее стартует следующий метод цепочки, однако после возврата результата — стейт-машина первого метода запустит вторую часть метода, и оба метода продолжат выполнение параллельно.

    Если хотите другие объяснения, то я поднимал этот вопрос на Stack Overflow.

    TaskScheduler — то же самое, что SynchronizationContext, только новее

    На самом деле, SynchronizationContext был представлен в .NET гораздо раньше, чем TaskScheduler.

    • Наследники обоих классов отвечают за планирование асинхронных операций.
    • Оба наследника работают с пулом потоков (класс ThreadPool) в реализациях по умолчанию.

    Появился в .NET 4.0.
    Высокоуровневая абстракция для работы с Task.
    Позволяет планировать выполнение Task и продолжений.
    Имеет две реализации по умолчанию:
    ThreadPoolTaskScheduler
    и SynchronizationContextTaskScheduler.
    Где используется:

    Появился в .NET 2.0.
    Низкоуровневый класс, позволяет запускать делегаты в нужных потоках.
    Используется для работы await.
    Имеет множество реализаций в зависимости от типа окружения.
    Где используется:

    III. Проблемный код и лучшие практики

    Проблемный код

    Async void problem

    Не используйте void вместе с async, если только это не написание обработчиков WinForms/WPF. Метод, отмеченный как async, будет запущен в пуле потоков, но у него нет механизма отлова исключений. Также вы не сможете отследить прогресс его выполнения, так как объекта Task, отвечающего за статус, здесь нет. Опасность отсутствия механизмов отлова исключений в том, что в случае падения такой метод завершит работу домена приложения, а если он единственный — то и работу всего приложения.

    Кстати, анонимный лямбда-метод — async Action, а Action имеет результат void. Поэтому, вернув в async лямбде результат Task, компилятор автоматически выберет нужную перегрузку метода Task.Run, возвращающий async Task — и проблем не будет.

    Deadlock problem

    В однопоточных контекстах синхронизации (Original asp.net, WinForms, WPF) возможны дедлоки из-за необходимости добавлять продолжение метода в уже занятый поток. При этом освободить поток нельзя из-за незаконченности задачи. Чтобы было проще понять, о чем я, давайте посмотрим на такой код:

    Если он будет вызван на старом ASP.NET или на WinForms/WPF-приложении, результатом будет дедлок.

    1. Выполнение заходит в метод GetJsonAsync.
    2. Выполнение доходит до оператора await, возвращая вверх по вызову незаконченную Task.
    3. На незаконченной Task запускается блокирующее ожидание результата свойством Result.
    4. После прихода await однопоточный контекст синхронизации планирует продолжение в единственно возможный поток, который ждет окончания Task. Но Task не закончится, пока не отработает продолжение.

    Еще один пример:

    Блокирующие операции

    Надеюсь, я уже привел достаточно аргументов в пользу того, что блокировки в асинхронном коде — это плохо. Смесь блокирующего и асинхронного кодов может приводить к дедлокам и нивелировать все преимущества написания кода в асинхронной манере.

    Когда вы блокируете поток пула потоков, это значит, что поток занят, и продолжение асинхронного метода не сможет отработать вовремя и тоже будет заблокировано, если попадет в такой поток. Или ThreadPool разместит его в новом, что потребует больше ресурсов.

    Потерянные исключения

    В случае возникновения исключения при исполнении задачи вызывающий код об этом никак не узнает, если явно не проверит, было ли внутри исключение.

    Запустите этот код в ASP.NET Core консольном приложении:

    Вы увидите, что после сборки мусора два раза сработает событие UnobservedTaskException , при этом никакой проблемы в работе приложения не будет.

    В .NET 4.0 поведение по умолчанию было иным: в случае необработанного исключения (оно считается необработанным, если Task, в котором оно произошло, попадает под сборку мусора, при этом мы не обратились к свойству Exception явно или неявно) будет выброшено исключение в пул потоков, что приведет к краху приложения.

    Ambient objects

    • Никогда не используйте ThreadLocal-хранилище в асинхронном коде, вместо этого был создан класс AsyncLocal.
    • При использовании TransactionScope помните об опции AsyncFlow, без нее работа транзакций не будет корректной. Я не сторонник использования TransactionScope в своих приложениях, однако при рефакторинге строго кода — вы вполне можете все сломать.

    Работа асинхронных методов и IDisposable

    В следующем коде:

    Если вызывать async метод конструкцией await внутри в синхронном using, то Dispose для serviceContext отработает перед тем, как завершится метод GetResult.

    Причина такого поведения в том, что после первого же await внутри метода GetResult нам вернется Task, исполнение кода продолжится, и по выходу из using будет вызван Dispose.

    Затем придет продолжение после await внутри метода GetResult, но будет поздно.

    Производительность

    await в цикле

    Если у вас есть код, где каждому элементу необходимо независимо от других сделать await, выполнение в цикле будет очень долгим. С логической точки зрения, если в основе вызываемых методов лежит IO Bound блокировка ожидания, то нет смысла вызывать их последовательно. С точки зрения конечного автомата внутри механизма async/await, это будет некоторый оверхед.

    Гораздо лучше собрать все таски — и вызвать Task.WhenAll для всех сразу. ThreadPool сам поймет, как лучше оптимизировать их работу.

    Dynamic & try/catch в async-методах

    Если в вашем приложении каждая миллисекунда имеет значение, помните, что использование try/catch внутри async-метода значительно его усложнит. То же самое — с await dynamics-результата. Стейт-машина станет в разы сложнее, что замедлит выполнение кода.

    ValueTask

    Использование ValueTask может дать незначительный прирост производительности в коде, массово использующем класс Task. ValueTask — структура, где на создание экземпляра не выделяется дополнительная память в управляемой куче.

    Лучшие практики

    По ссылке вы можете найти собранные в одном месте лучшие практики написания асинхронного кода.

    • Не используйте async void, за исключением обработчиков WinForms/WPF.
    • Если начали, делайте все приложение асинхронным.
    • Не используйте блокирующие конструкции, используйте await.
    • Выбирайте неблокирующее ожидание await > ContinueWith.
    • Используйте ConfigureAwait(false) в коде вашей библиотеки.
    • Возвращайте только запущенные задачи.
    • Используйте конвенции именований.
    • Используйте флаги задач, если это необходимо.
    • Используйте асинхронную версию SemaphoreSlim для синхронизации доступа к ресурсу.

    IV. Библиотеки и тулинг

    Правильные инструменты

    Неблокирующие коллекции

    Non-blocking dictionary — усовершенствованный по перфомансу словарь.

    Immutable collections — стандартный набор неизменяемых коллекций. Примеры использования и кейс можно найти в этой статье.

    Анализаторы кода

    AsyncFixer — анализатор-расширение для Visual Studio для проблемных мест, связанных с асинхронностью. Ненужные await, async void методы, использование async & using, места, где можно использовать async-версии методов, обнаружение явных кастов Task<T> к Task.

    Ben.BlockingDetector — библиотека-помощник обнаружения блокировок в вашем коде.

    Демистифаеры стек-трейса

    Ben.Demystifier позволяет получить более приятный стек-трейс в вашем приложении.

    V. Что почитать/посмотреть

    • Можете глянуть мой доклад «Асинхронность в .NET — от простого к сложному» по этой теме. По структуре материала он похож на эту статью.
    • Довольно интересный доклад Игоря Фесенко про работу асинхронности и многопоточности, скрытые проблемы и методы их решения. , автора Concurrency in C# Cookbook (2nd ed).
    • Блог непосредственно разработчиков асинхронных средств Pfx team: async/await, Tasks in-depth. .
    • ILSpy/DotPeek, чтобы посмотреть все самому �� Если хотите посмотреть код, генерируемый для async-методов — в настройках вашей reverse-engineering tool необходимо включить соответствующую настройку.
    • Еще пара книг по этой теме, которые мне показались вполне понятными: Алекс Дэвис «Асинхронное программирование в C# 5.0», Richard Blewett, Andrew Clymer Pro Asynchronous Programming with .NET.

    Если у вас есть вопросы, замечания или пожелания, можете писать мне на Facebook.

    Также если вы начинающий или опытный разработчик в поиске работы/в процессе изучения технологий, можете вступить в мое комьюнити в Telegram. Участвуйте в обсуждениях, задавайте вопросы — или просто поговорим с вами за жизнь!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *