Как стать автором
Обновить
Dodo Engineering
О том, как разработчики строят IT в Dodo

Асинхронный рассинхрон: антипаттерны в работе с async/await в .NET

Время на прочтение 9 мин
Количество просмотров 49K

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




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

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


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


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


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


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


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


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


Дедлок в WPF и WinForms


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


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

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";
}

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


Дедлок в ASP.NET


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


Пример:

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

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


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


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


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




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

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


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

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


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


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


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


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

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


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

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

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


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

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

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


Будьте аккуратнее с методами 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 объект).


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


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


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


UPD:
Как справедливо замечают в комментариях, выпиливание async/await из однострочных методов ведет к негативным побочным эффектам. Например, при выбросе исключения, метод, пробрасывающий Task наверх, не будет видно в стэке. Поэтому, убирать эвейты по-умолчанию не рекоммендуется. Пост Клэри с разбором.
Теги:
Хабы:
+47
Комментарии 64
Комментарии Комментарии 64

Публикации

Информация

Сайт
dodo.dev
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия