Источник: Habr.com
Дата: 2019-09-08
Кто из нас не косячит? Я регулярно встречаюсь с ошибками в асинхронном коде и делаю их сам. Чтобы прекратить это колесо Сансары делюсь с вами самыми типичными косяками из тех, которые иногда довольно сложно отловить и починить.
Этот текст вдохновлен блогом Стивена Клэри, человека который знает всё про конкурентность, асинхронность, многопоточность и другие страшные слова. Он автор книги Concurrency in C# Cookbook, собравшей в себе огромное количество паттернов для работы с конкурентностью.
Для понимания асинхронного дедлока стоит разобраться в каком потоке исполняется метод, вызванный с использованием ключевого слова 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 есть специальный поток — поток пользовательского интерфейса. 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()
:
StartWork
и дойдет до инструкции await Task.Delay(100)
.Task.Delay(100)
, а сам вернёт управление в метод Button_Click
, а там его ждёт метод Wait()
класса Task
. При вызове метода Wait()
произойдёт блокировка UI-потока до момента окончания асинхронной операции, и мы ожидаем, что как только она завершится, UI-поток сразу же подхватит выполнение и пойдёт дальше по коду, однако, всё будет не так.Task.Delay(100)
завершится, UI-поток должен будет сначала продолжить выполнение метода StartWork()
и для этого ему нужен строго тот поток, в котором выполнение стартовало. Но UI-поток сейчас занят ожиданием результата выполнения операции.