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