Главная » Хабрахабр » [Из песочницы] Асинхронный рассинхрон: антипаттерны в работе с async/await в .NET

[Из песочницы] Асинхронный рассинхрон: антипаттерны в работе с async/await в .NET

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

Он автор книги Concurrency in C# Cookbook, собравшей в себе огромное количество паттернов для работы с конкурентностью. Этот текст вдохновлен блогом Стивена Клэри, человека который знает всё про конкурентность, асинхронность, многопоточность и другие страшные слова.

Классический асинхронный deadlock

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

Как именно реализуется источник асинхронности — тема, выходящая за рамки данной статьи. Сначала метод будет углубляться в цепочку вызовов async-методов пока не встретит источник асинхронности. Синхронный запуск такой операции означает то, что во время ожидания её результата в системе будет как минимум один заснувший поток, который потребляет ресурсы, но не выполняет никакой полезной работы. Сейчас для простоты примем, что это операция, которая не требует рабочего потока во время ожидания её результата, например запрос к базе данных или HTTP-запрос.

NET нет никаких гарантий, что код, лежащий после await будет выполняться в том же потоке, что и код до await. При асинхронном вызове, мы как бы разрываем поток выполнения команд на «до» и «после» асинхронной операции и в . Нужно использовать SynchronizationContext. В большинстве случаев это и не нужно, но что делать, когда такое поведение жизненно необходимо для работы программы? Далее мы будем иметь дело с двумя контекстами синхронизации (WindowsFormsSynchronizationContext и AspNetSynchronizationContext), но Алекс Дэвис в своей книге пишет, что в . Это механизм, позволяющий наложить определенные ограничения на потоки, в которых выполняется код. Про SynchronizationContext хорошо написано здесь, здесь, а здесь автор реализовал свой собственный, за что ему большой респект. NET их около десятка.

Current, потом стартует асинхронную операцию и освобождает текущий поток. Итак, как только код приходит к источнику асинхронности, он сохраняет контекст синхронизации, который был в thread-static свойстве SynchronizationContext. После окончания выполнения асинхронной операции мы должны выполнить инструкции, которые находятся после источника асинхронности и тут, для того чтобы решить в каком потоке нам выполнять код после асинхронной операции, нам нужно проконсультироваться с сохраненным ранее контекстом синхронизации. Иными словами, пока мы ждем окончания выполнения асинхронной операции, мы не блокируем ни один поток и это главный профит от асинхронной операции по сравнению с синхронной. Скажет выполнять в том же потоке, что и код до await — выполним в том же, не скажет — возьмем первый попавшийся поток из пула. Как он скажет, так и будем делать.

Нужно использовать мантру ConfigureAwait(false). А что делать, если в данном конкретном случае нам важно, чтобы код после await выполнялся в любом свободном потоке из пула потоков? А что произойдет, если в момент выполнения метода с await контекста синхронизации вообще не было (SynchronizationContext. Значение false, переданное в параметр continueOnCapturedContext как раз и сообщает системе, что можно использовать любой поток из пула. В этом случае у нас нет никаких ограничений на поток, в котором должен быть выполнен код после await и система возьмет первый попавшийся поток из пула, как и в случае с ConfigureAwait(false). Current == null), как например в консольном приложении.

Итак, что же такое асинхронный дедлок?

Дедлок в WPF и WinForms

У контекста синхронизации WPF и WinForms есть специальный поток — поток пользовательского интерфейса. Отличием WPF и WinForms-приложений является наличие того самого контекста синхронизации. По умолчанию, код, начавший работать в UI-потоке, возобновляет работу после асинхронной операции в нём же. UI-поток один на SynchronizationContext и только из этого потока можно взаимодействовать с элементами пользовательского интерфейса.

Теперь посмотрим на пример:

private void Button_Click(object sender, System.Windows.RoutedEventArgs e)
{ StartWork().Wait();
}
private async Task StartWork()
{ await Task.Delay(100); var s = "Just to illustrate the instruction following await";
}

Что произойдет при вызове StartWork().Wait():

  1. Вызывающий поток (а это поток пользовательского интерфейса) войдёт в метод StartWork и дойдет до инструкции await Task.Delay(100).
  2. UI-поток запустит асинхронную операцию Task.Delay(100), а сам вернёт управление в метод Button_Click, а там его ждёт метод Wait() класса Task. При вызове метода Wait() произойдёт блокировка UI-потока до момента окончания асинхронной операции, и мы ожидаем, что как только она завершится, UI-поток сразу же подхватит выполнение и пойдёт дальше по коду, однако, всё будет не так.
  3. Как только Task.Delay(100) завершится, UI-поток должен будет сначала продолжить выполнение метода StartWork() и для этого ему нужен строго тот поток, в котором выполнение стартовало. Но UI-поток сейчас занят ожиданием результата выполнения операции.
  4. Дедлок: StartWork() не может продолжить выполнение и вернуть результат, а Button_Click ждёт того самого результата, а из-за того, что выполнение стартовало в потоке пользовательского интерфейса, приложение просто напросто повиснет без шансов на продолжение работы.

Такую ситуацию можно довольно просто вылечить изменив вызов Task.Delay(100) на Task.Delay(100).ConfigureAwait(false):

private void Button_Click(object sender, System.Windows.RoutedEventArgs e)
{ StartWork().Wait();
}
private async Task StartWork()
{ await Task.Delay(100).ConfigureAwait(false); var s = "Just to illustrate the instruction following await";
}

Стивен Клэри в своём блоге рекомендует использовать ConfigureAwait(false) во всех «библиотечных методах», но специально подчеркивает, что использовать ConfigureAwait(false) для лечения дедлоков — неправильная практика. Этот код отработает без дедлоков, так как теперь для завершения метода StartWork() может быть использован поток из пула, а не заблокированный UI-поток. GetResult() и переводить все методы на использование async/await, если это возможно (так называемый принцип Async all the way). Вместо этого он советует НЕ использовать блокирующие методы типа Wait(), Result, GetAwaiter().

Дедлок в ASP.NET

NET также есть контекст синхронизации, но у него немного другие ограничения. В ASP. Он разрешает использовать только один поток на запрос в одно и то же время и так же требует, чтобы код после await выполнялся в том же потоке, что и код до await.

Пример:

public class HomeController : Controller
private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the code following await"; }
}

Wait() единственный разрешённый поток будет заблокирован и будет ожидать окончания операции StartWork(), а она никогда не закончится, так как поток, в котором выполнение должно продолжиться, занят ожиданием. Этот код так же вызовет дедлок, так как в момент вызова StartWork().

Исправляется это всё тем же ConfigureAwait(false).

Дедлок в ASP.NET Core (на самом деле нет)

NET в проекте для ASP. Теперь попробуем запустить код из примера для ASP. Если мы это сделаем, то увидим, что дедлока не будет. NET Core. NET Core нет контекста синхронизации. Это связано с тем, что в ASP. И что, теперь можно обмазывать код блокирующими вызовами и не боятся дедлоков? Отлично! Строго говоря да, но помните, что это заставляет поток засыпать во время ожидания, то есть поток потребляет ресурсы, но не выполняет никакой полезной работы.

Да, иногда без использования Wait() не получится написать программу, но причина должна быть серьёзной. Запомните, что использование блокирующих вызовов нивелирует все преимущества асинхронного программирования превращая его в синхронное.

Ошибочное использование Task.Run()

Run() был создан для запуска операций в новом потоке. Метод Task. Run() и эвейтить результат этого метода. Как и положено методу, написанному по TAP-паттерну, он возвращает Task или Task<T> и у людей, которые впервые сталкиваются с async/await появляется большое желание завернуть синхронный код в Task. Давайте разберёмся что получается при таком использовании Task. Код как будто бы стал асинхронным, но на самом деле ничего не поменялось. Run().

Пример:

private static async Task ExecuteOperation()
{ Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}"); await Task.Run(() => { Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}"); Thread.Sleep(1000); Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}"); }); Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}");
}

Результатом работы этого кода будет:

Before: 1
Inside before sleep: 3
Inside after sleep: 3
After: 3

Sleep(1000) — это какая-либо синхронная операция, которая требует потока для выполнения. Здесь Thread. Run(). Допустим, мы хотим сделать наше решение асинхронным и для того, чтобы эту операцию можно было эвейтить, мы завернули её в Task.

Run(), достаётся другой поток из пула потоков и в нём исполняется код, который мы передали в Task. Как только код доходит до метода Task. Новый поток выполняет переданный код, доходит до синхронной операции, синхронно выполняет её (ждёт пока операция не будет выполнена) и идёт дальше по коду. Run() с использованием ключевого слова await, то старый поток, как и положено приличному потоку, возвращается в пул и ждёт, когда его снова позовут делать работу. Единственное отличие — мы потратили время на переключение контекста при вызове Task. Иными словами, операция так и осталась синхронной: мы, как и раньше, используем поток во время выполнения синхронной операции. Всё стало немножечко хуже. Run() и при возврате в ExecuteOperation().

Просто ASP. Надо понимать, что несмотря на то, что в строках Inside after sleep: 3 и After: 3 мы видим один и тот же Id потока, в этих местах совершенно разный контекст выполнения. Run() во внешний код. NET умнее нас и старается сэкономить ресурсы при переключении контекста из кода внутри Task. Здесь он решил не менять хотя бы поток выполнения.

Run(). В таких случаях нет никакого смысла использовать Task. Sleep(1000) на Task. Вместо этого Клэри советует делать все операции асинхронными, то есть в нашем случае заменять Thread. Что делать в случаях, когда мы используем сторонние библиотеки, которые не можем или не хотим переписывать и делать до конца асинхронными, но нам по тем или иным причинам нужен именно async-метод? Delay(1000), но это, конечно, не всегда возможно. FromResult() для оборачивания результата работы вендорных методов в Task. Лучше использовать Task. Это, конечно, не сделает код асинхронным, но мы хотя бы сэкономим на переключении контекста.

Run()? Ответ прост: для CPU-bound операций, когда нужно сохранить отзывчивость UI или распараллелить вычисления. Для чего же тогда использовать Task. Именно для запуска синхронных операций в асинхронном стиле и был придуман Task. Здесь нужно сказать, что CPU-bound операции по натуре синхронны. Run().

Использование async void не по назначению

Возможность писать асинхронные методы, возвращающие void была добавлена для того, чтобы писать асинхронные хендлеры событий. Давайте разберёмся почему они могут внести смуту, если их использовать не по назначению:

  1. Нельзя дождаться результата.
  2. Не поддерживается обработка исключений через try-catch.
  3. Нельзя комбинировать вызовы через Task.WhenAll(), Task.WhenAny() и прочие подобные методы.

Дело в том, что в async-методах, возвращающих Task или Task<T>, исключения перехватываются и оборачиваются в объект Task, который потом будет передан вызывающему методу. Из всех перечисленных причин самым интересным моментом является обработка исключений. Итогом является необработанное исключение из-за которого процесс крашится, успевая, разве что написать ошибку в консоль. В своей статье для MSDN Клэри пишет, что так как в async-void методах нет возвращаемого значения, то и оборачивать исключения не во что и они возбуждаются напрямую в контексте синхронизации. UnhandledException, но остановить краш процесса даже в обработчике этого события уже не удастся. Получить и залогировать такие исключения можно подписавшись на событие AppDomain. Такое поведение характерно как раз для хендлера события, но не для обычного метода, от которого мы ожидаем возможности стандартной обработки исключения через try-catch.

Например, если в ASP.NET Core приложении написать так, процесс гарантированно упадёт:

public IActionResult ThrowInAsyncVoid()
{ ThrowAsynchronously(); return View();
}
private async void ThrowAsynchronously()
{ throw new Exception("Obviously, something happened");
}

NET Core, а процесс будет продолжать жить несмотря на эксепшн. Но стоит поменять тип возвращаемого значения метода ThrowAsynchronously на Task (даже не добавляя ключевое слово await) и исключение будет перехвачено стандартным хендлером ошибок ASP.

Будьте аккуратнее с методами async-void — они могут положить вам процесс.

await в однострочном методе

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

Вместо такого кода:

public async Task MyMethodAsync()
{ await Task.Delay(1000);
}

вполне можно (и предпочтительно) было бы написать:

public Task MyMethodAsync()
{ return Task.Delay(1000);
}

Потому, что ключевое слово await может применяться к Task-like объектам, а не к методам, помеченным ключевым словом async. Почему это работает? В свою очередь ключевое слово async как раз говорит компилятору о том, что данный метод нужно развернуть в конечный автомат, а все возвращаемые значения обернуть в Task (или в другой Task-like объект).

Delay(1000), а результат второй версии метода — Task, возвращаемый самим Task. Иными словами, результат первой версии метода — Task, который станет Completed как только закончится ожидание Task. Delay(1000), который станет Completed, как только пройдёт 1000 милисекунд.

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

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


Оставить комментарий

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

*

x

Ещё Hi-Tech Интересное!

Компания Sikorsky провела демонстрацию беспилотного вертолёта с человеком на борту

Вертолёт SARA (Sikorsky Autonomy Research Aircraft) на базе Sikorsky S-76 с автопилотом Matrix Technology Созданный набор систем Matrix Technology достиг уже такого уровня, что в течение года компания планирует интегрировать некоторые функции в вертолёты Black Hawk, которые поставляет для армии. ...

[Перевод] Ремастеринг «Звёздного пути» нейросетями до 1080p и 4K

В качестве небольшого любительского проекта я поэкспериментировал с нейросетями AI Gigapixel для апскейла одного из моих любимых научно-фантастических сериалов — Star Trek: Deep Space Nine (DS9), в русском переводе «Звёздный путь: Глубокий космос 9». Так же, как Final Fantasy 7, ...