Зависает оператор `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 продолжение в очереди и никаких продолжений после завершения не остается гарантировано).