Зависает оператор `await` в оконном приложении / программа висит при вызове Task.Result или Wait

1,00
р.
Есть простой код
private static void Foo() { Bar().Wait() Console.WriteLine("Foo() done.") }
private static async Task Bar() { await Task.Delay(1000) Console.WriteLine("Bar() done.") }
Почему при вызове Foo() программа зависает и на консоль ничего не выводится?
Как этот код исправить?

Ответ
Объяснение
Такое случается при работе в потоке UI. Дело в том, что все асинхронные вызовы, сделанные из потока UI, после выполнения "возвращаются" обратно в свой поток. И если этот поток заблокирован ожиданием окончания вызова - привет взаимоблокировка!
Разберем что происходит подробнее.
Вызывается метод Bar. Начинается задача Task.Delay(1000) Управление из метода Bar возвращается в метод Foo В методе Foo начинается синхронное ожидание результата задачи, которое останавливает очередь сообщений. Через секунду завершается задача Task.Delay(1000) в потоке таймера Потоку UI посылается оконное сообщение, чтобы он возобновил выполнение метода Bar.
Но поток UI не может обработать это сообщение - ведь он висит в методе Foo! Все. Взаимоблокировка.
Решение первое - сквозная асинхронность.
Все просто - если вызов Wait() вешает программу - надо избежать его. К примеру, сделать функцию Foo асинхронной:
private static async Task Foo() { await Bar() Console.WriteLine("Foo() done.") }
private static async Task Bar() { await Task.Delay(1000) Console.WriteLine("Bar() done.") }
Разумеется, одно такое преобразование проблему не решает - ведь кто-то же вызывает Foo() и ему теперь тоже надо дождаться окончания ее работы. Поэтому это преобразование придется делать уровень за уровнем до самого верха.
Закончится все, скорее всего, обработчиком событий. Его придется сделать async void-методом. Если Foo - обработчик события, то это будет выглядеть так:
private static async void Foo() { await Bar() Console.WriteLine("Foo() done.") }
Но у таких методов есть проблема. Если вылетит исключение - оно либо уронит программу, либо вы про него никогда не узнаете. Поэтому всегда настраивайте обработчики неперехваченных исключений.
Для WinForms это делается так:
Application.ThreadException += Application_ThreadException
// ...
static void Application_ThreadException(object sender, ThreadExceptionEventArgs e) { // Тут надо записать исключение в лог или показать MessageBox }
Решение второе - уйти в пул потоков
Если вывести исполнение метода Bar из потока UI - проблема также исчезнет. Для этого достаточно запустить Bar в пуле потоков любым доступным способом.
К примеру, вот так:
private static void Foo() { Func f = Bar f.EndInvoke(f.BeginInvoke(null, null)).Wait() Console.WriteLine("Foo() done.") }
Но этот код я привел только для примера. EndInvoke/BeginInvoke попросту выглядит некрасиво - так что лучше использовать более свежее API.
Наверное, наиболее красивый способ - вот этот:
private static void Foo() { Task.Run(() => Bar()).Wait() Console.WriteLine("Foo() done.") }
Однако, при использовании Task.Run не удастся обойтись без замыкания, если Bar принимает хотя бы один параметр. Получается лишняя функция в цепочке вызовов. В этом нет ничего страшного для уже существующего кода - но при написании кода с нуля может возникнуть желание писать без дополнительных функций.
Нет ничего проще:
private static void Foo() { Task.Run(async () => { await Task.Delay(1000) Console.WriteLine("Foo() inner task done.") }).Wait() Console.WriteLine("Foo() done.") }
Решение третье - "побег из потока UI"
Оба прошлых решения были основаны на изменении кода Foo - но это будет менять код Bar. В этом есть некоторый смысл как в плане защитного программирования, так и в плане оптимизации. Дело в том, что асинхронный код быстрее выполняется в пуле потоков, чем в потоке UI - а потому большие куски кода, которые не работают с UI-контролами, из потока UI целесообразно вытаскивать.
Это можно было бы сделать обернув весь метод Bar в одну большую лябмду и воспользовавшиcь Task.Run - но красивым такой способ не назвать. Поэтому "побег из потока UI" чаще всего делают при помощи "хитрых" форм оператора await.
Самый простой вариант - это вот такой:
private static async Task Bar() { await Task.Delay(1000).ConfigureAwait(false) Console.WriteLine("Bar() done.") }
Вызов ConfigureAwait(false) говорит продолжать исполнение в том потоке, где выполнилась задача, не возвращаясь обратно в поток UI. И такой код работает.
Но у ConfigureAwait(false) есть свои подводные камни. Во-первых, если задача была выполнена еще до вызова await - то реального переключения не произойдет! А значит, вот такой код работать не будет:
private static async Task Bar() { await Task.FromResult(0).ConfigureAwait(false) // не работает await Task.Delay(1000) Console.WriteLine("Bar() done.") }
Поэтому, ConfigureAwait(false) желательно дописывать к каждому вызову await, а не только к первому. Это значительно снижает визуальную красоту решения.
Во-вторых, вложенные асинхронные вызовы не защищены от взаимоблокировок, как это было с Task.Run! Особенно первый вложенный вызов, ведь он делается еще до переключения на поток пула.
private static async Task Bar() { await Baz().ConfigureAwait(false) Console.WriteLine("Bar() done.") }
private static async Task Baz() { await Task.Delay(1000) // тут все равно повисло }
Кроме того, код выше содержит и другую проблему. Задача Baz() выполнялась в потоке UI - а значит, вызов await Baz().ConfigureAwait(false) переключит нас ... на поток UI, а вовсе не на поток пула, куда мы так стремились!
Так что серебряной пулей ConfigureAwait(false) определенно не является.
Если есть желание окончательно переключаться в поток пула одной строчкой, могу предложить вот такой хелпер:
struct ContextSwitcher : INotifyCompletion { private SynchronizationContext target
public static ContextSwitcher SwitchToBackground() { return SwitchToContext(null) }
public static ContextSwitcher SwitchToContext(SynchronizationContext target) { return new ContextSwitcher { target = target } }
public ContextSwitcher GetAwaiter() { return this }
public bool IsCompleted { get { return SynchronizationContext.Current == target } }
public void GetResult() { }
public void OnCompleted(Action continuation) { if (target == null) continuation.BeginInvoke(continuation.EndInvoke, null) else target.Post(_ => continuation(), null) } }
Использование:
private static async Task Bar() { await ContextSwitcher.SwitchToBackground()
await Task.Delay(1000) // Мы уже в потоке пула, и у нас нет никаких взаимоблокировок Console.WriteLine("Bar() done.") }
Решение четвертое - ожидание в своем контексте синхронизации
Зачем создавать отдельный поток - если все асинхронные продолжения можно выполнить в текущем? Надо лишь придумать как выполнять их во время ожидания...
К примеру, для этого можно установить свой контекст синхронизации, который будет ставить продолжения в очередь - а во время ожидания он будет из этой очереди продолжения исполнять.
Тут самое сложное - аккуратно обработать ситуацию поступления продолжения в очередь когда ожидание закончилось. Такое продолжение надо вернуть родительскому контексту...
class QueueSynchronizationContext : SynchronizationContext, IDisposable { struct PostData { public SendOrPostCallback d public object state }
private BlockingCollection queue = new BlockingCollection(new ConcurrentQueue()) private readonly SynchronizationContext parent = SynchronizationContext.Current
public QueueSynchronizationContext() { SynchronizationContext.SetSynchronizationContext(this) }
public override void Send(SendOrPostCallback d, object state) { throw new NotSupportedException() }
public override void Post(SendOrPostCallback d, object state) { var q = queue try { if (q != null) q.Add(new PostData { d = d, state = state }) } catch (InvalidOperationException) { // Мы можем сюда попасть после вызова CompleteAdding, если он произошел только что (гонка) или если измененная queue еще не видна в текущем потоке // ObjectDisposedException попадает сюда же q = null } if (q == null) PostToParent(d, state) }
private void PostToParent(SendOrPostCallback d, object state) { if (parent == null) d.BeginInvoke(state, d.EndInvoke, null) else parent.Post(d, state) }
public void Dispose() { using (var queue_local = Interlocked.Exchange(ref queue, null)) if (queue_local != null) { queue_local.CompleteAdding()
foreach (var data in queue_local.GetConsumingEnumerable()) PostToParent(data.d, data.state) }
SynchronizationContext.SetSynchronizationContext(parent) }
public void RunLoop(CancellationToken token) { var queue_local = queue if (queue_local == null) throw new ObjectDisposedException("QueueSynchronizationContext")
try { foreach (var data in queue_local.GetConsumingEnumerable(token)) data.d(data.state) } catch (OperationCanceledException) { // Это нормальный выход из queue_local.GetConsumingEnumerable(token) при отмене токена return }
// А если мы добрались сюда - значит, кто-то нас уже закрыл throw new ObjectDisposedException("QueueSynchronizationContext") }
public void WaitFor(Task task, int timeout = Timeout.Infinite) { using (var source = new CancellationTokenSource(timeout)) { task.ContinueWith(_ => source.Cancel(), TaskContinuationOptions.ExecuteSynchronously) RunLoop(source.Token) } }
public void WaitFor(Task task, TimeSpan timeout) { using (var source = new CancellationTokenSource(timeout)) { task.ContinueWith(_ => source.Cancel(), TaskContinuationOptions.ExecuteSynchronously) RunLoop(source.Token) } }
public void WaitFor(Task task, CancellationToken token) { using (var source = CancellationTokenSource.CreateLinkedTokenSource(token)) { task.ContinueWith(_ => source.Cancel(), TaskContinuationOptions.ExecuteSynchronously) RunLoop(source.Token) } } }
Использование:
private static void Foo() { using (var ctx = new QueueSynchronizationContext()) { ctx.WaitFor(Bar()) Console.WriteLine("Foo() done.") } }
Замечание - внутри этого блока using нельзя использовать оператор await по понятным причинам. А вот внутри вызываемых методов (например, внутри Bar) - можно.
Кстати, нечто подобное использует Windows Workflow Foundation для "синхронного" исполнения рабочего процесса. Только там получается проще из-за особенностей работы (максимум 1 продолжение в очереди и никаких продолжений после завершения не остается гарантировано).