Страницы

Поиск по вопросам

среда, 3 октября 2018 г.

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

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

Комментариев нет:

Отправить комментарий