Источник: Habr.com

Дата: 2019-09-08


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

Этот текст вдохновлен блогом Стивена Клэри, человека который знает всё про конкурентность, асинхронность, многопоточность и другие страшные слова. Он автор книги 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-поток сейчас занят ожиданием результата выполнения операции.