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

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

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

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

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

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

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

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

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

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

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

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

  • Вытесняющая (Preemptive) многозадачность — вид многозадачности, когда система выделяет каждой задаче некоторый квант времени — реализуется через механизм потоков и Task, выполняющий свой код в многопоточных контекстах.
  • Кооперативная (Cooperative) многозадачность — вид многозадачности, когда система выделяет задаче время до тех пор, пока задача не завершится. Похоже на асинхронные вызовы в однопоточном контексте синхронизации, например 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 для примеров.


Классификация подходовПаттерныИмплементация в JSИмплементация в GolangИмплементация в C#
Императивные Saga и ее вариации, коллбеки Redux-saga
ES7 async/await,
передача колбеков
Передача коллбеков,

Select + Channels
async/await, передача коллбеков
ОбъектныйDTO
Объектно-ориентированное представление статуса о выполненной задаче
Promise-Task
РеактивныеObserver/Observable
(pub/sub), Builder
RxJS
Observable
EventEmitter
MobX
ChannelsEvents
Rx.NET

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. Фабрики запущенных задач

Task.Run(Action/Func)
Task.Factory.StartNew(Action/Func)
3. Конструктор

var t = new Task(Action/Func);
t.Start();
2. Фабрики завершенных задач

Task.FromResult(Result)
Task.FromCanceled(CancellationToken)
Task.FromException(Exception)
Task.CompletedTask
4. Фабрики-таскофикаторы

Task.Factory.FromAsync (APM)
TaskCompletionSource (EAP, APM, etc)
  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 кода согласованной отмены:

//Подготовка
var cts = new CancellationTokenSource();
var token = cts.Token;
token.Register(() => Console.WriteLine("Token works"));

//Получаем задачу
var t = Task.Run(async () =>
{
         //производим отмену на CancellationTokenSource
         cts.Cancel();
         //в Delay попадет уже отмененный токен, что выбросит исключение
         await Task.Delay(10000, token);
}, token);

try
{
    //неблокирующее ожидание задачи
await t;
}
//В данном случае выбросится TaskCanceledException
catch (TaskCanceledException e)
{
      Console.WriteLine(e.Message + " TaskCanceledException");
}

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

Token works
A task was canceled. TaskCanceledException

Пример #2

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

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

//Получаем задачу
var t = Task.Run(async () =>
{
         //производим отмену на CancellationTokenSource
         cts.Cancel();
         //выбрасываем исключение на отмененном токене
         token.ThrowIfCancellationRequested();

}, token);

try
{
     await t;
}
//В данном случае выбросится OperationCanceledException
catch (OperationCanceledException e)
{
     Console.WriteLine(e.Message + " OperationCanceledException");
}

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

Token works
The operation was canceled.OperationCanceledException

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

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

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

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

//Не используйте такой код в продакшене :) написано с целью демонстрации
//Код считает до 100 с определенной задержкой репортуя прогресс
public async Task RunAsync(int delay, CancellationToken cancellationToken, IProgress<int> progress)
{
      int completePercent = 0;

      while (completePercent < 100)
      {
        await Task.Run(() =>
        {
            completePercent++;

             new Task(() =>
             {
                 progress?.Report(completePercent);
             }, cancellationToken, 
                TaskCreationOptions.PreferFairness).Start();

        }, cancellationToken);

        await Task.Delay(delay, cancellationToken);
      }
}

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

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

Комбинаторы задач

Task.WaitAll (list of tasks) -> Task
Task.WaitAny (list of tasks) -> Task
Task.WhenAll (list of tasks) -> Task
Task.WhenAny (list of tasks) -> Task
Метод расширения ContinueWith для экземпляров задач с опциями реакции на исключения, отмену или удачное завершение предыдущей задачи

t.ContinueWith( res=>{ код продолжения }, TaskContinuationOptions )
Метод расширения ContinueWith для экземпляров задач с опциями продолжения синхронно или асинхронно, установкой другого планировщика задач

t.ContinueWith( res=>{ код продолжения }, TaskContinuationOptions )
Метод расширения ContinueWith для экземпляров задач с опциями присоединения к времени выполнения родительской задачи (дочерняя задача не сможет завершиться до завершения родительской)

t.ContinueWith( res=>{ код продолжения }, TaskContinuationOptions )

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

Всего у 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 в том, чтобы писать асинхронный код в синхронной манере и не задумываться, как это работает. Но в этом и основной подводный камень — незнание начинки может породить неожиданные сайд-эффекты, о которых мы не задумывались.

Следующий код:

async Task<ResultType> funcAsync()
            {
                var result1 = await LongOperation1(null);
                var result2 = await LongOperation2(result1);
                var result3 = await LongOperation3(result2);
                return result3;
            }

ResultType funcResult = await funcAsync();

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

public static void CallbackFunc(Action<ResultType> resultCallback)
{
   LongOperation1(arg: null, onCompleted: (result1) =>
   {
      LongOperation2(arg: result1,  onCompleted: (result2) =>
      {
          LongOperation3(arg: result2,onCompleted: (result3) =>
           {           
               resultCallback(result3); 
           });
      });
    });
 }

CallbackFunc(result =>
{
   ResultType funcResult = result;
 });

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

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

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

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

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


БылоСталоЗачем нужно
task.Waitawait taskОжидать завершения Task’а
task.Resultawait taskПолучить результат завершенного Task’а
Task.WaitAnyawait Task.WhenAnyОжидать завершения одного (любого) Task’a из коллекции
Task.WaitAllawait Task.WhenAllОжидать завершения всех (последнего) Task’a из коллекции
Thread.Sleepawait Task.DelayЖдать заданный период времени

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

C# 5.0 / .NET 4.5

  • async/await;
  • Progress<T>.

C# 6.0

  • 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). Единственный поток будет заблокирован незаконченной задачей, а закинуть в него продолжение задачи и завершить эту же самую задачу вы не сможете. И получите классический дедлок.

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


ПотокКонтекст синхронизации по умолчаниюГде выполнится продолжение метода после await в случае возврата незавершенного Task
Собственный потокSynchronizationContextThreadPool
Console ApplicationSynchronizationContextThreadPool
ASP.NET Core SynchronizationContextThreadPool
Original ASP.NETAspNetSynchronizationContextТот же поток
WinFormsWindowsFormsSynchronizationContextЕдинственный UI-поток
WPFDispatcherSynchronizationContext Единственный UI-поток

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

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

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

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

await Task.Run(() => { })
                .ContinueWith(async prev =>
                {
                    Console.WriteLine("Continue with 1 start");
                    await Task.Delay(1000);
                    Console.WriteLine("Continue with 1 end");
                })
                .ContinueWith(prev =>
                {
                    Console.WriteLine("Continue with 2 start");
                });

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

Continue with 1 start
Continue with 2 start
Continue with 1 end

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

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

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

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

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

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

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

  • Любые операции с Task API, явно или неявно.
SynchronizationContext

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

  • Продолжение метода после await.
  • TaskScheduler.FromCurrentSynchronizationContext().
  • Запуск обработчиков в WinForms.

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) возможны дедлоки из-за необходимости добавлять продолжение метода в уже занятый поток. При этом освободить поток нельзя из-за незаконченности задачи. Чтобы было проще понять, о чем я, давайте посмотрим на такой код:

public static async Task<JObject> GetJsonAsync(Uri uri)
{
  using (var client = new HttpClient())
  {
	//await ожидает освобождения потока, чтобы запланировать запуск продолжения метода
    var jsonString = await client.GetStringAsync(uri); 
    return JObject.Parse(jsonString);
  }
}

public string Get(){
    var jsonTask = GetJsonAsync(...);
//поток заблокирован с помощью Result, ожидается завершение Task
    return jsonTask.Result.ToString();
}

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

По порядку:

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

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

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

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

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

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

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

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

#if DEBUG
            Console.WriteLine("Please, switch to Release mode");
#endif
#if RELEASE
            TaskScheduler.UnobservedTaskException += (s, e) =>
            {
                Console.WriteLine("Unobserved exception");
            };
#endif

  Task.Factory.StartNew(() => throw new ArgumentNullException());
  Task.Factory.StartNew(() => throw new ArgumentOutOfRangeException());

  Thread.Sleep(100);
  GC.Collect();
  GC.WaitForPendingFinalizers();
      
  await Task.Delay(10000);

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

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

Ambient objects

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

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

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

public async Task<Result> GetResult()
{
      return await service.get();
}

public Task<Result> GetResultWrapper()
{
      using(var serviceContext = new ServiceContext())
      {
            return serviceContext.GetResult();
      }
}

Если вызывать 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 — от простого к сложному» по этой теме. По структуре материала он похож на эту статью.

Довольно интересный доклад Игоря Фесенко про работу асинхронности и многопоточности, скрытые проблемы и методы их решения.

Блог Stephen Cleary, автора Concurrency in C# Cookbook (2nd ed).

Блог непосредственно разработчиков асинхронных средств Pfx team: async/await, Tasks in-depth.

TAP Pattern whitepaper.

ILSpy/DotPeek, чтобы посмотреть все самому :) Если хотите посмотреть код, генерируемый для async-методов — в настройках вашей reverse-engineering tool необходимо включить соответствующую настройку.

Еще пара книг по этой теме, которые мне показались вполне понятными: Алекс Дэвис «Асинхронное программирование в C# 5.0», Richard Blewett, Andrew Clymer Pro Asynchronous Programming with .NET.


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

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

Похожие статьи:
Я познакомился с Михаилом примерно за год до того, как пришел работать в DataArt. Я был студентом, а ребята из компании приехали в Одессу...
Николай Алименков — независимый IT-консультант в XP Injection с более чем 17-летним опытом разработки. Его задача как консультанта —...
DOU повертається з новим випуском рубрики, присвяченій запитанням на співбесідах, що допоможуть пошукачам роботи якісно...
Журналісти BIHUS info випустили розслідування, яке стосується нового очільника головного управління контррозвідувального...
У випуску: тренди у техрайтингу, сучасні хмарні сервіси для перевірки документації, десятки книжок у PDF...
Яндекс.Метрика