Async/await добавили в .NET более семи лет назад. Это решение оказало существенное влияние не только на экосистему .NET — оно также находит отражение во многих других языках и фреймворках. На данный момент реализовано множество усовершенствований в .NET с точки зрения дополнительных языковых конструкций, использующих асинхронность, реализованы API-интерфейсы с поддержкой асинхронности, произошли фундаментальные улучшения в инфраструктуре, благодаря которым async/await работает как часы (в особенности, улучшены возможности производительности и диагностики в .NET Core).
ConfigureAwait — один из аспектов async/await, который продолжает вызывать вопросы. Надеюсь, у меня получится ответить на многие из них. Я постараюсь сделать эту статью читаемой от начала до конца, и вместе с тем выполнить ее в стиле ответов на часто задаваемые вопросы (FAQ), чтобы на нее можно было ссылаться в последующем.
Чтобы на самом деле разобраться с ConfigureAwait, мы немного перенесемся назад.
Согласно документации System.Threading.SynchronizationContext “Обеспечивает базовую функциональность для распространения контекста синхронизации в различных моделях синхронизации”. Это определение не совсем очевидное.
В 99.9% случаев SynchronizationContext используется просто как тип с виртуальным методом Post, который принимает делегат на асинхронное выполнение (в SynchronizationContext есть и другие виртуальные члены, но они встречаются реже и не будут рассмотрены в этой статье). Метод Post базового типа буквально просто вызывает ThreadPool.QueueUserWorkItem для асинхронного выполнения предоставленного делегата. Производные типы переопределяют Post, чтобы делегат можно было выполнить в нужном месте в нужное время.
К примеру, в Windows Forms есть производный от SynchronizationContext тип, который переопределяет Post, чтобы сделать эквивалент Control.BeginInvoke. Это означает, что любой вызов данного Post-метода будет приводить к вызову делегата на более позднем этапе в потоке, связанном с соответствующим Control — так называемом UI потоке. В основе Windows Forms лежит обработка сообщений Win32. Цикл сообщений выполняется в UI потоке, который просто ждет новые сообщения для обработки. Эти сообщения вызываются движением мыши, кликом, вводом с клавиатуры, системными событиями, доступными для выполнения делегатами и т. д. Таким образом, при наличии экземпляра SynchronizationContext для UI потока в приложении Windows Forms, чтобы выполнить в нем операцию необходимо передать делегат методу Post.
В Windows Presentation Foundation (WPF) также есть производный от SynchronizationContext тип с переопределенным методом Post, который аналогично “направляет” делегат в UI поток (с помощью Dispatcher.BeginInvoke), при этом управление происходит Диспетчером WPF, а не Windows Forms Control.
И в Windows RunTime (WinRT) есть свой SynchronizationContext -производный тип, который также ставит делегат в очередь UI-потока при помощи CoreDispatcher.
Вот что скрывается за фразой “выполнить делегат в UI потоке”. Можно также реализовать свой SynchronizationContext с методом Post и какой-нибудь реализацией. Например, я могу не беспокоиться в каком потоке выполняется делегат, но я хочу быть уверен, что любые делегаты метода Post в моем SynchronizationContext выполняются с некоторой ограниченной степенью параллелизма. Можно реализовать специальный SynchronizationContext таким образом:
internal sealed class MaxConcurrencySynchronizationContext : SynchronizationContext
{
private readonly SemaphoreSlim _semaphore;
public MaxConcurrencySynchronizationContext(int maxConcurrencyLevel) =>
_semaphore = new SemaphoreSlim(maxConcurrencyLevel);
public override void Post(SendOrPostCallback d, object state) =>
_semaphore.WaitAsync().ContinueWith(delegate
{
try { d(state); } finally { _semaphore.Release(); }
}, default, TaskContinuationOptions.None, TaskScheduler.Default);
public override void Send(SendOrPostCallback d, object state)
{
_semaphore.Wait();
try { d(state); } finally { _semaphore.Release(); }
}
}
Во фреймворке xUnit есть похожая реализация SynchronizationContext. Здесь она используется для снижения количества кода, связанного с параллельными тестами.
Преимущество здесь такие же, как и с любой абстракцией: предоставляется единый API, который можно использовать для постановки в очередь на выполнение делегата таким образом, как того пожелает программист, при этом нет необходимости знать детали реализации. Допустим, я пишу библиотеку, где мне нужно сделать некоторую работу, а затем поставить делегат в очередь обратно в исходный контекст. Для этого мне нужно захватить его SynchronizationContext, и когда я завершу необходимое, мне останется вызвать метод Post данного контекста и передать ему делегат на выполнение. Мне не нужно знать, что для Windows Forms нужно взять Control и использовать его BeginInvoke, для WPF использовать BeginInvoke у Dispatcher, или каким-то образом получить контекст и его очередь для xUnit. Все что мне нужно — это захватить текущий SynchronizationContext и использовать его позже. Для этого у SynchronizationContext есть свойство Current. Это можно реализовать следующим образом:
public void DoWork(Action worker, Action completion)
{
SynchronizationContext sc = SynchronizationContext.Current;
ThreadPool.QueueUserWorkItem(_ =>
{
try { worker(); }
finally { sc.Post(_ => completion(), null); }
});
}
Установить специальный контекст из свойства Current можно при помощи метода SynchronizationContext.SetSynchronizationContext.