Привет! Мы продолжаем рассказывать об асинхронном программировании на C#. Сегодня поговорим о едином сценарии использования или пользовательском сценарии, подходящем для любых задач в рамках асинхронного программирования. Затронем темы синхронизации, взаимоблокировок, настройки операторов, обработки исключений и много другого. Присоединяйтесь!
Предыдущие статьи по теме
Практически любое нестандартное поведение асинхронных методов в C# можно объяснить, исходя из одного пользовательского сценария: преобразование существующего синхронного кода в асинхронный должно быть максимально простым. Необходимо иметь возможность добавить ключевое слово async перед возвращаемым типом метода, добавить суффикс Async к имени этого метода, а также добавить ключевое слово await здесь и в текстовой области метода, чтобы получить полнофункциональный асинхронный метод.
«Простой» сценарий кардинально меняет многие аспекты поведения асинхронных методов: от планирования продолжительности выполнения задачи до обработки исключений. Сценарий выглядит убедительным и значимым, однако в его контексте простота асинхронных методов становится весьма обманчивой.
Контекст синхронизации
Разработка пользовательского интерфейса (UI) — одна из областей, где описанный выше сценарий особенно важен. Из-за продолжительных операций в потоке пользовательского интерфейса время отклика приложений увеличивается, и в этом случае асинхронное программирование всегда считалось весьма эффективным инструментом.
private async void buttonOk_ClickAsync(object sender, EventArgs args)
{
textBox.Text = "Running.."; // 1 -- UI Thread
var result = await _stockPrices.GetStockPricesForAsync("MSFT"); // 2 -- Usually non-UI Thread
textBox.Text = "Result is: " + result; //3 -- Should be UI Thread
}
Код выглядит очень простым, но возникает одна проблема. Для большинства пользовательских интерфейсов имеются ограничения: элементы UI могут изменяться только специальными потоками. То есть в строке 3 возникает ошибка, если продолжительность выполнения задачи запланирована в потоке из пула потоков. К счастью, об этой проблеме известно давно, и в версии .NET Framework 2.0 появилось понятие контекста синхронизации.
Каждый UI предоставляет специальные служебные программы для маршалинга задач в один или несколько специализированных потоков пользовательского интерфейса. Windows Forms использует метод Control.Invoke
, WPF — Dispatcher.Invoke, остальные системы могут обращаться к другим методам. Схемы, используемые во всех этих случаях, во многом похожи, однако различаются в деталях. Контекст синхронизации позволяет абстрагироваться от различий, предоставляя API для запуска кода в «специальном» контексте, который обеспечивает обработку второстепенных деталей такими производными типами, как WindowsFormsSynchronizationContext
, DispatcherSynchronizationContext
и т. д.
Чтобы решить проблему, связанную со сходством потоков, программисты С# решили ввести текущий контекст синхронизации на начальном этапе реализации асинхронных методов и запланировать все последующие операции в таком контексте. Теперь каждый из блоков между операторами await выполняется в потоке пользовательского интерфейса, благодаря чему становится возможным внедрение главного сценария. Однако данное решение породило ряд новых проблем.
Взаимоблокировки
Давайте рассмотрим небольшой, относительно простой фрагмент кода. Здесь есть какие-либо проблемы?
// UI code
private void buttonOk_Click(object sender, EventArgs args)
{
textBox.Text = "Running..";
var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
textBox.Text = "Result is: " + result;
}
// StockPrices.dll
public Task<decimal> GetStockPricesForAsync(string symbol)
{
await Task.Yield();
return 42;
}
Этот код вызывает взаимоблокировку. Поток пользовательского интерфейса запускает асинхронную операцию и синхронно ожидает результата. Однако выполнение асинхронного метода нельзя завершить, поскольку вторая строка GetStockPricesForAsync
должна выполняться в потоке пользовательского интерфейса, который вызывает взаимоблокировку.
Вы возразите, что эту проблему довольно легко решить. Да, действительно. Нужно запретить все вызовы метода Task.Result
или Task.Wait
из кода пользовательского интерфейса, однако проблема все равно может возникать в том случае, если компонент, используемый таким кодом, синхронно ожидает результата пользовательской операции:
// UI code
private void buttonOk_Click(object sender, EventArgs args)
{
textBox.Text = "Running..";
var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
textBox.Text = "Result is: " + result;
}
// StockPrices.dll
public Task<decimal> GetStockPricesForAsync(string symbol)
{
// We know that the initialization step is very fast,
// and completes synchronously in most cases,
// let's wait for the result synchronously for "performance reasons".
InitializeIfNeededAsync().Wait();
return Task.FromResult((decimal)42);
}
// StockPrices.dll
private async Task InitializeIfNeededAsync() => await Task.Delay(1);
Этот код опять же вызывает взаимную блокировку. Как это решить:
- Не следует блокировать асинхронный код посредством
Task.Wait()
илиTask.Result
и - использовать
ConfigureAwait(false)
в коде библиотеки.
Смысл первой рекомендации понятен, а вторую мы разъясним ниже.
Настройка операторов await
Есть две причины, в силу которых в последнем примере возникает взаимоблокировка: Task.Wait()
в GetStockPricesForAsync
и косвенное использование контекста синхронизации на последующих этапах в InitializeIfNeededAsync. Хотя программисты C# не рекомендуют блокировать вызовы асинхронных методов, очевидно, что в массе случаев такая блокировка все равно используется. Программисты С# предлагают следующее решение проблемы, связанной с взаимоблокировками: Task.ConfigureAwait(continueOnCapturedContext:false)
.
Несмотря на странный вид (если вызов метода выполняется без именованного аргумента, это абсолютно ни о чем не говорит), свою функцию это решение выполняет: оно обеспечивает принудительное продолжение выполнения без контекста синхронизации.
public Task<decimal> GetStockPricesForAsync(string symbol)
{
InitializeIfNeededAsync().Wait();
return Task.FromResult((decimal)42);
}
private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false);
В этом случае продолжение выполнения задачи Task.Delay(1
) (здесь — пустой оператор) планируется в потоке из пула потоков, а не в потоке пользовательского интерфейса, что устраняет взаимоблокировку.
Отключение контекста синхронизации
Я знаю, что ConfigureAwait
фактически решает эту проблему, но сам порождает гораздо большую. Вот маленький пример:
public Task<decimal> GetStockPricesForAsync(string symbol)
{
InitializeIfNeededAsync().Wait();
return Task.FromResult((decimal)42);
}
private async Task InitializeIfNeededAsync()
{
// Initialize the cache field first
await _cache.InitializeAsync().ConfigureAwait(false);
// Do some work
await Task.Delay(1);
}
Вы видите проблему? Мы использовали ConfigureAwait(false)
, поэтому все должно быть хорошо. Но не факт.
ConfigureAwait(false)
возвращает настраиваемый объект awaiter ConfiguredTaskAwaitable
, а мы знаем, что он используется только в случае, если задача не завершается синхронно. То есть, если _cache.InitializeAsync()
завершается синхронно, взаимоблокировка все равно возможна.
Чтобы устранить взаимоблокировку, все задачи, ожидающие завершения, необходимо «украсить» вызовом метода ConfigureAwait(false)
. Все это раздражает и порождает ошибки.
Как вариант, можно использовать настраиваемый объект awaiter во всех общедоступных методах, чтобы отключить контекст синхронизации в асинхронном методе:
private void buttonOk_Click(object sender, EventArgs args)
{
textBox.Text = "Running..";
var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
textBox.Text = "Result is: " + result;
}
// StockPrices.dll
public async Task<decimal> GetStockPricesForAsync(string symbol)
{
// The rest of the method is guarantee won't have a current sync context.
await Awaiters.DetachCurrentSyncContext();
// We can wait synchronously here and we won't have a deadlock.
InitializeIfNeededAsync().Wait();
return 42;
}
Awaiters.DetachCurrentSyncContext
возвращает следующий настраиваемый объект awaiter:
public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion
{
/// <summary>
/// Returns true if a current synchronization context is null.
/// It means that the continuation is called only when a current context
/// is presented.
/// </summary>
public bool IsCompleted => SynchronizationContext.Current == null;
public void OnCompleted(Action continuation)
{
ThreadPool.QueueUserWorkItem(state => continuation());
}
public void UnsafeOnCompleted(Action continuation)
{
ThreadPool.UnsafeQueueUserWorkItem(state => continuation(), null);
}
public void GetResult() { }
public DetachSynchronizationContextAwaiter GetAwaiter() => this;
}
public static class Awaiters
{
public static DetachSynchronizationContextAwaiter DetachCurrentSyncContext()
{
return new DetachSynchronizationContextAwaiter();
}
}
DetachSynchronizationContextAwaiter
делает следующее: метод async работает с ненулевым контекстом синхронизации. Но если метод async работает без контекста синхронизации, свойство IsCompleted
возвращает true, а продолжение метода выполняется синхронно.
Это означает наличие служебных данных, близких к нулю, когда асинхронный метод выполняется из потока в пуле потоков, и оплата совершается однократно за перевод выполнения из потока пользовательского интерфейса в поток из пула потоков.
Ниже перечислены другие преимущества данного подхода.
- Снижается вероятность ошибки.
ConfigureAwait(false)
работает только в том случае, если применяется для всех задач, ожидающих завершения. Стоит забыть хотя бы об одной — и может возникнуть взаимоблокировка. В случае с настраиваемым объектом awaiter следует помнить, что все общедоступные методы библиотеки должны начинаться сAwaiters.DetachCurrentSyncContext()
. Ошибки возможны и здесь, но их вероятность значительно ниже. - Полученный в результате код отличается большей декларативностью и четкостью. Метод с использованием нескольких вызовов
ConfigureAwait
кажется мне менее удобным для чтения (из-за лишних элементов) и недостаточно информативным для новичков.
Обработка исключений
В чем разница между этими двумя вариантами:
Task mayFail = Task.FromException(new ArgumentNullException());
// Case 1
try { await mayFail; }
catch (ArgumentException e)
{
// Handle the error
}
// Case 2
try { mayFail.Wait(); }
catch (ArgumentException e)
{
// Handle the error
}
В первом случае все соответствует ожиданиям — выполняется обработка ошибки, но во втором случае этого не происходит. Библиотека параллельных задач TPL создана для асинхронного и параллельного программирования, и Task/Task может представлять результат нескольких операций. Именно поэтому Task.Result
и Task.Wait()
всегда выдают исключение AggregateException
, которое может содержать несколько ошибок.
Однако наш главный сценарий меняет все: пользователь должен иметь возможность добавить оператор async/await, не трогая логику обработки ошибок. То есть оператор await должен отличаться от Task.Result
/Task.Wait()
: он должен снимать обертку с одного исключения в экземпляре AggregateException
. Сегодня мы выберем первое исключение.
Все нормально, если все методы на основе Task являются асинхронными и для выполнения задач не используются параллельные вычисления. Но в некоторых случаях все иначе:
try
{
Task<int> task1 = Task.FromException<int>(new ArgumentNullException());
Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
// await will rethrow the first exception
await Task.WhenAll(task1, task2);
}
catch (Exception e)
{
// ArgumentNullException. The second error is lost!
Console.WriteLine(e.GetType());
}
Task.WhenAll
возвращает задачу с двумя ошибками, однако оператор await извлекает и заполняет только первую.
Решить эту проблему можно двумя способами:
- вручную просмотреть задачи, если к ним есть доступ, или
- настроить в библиотеке TPL принудительное заворачивание исключения в другое исключение
AggregateException
.
try
{
Task<int> task1 = Task.FromException<int>(new ArgumentNullException());
Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
// t.Result forces TPL to wrap the exception into AggregateException
await Task.WhenAll(task1, task2).ContinueWith(t => t.Result);
}
catch(Exception e)
{
// AggregateException
Console.WriteLine(e.GetType());
}
Метод async void
Метод на основе Task возвращает токен, который можно использовать для обработки результатов в будущем. Если задача утеряна, токен становится недоступным для считывания пользовательским кодом. Асинхронная операция, возвращающая метод void, выдает ошибку, которую невозможно обработать в пользовательском коде. В этом смысле токены бесполезны и даже опасны — сейчас мы это увидим. Однако наш главный сценарий предполагает их обязательное использование:
private async void buttonOk_ClickAsync(object sender, EventArgs args)
{
textBox.Text = "Running..";
var result = await _stockPrices.GetStockPricesForAsync("MSFT");
textBox.Text = "Result is: " + result;
}
Но что если GetStockPricesForAsync
выдает ошибку? Необработанное исключение метода async void маршализируется в текущий контекст синхронизации, запуская то же самое поведение, что и для синхронного кода (дополнительные сведения см. в разделе Метод ThrowAsync на веб-странице AsyncMethodBuilder.cs). В Windows Forms необработанное исключение в обработчике событий запускает событие Application.ThreadException
, для WPF запускается событие Application.DispatcherUnhandledException
и т. д.
А если метод async void не получает контекста синхронизации? В этом случае необработанное исключение вызывает неустранимый сбой приложения. Оно не будет запускать восстанавливаемое событие [TaskScheduler.UnobservedTaskException
], а запустит невосстанавливаемое событие AppDomain.UnhandledException
и затем закроет приложение. Это происходит намеренно, и именно такой результат нам нужен.
Теперь давайте рассмотрим еще один известный способ: использование асинхронных методов void только для обработчиков событий пользовательского интерфейса.
К сожалению, метод asynch void легко вызвать совершенно случайно.
public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError)
{
// Calls 'provider' N times and calls 'onError' in case of an error.
}
public async Task<string> AccidentalAsyncVoid(string fileName)
{
return await ActionWithRetry(
provider:
() =>
{
return File.ReadAllTextAsync(fileName);
},
// Can you spot the issue?
onError:
async e =>
{
await File.WriteAllTextAsync(errorLogFile, e.ToString());
});
}
С первого взгляда на лямбда-выражение трудно сказать, является ли функция методом на основе Task или методом async void, и поэтому в вашу базу кода может закрасться ошибка, несмотря на самую тщательную проверку.
Заключение
На многие аспекты асинхронного программирования на языке С# повлиял один пользовательский сценарий — простое преобразование синхронного кода существующего приложения пользовательского интерфейса в асинхронный:
- Последующее выполнение асинхронных методов запланировано в полученном контексте синхронизации, что может вызвать взаимоблокировки.
- Чтобы предотвратить их, необходимо повсеместно в коде асинхронной библиотеки разместить вызовы
ConfigureAwait(false)
. - await task; выдает первую ошибку, и это усложняет создание исключения обработки для параллельного программирования.
- Методы async void введены для обработки событий пользовательского интерфейса, но их легко выполнить совершенно случайно, что вызовет сбой приложения в случае необработанного исключения.
Бесплатный сыр бывает только в мышеловке. Простота использования иногда может обернуться большими сложностями в других областях. Если вы знакомы с историей асинхронного программирования на C#, самое странное поведение перестает казаться таким уж странным, и вероятность возникновения ошибок в асинхронном коде существенно снижается.
Автор: sahsAGU