ConfigureAwait: часто задаваемые вопросы

Привет, Хабр! Представляю вашему вниманию перевод статьи «ConfigureAwait FAQ» автора Стивен Тауб.

image

Async/await добавили в .NET более семи лет назад. Это решение оказало существенное влияние не только на экосистему .NET — оно также находит отражение во многих других языках и фреймворках. На данный момент реализовано множество усовершенствований в .NET с точки зрения дополнительных языковых конструкций, использующих асинхронность, реализованы API-интерфейсы с поддержкой асинхронности, произошли фундаментальные улучшения в инфраструктуре, благодаря которым async/await работает как часы (в особенности, улучшены возможности производительности и диагностики в .NET Core).

ConfigureAwait — один из аспектов async/await, который продолжает вызывать вопросы. Надеюсь, у меня получится ответить на многие из них. Я постараюсь сделать эту статью читаемой от начала до конца, и вместе с тем выполнить ее в стиле ответов на часто задаваемые вопросы (FAQ), чтобы на нее можно было ссылаться в последующем.

Чтобы на самом деле разобраться с ConfigureAwait, мы немного перенесемся назад.

Что такое SynchronizationContext?


Согласно документации 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.

Что такое Планировщик Задач?


SynchronizationContext это общая абстракция для “планировщика”. В некоторых фреймворках для него реализованы собственные абстракции, и System.Threading.Tasks не исключение. Когда в Task есть делегаты, которые могут быть поставлены в очередь и выполнены, они связаны с System.Threading.Tasks.TaskScheduler. Здесь также есть виртуальный метод Post для постановки делегата в очередь на выполнение (вызов делегата реализован при помощи стандартных механизмов), TaskScheduler предоставляет абстрактный метод QueueTask (вызов задачи реализован с помощью метода ExecuteTask).

Планировщик по умолчанию, который возвращает TaskScheduler.Default представляет собой пул потоков. Из TaskScheduler также есть возможность получить и переопределить методы для настройки времени и места вызова Task. Например, основные библиотеки включают тип System.Threading.Tasks.ConcurrentExclusiveSchedulerPair. Экземпляр этого класса предоставляет два свойства TaskScheduler: ExclusiveScheduler и ConcurrentScheduler. Задачи, запланированные в ConcurrentScheduler, могут выполняться параллельно, но с учетом ограничения, задаваемого ConcurrentExclusiveSchedulerPair при его создании (аналогично MaxConcurrencySynchronizationContext). Ни одна задача ConcurrentScheduler не будет выполняться, если выполняется задача в ExclusiveScheduler и разрешено запускать одновременно только одну эксклюзивную задачу. Данное поведение очень похоже на блокировку чтения/записи.

Как и SynchronizationContext, TaskScheduler имеет свойство Current, которое возвращает текущий TaskScheduler. Однако в отличие от SynchronizationContext в нем отсутствует метод для установки текущего планировщика. Вместо этого, планировщик связан с текущей задачей Task. Так, например, данная программа выведет True, так как лямбда, используемая в StartNew, выполняется в ExclusiveScheduler экземпляра ConcurrentExclusiveSchedulerPair, и TaskScheduler.Current установлен на данный планировщик:

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        var cesp = new ConcurrentExclusiveSchedulerPair();
        Task.Factory.StartNew(() =>
        {
            Console.WriteLine(TaskScheduler.Current == cesp.ExclusiveScheduler);
        }, default, TaskCreationOptions.None, cesp.ExclusiveScheduler).Wait();
    }
}

Интересно, что TaskScheduler предоставляет статический метод FromCurrentSynchronizationContext. Метод создает новый TaskScheduler и тот ставит задачи в очередь на выполнение в возвращаемом SynchronizationContext.Current контексте, используя метод Post.

Как SynchronizationContext и TaskScheduler связаны с await?


Допустим, необходимо написать UI приложение с кнопкой. Нажатие кнопки инициирует скачивание текста с веб сайта и устанавливает его в Content кнопки. Кнопка должны быть доступна только из UI потока, в котором она и находится, поэтому, когда мы успешно загружаем дату и время и хотим разместить их в Content кнопки, нам нужно это сделать из потока, который имеет над ней контроль. Если это условие не будет выполняться, мы получим исключение:

System.InvalidOperationException: 'Вызывающий поток не может получить доступ к этому объекту, поскольку им владеет другой поток.'

Мы можем вручную использовать SynchronizationContext, чтобы установить Content в исходном контексте, например через TaskScheduler:

private static readonly HttpClient s_httpClient = new HttpClient();

private void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    s_httpClient.GetStringAsync("http://example.com/currenttime").ContinueWith(downloadTask =>
    {
        downloadBtn.Content = downloadTask.Result;
    }, TaskScheduler.FromCurrentSynchronizationContext());
}

А можем использовать SynchronizationContext напрямую:

private static readonly HttpClient s_httpClient = new HttpClient();

private void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    SynchronizationContext sc = SynchronizationContext.Current;
    s_httpClient.GetStringAsync("http://example.com/currenttime").ContinueWith(downloadTask =>
    {
        sc.Post(delegate
        {
            downloadBtn.Content = downloadTask.Result;
        }, null);
    });
}

Однако оба эти варианта явно используют обратный вызов. Вместо этого мы можем использовать async/await:

private static readonly HttpClient s_httpClient = new HttpClient();

private async void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    string text = await s_httpClient.GetStringAsync("http://example.com/currenttime");
    downloadBtn.Content = text;
}

Все это “просто работает” и успешно настраивает Content в UI потоке, так как в случае с вручную реализованной выше версией, по умолчанию ожидание задачи обращается к SynchronizationContext.Current и TaskScheduler.Current. Когда вы «ожидаете» что-либо в C#, компилятор преобразует код для опроса (вызовом метода GetAwaiter) “ожидаемого” (в данном случае Task) для “ожидающего” (TaskAwaiter). “Ожидающий” отвечает за присоединение коллбэка (часто называемого “продолжением”) который осуществляет обратный вызов в конечный автомат по завершении ожидания. Он реализует это, используя тот контекст/планировщик, который захватил во время регистрации коллбэка. Немного оптимизируем и настроим, получится что-то вроде такого:

object scheduler = SynchronizationContext.Current;
if (scheduler is null && TaskScheduler.Current != TaskScheduler.Default)
{
    scheduler = TaskScheduler.Current;
}

Здесь сначала проверяется, задан ли SynchronizationContext, а если нет – существует ли нестандартный TaskScheduler. Если такой есть, то когда коллбэк будет готов к вызову, будет использован захваченный планировщик; если нет — обратный вызов выполнится как часть операции, завершающей ожидаемую задачу.

Что делает ConfigureAwait(false)


Метод ConfigureAwait не является специальным: он не распознается каким-либо особым образом компилятором или средой выполнения. Это обычный метод, который возвращает структуру (ConfiguredTaskAwaitable — оборачивает оригинальную задачу) и принимает булево значение. Не забывайте, что await может использоваться с любым типом, который реализует правильный паттерн. Если возвращается другой тип, это значит, что когда компилятор получает доступ к методу GetAwaiter (часть паттерна) экземпляров, но делает это из типа, возвращенного из ConfigureAwait, а не из задачи напрямую. Это позволяет менять поведение await для этого специального awaiter.

Ожидание типа, возвращаемого ConfigureAwait(continueOnCapturedContext: false) вместо ожидания Task, напрямую влияет на реализацию захвата контекста/планировщика, разобранную выше. Логика становится примерно такой:

object scheduler = null;
if (continueOnCapturedContext)
{
    scheduler = SynchronizationContext.Current;
    if (scheduler is null && TaskScheduler.Current != TaskScheduler.Default)
    {
        scheduler = TaskScheduler.Current;
    }
}

Иными словами, указывая false, даже при наличии текущего контекста или планировщика для обратного вызова, подразумевается, что он отсутствует.

Почему мне нужно использовать ConfigureAwait(false)?


ConfigureAwait(continueOnCapturedContext: false) используется для предотвращения принудительного вызова коллбэка в исходном контексте или планировщике. Это дает нам несколько преимуществ:

Улучшение производительности. Существуют накладные расходы постановки обратного вызова в очередь, в отличие просто от вызова, так как для этого требуется дополнительная работа (и, как правило, дополнительная аллокация). Кроме того мы не можем использовать оптимизацию во время выполнения (мы можем оптимизировать больше, когда точно знаем, как именно будет вызван обратный вызов, но если он передан произвольной реализации абстракции, иногда это накладывает ограничения). Для высоконагруженных участков даже дополнительные затраты на проверку текущего SynchronizationContext и текущего TaskScheduler (оба из которых подразумевают и доступ к статике потоков) могут существенно увеличить накладные расходы. Если код после await не требует выполнения в исходном контексте, используя ConfigureAwait(false) можно избежать всех этих расходов, так как он не нуждается в излишней постановке в очередь, может использовать все доступные оптимизации, а также может избежать ненужного доступа к статике потока.

Предотвращение дедлоков. Рассмотрим библиотечный метод, который использует await для загрузки чего-либо из сети. Вы вызываете этот метод и синхронно блокируетесь, ожидая полного завершения задачи Task, например, с помощью .Wait() или .Result или .GetAwaiter() .GetResult(). Теперь рассмотрим, что происходит, если вызов происходит, когда текущий SynchronizationContext ограничивает число операций в нем до 1 явным образом при помощи MaxConcurrencySynchronizationContext, или неявно, если это контекст с единственным потоком для использования, (например потоком UI). Таким образом, вы вызываете метод в единственном потоке, а затем блокируете его, ожидая завершения операции. Происходит запуск загрузки по сети и ожидание ее завершения. По умолчанию ожидание Task захватит текущий SynchronizationContext (так и в этом случае), и по завершении загрузки из сети, оно помещается в очередь обратно в коллбэк SynchronizationContext, который вызовет оставшуюся часть операции. Но единственный поток, который может обработать обратный вызов в очереди, в настоящее время заблокирован в ожидании завершения операции. И эта операция не будет завершена, пока не будет обработан обратный вызов. Дедлок! Он может произойти даже в том случае, когда контекст не ограничивает параллелизм до 1, но каким-либо образом ограничены ресурсы. Представьте себе ту же ситуацию, только со значением 4 для MaxConcurrencySynchronizationContext. Вместо того чтобы выполнить операцию однократно, мы ставим в очередь к контексту 4 вызова. Каждый вызов производится и происходит блокировка в ожидании его завершения. Все ресурсы теперь заблокированы в ожидании завершения асинхронных методов, и единственное, что позволит их завершить, это если их коллбэки будут обработаны этим контекстом. Однако тот уже полностью занят. Снова дедлок. Если бы вместо этого библиотечный метод использовал ConfigureAwait(false), он не ставил бы обратный вызов в очередь к исходному контексту, что позволило бы избежать сценариев дедлока.

Нужно ли использовать ConfigureAwait (true)?


Нет, за исключением тех случаев, когда нужно явно указать, что вы не используете ConfigureAwait(false) (например, для скрытия предупреждений статического анализа и т.п.). ConfigureAwait(true) не делает ничего значимого. Если сравнить await task и await task.ConfigureAwait(true) — они окажутся функционально идентичны. Таким образом, если в коде присутствует ConfigureAwait(true), его можно удалить без каких-либо негативных последствий.

Метод ConfigureAwait принимает логическое значение, так как в некоторых ситуациях ему может потребоваться передача переменной для управления конфигурацией. Но в 99% случаев задается значение false, ConfigureAwait(false).

Когда использовать ConfigureAwait(false)?


Это зависит от того, реализуете ли вы код уровня приложения или код библиотеки общего назначения.

При написании приложений обычно требуется некоторое поведение по умолчанию. Если модель приложения/среда (например, Windows Forms, WPF, ASP.NET Core) публикует специальный SynchronizationContext, почти наверняка этому есть веская причина: значит, код позволяет заботиться о контексте синхронизации для правильного взаимодействия с моделью приложения/средой. Например, если вы пишете, обработчик событий в приложении Windows Forms, тест в xUnit, или код в контроллере ASP.NET MVC, независимо от того, опубликовала ли модель приложения SynchronizationContext, вам нужно использовать SynchronizationContext при его наличии. Это значит, если используются ConfigureAwait(true) и await, обратные вызовы/продолжения отправляются обратно в исходный контекст — все идет как нужно. Отсюда можно сформулировать общее правило: если вы пишете код уровня приложения, не используйте ConfigureAwait(false). Давайте вернемся к обработчику клика:

private static readonly HttpClient s_httpClient = new HttpClient();

private async void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    string text = await s_httpClient.GetStringAsync("http://example.com/currenttime");
    downloadBtn.Content = text;
}

downloadBtn.Content = text должен быть выполнен в исходном контексте. Если код нарушил это правило и вместо этого использовал ConfigureAwait (false), тогда он не будет использован в исходном контексте:

private static readonly HttpClient s_httpClient = new HttpClient();

private async void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    string text = await s_httpClient.GetStringAsync("http://example.com/currenttime").ConfigureAwait(false); // баг
    downloadBtn.Content = text;
}

это приведет к неправильному поведению. То же самое относится и к коду в классическом ASP.NET приложении, зависящем от HttpContext.Current. При использовании ConfigureAwait(false) последующая попытка использовать функцию Context.Current, скорее всего, приведет к проблемам.

Этим и отличаются библиотеки общего назначения. Они являются универсальными отчасти потому, что их не волнует среда, в которой они используются. Вы можете использовать их из веб-приложения, из клиентского приложения или из теста — это не имеет значения, так как код библиотеки является агностическим для модели приложения, в которой он может быть использован. Агностический также означает, что библиотека не будет делать что-либо для взаимодействия с моделью приложения, например, она не будет получать доступ к элементам управления пользовательского интерфейса, потому что библиотека общего назначения ничего о них не знает. Так как нет необходимости запускать код в какой-либо конкретной среде, мы можем избежать принудительного вызова продолжений/обратных вызовов к исходному контексту, и мы делаем это, используя ConfigureAwait(false), что дает нам преимущества в производительности и повышает надежность. Это приводит нас к следующему: если вы пишете код библиотеки общего назначения, используйте ConfigureAwait(false). Вот почему каждый (или почти каждый) await в библиотеках среды выполнения .NET Core использует ConfigureAwait(false); За несколькими исключениями, которые скорее всего являются багами, и будут исправлены. Например, этот PR исправил отсутствующий вызов ConfigureAwait(false) в HttpClient.

Конечно это не везде имеет смысл. Например, одним из больших исключений (или, по крайней мере, случаев, где нужно подумать) в библиотеках общего назначения является случай, когда эти библиотеки имеют API, которые принимают делегаты на вызов. В таких случаях, библиотека принимает потенциальный код уровня приложения от вызывающей стороны, что делает эти допущения для библиотеки ”общего назначения" весьма спорными. Представьте, например, асинхронную версию метода Where LINQ: public static async IAsyncEnumerable<T> WhereAsync(this IAsyncEnumerable<T> source, Func<T, bool> predicate). Должен ли predicate вызываться в исходном SynchronizationContext вызывающего кода? Это зависит от реализации WhereAsync, и это причина, по которой он может решить не использовать ConfigureAwait(false).

Даже в особых случаях придерживайтесь общей рекомендации: используйте ConfigureAwait(false) если вы пишете библиотеку общего назначения/app-model-agnostic код.

Гарантирует ли ConfigureAwait (false), что обратный вызов не будет выполнен в исходном контексте?


Нет, это гарантирует, что он не будет поставлен в очередь к исходному контексту. Но это не значит, что код после await не будет выполняться в исходном контексте. Это связано с тем, что уже завершенные операции возвращаются синхронно, а не возвращаются принудительно в очередь. Поэтому, если вы ожидаете задание, которое уже завершено к моменту ожидания, независимо от того, используется ли ConfigureAwait(false), код сразу после этого будет продолжать выполняться в текущем потоке в контексте, который все еще является действительным.

Можно ли использовать ConfigureAwait (false) только при первом ожидании в моем методе, а в остальных — нет?


В общем, нет. Вспомните предыдущий FAQ. Если await task.ConfigureAwait(false) включает задачу, которая уже выполнена к моменту ожидания (что на самом деле происходит довольно часто), тогда использование ConfigureAwait(false) будет бессмысленным, так как поток продолжает выполнять следующий код в методе и по-прежнему в том же контексте, что и был ранее.

Одно примечательное исключений в том, что первый await всегда будет завершаться асинхронно, и ожидаемая операция вызовет его обратный вызов в среде, свободной от специального SynchronizationContext или TaskScheduler. Например, CryptoStream в библиотеках среды выполнения .NET проверяет, что его потенциально интенсивный с точки зрения вычислений код не выполняется как часть синхронного вызова вызывающего кода. Для этого он использует специальный awaiter, чтобы убедиться, что код после первого ожидания выполняется в потоке пула потоков. Однако даже в этом случае можно заметить, что следующий await по-прежнему использует ConfigureAwait(false); Технически в этом нет необходимости, но это значительно упрощает ревью кода, так как не нужно разбираться, почему не был использован ConfigureAwait(false).

Можно ли использовать Task.Run, чтобы избежать использования ConfigureAwait (false)?


Да, если вы напишете:

Task.Run(async delegate
{
    await SomethingAsync(); // не увидит оригинальный контекст
});

тогда ConfigureAwait(false) в SomethingAsync() будет лишним, так как делегат, переданный в Task.Run будет выполнен в потоке пула потоков, так что без изменений в коде выше, SynchronizationContext.Current вернет значение null. Более того, Task.Run неявно использует TaskScheduler.Default, поэтому TaskScheduler.Current внутри делегата также вернет значение Default. Это значит, что await будет иметь такое же поведение независимо от того, был ли использован ConfigureAwait(false). Это также не может дать гарантии насчет того, что может делать код внутри данной лямбды. Если у вас есть код:

Task.Run(async delegate
{
    SynchronizationContext.SetSynchronizationContext(new SomeCoolSyncCtx());
    await SomethingAsync(); // будет нацелен на SomeCoolSyncCtx
});

тогда код внутри SomethingAsync фактически увидит SynchronizationContext.Current экземпляра SomeCoolSyncCtx . и этот await, и любые не настроенные ожидания внутри SomethingAsync будут возвращены в данный контекст. Таким образом, чтобы использовать этот подход, необходимо понимать, что может делать или не делать весь код, который вы ставите в очередь, и могут ли его действия стать помехой.

Этот подход также происходит за счет необходимости создания/постановки в очередь дополнительного объекта задачи. Это может иметь или не иметь значение для приложения/библиотеки в зависимости от требований к производительности.

Также имейте в виду, что такие обходные пути могут вызвать больше проблем, чем преимуществ, и иметь разные непреднамеренные последствия. Например, некоторые инструменты статического анализа помечают флагом ожидания, которые не используют ConfigureAwait(false) CA2007. Если вы включите анализатор, а затем используете такой трюк чтобы избежать использования ConfigureAwait, есть большая вероятность, что анализатор отметит его. Это может повлечь за собой еще больше работы, например, вы можете захотеть отключить анализатор из-за его назойливости, а это уже повлечет за собой пропуск других мест в кодовой базе, где на самом деле нужно использовать ConfigureAwait(false).

Можно ли использовать SynchronizationContext.SetSynchronizationContext, чтобы избежать использования ConfigureAwait (false)?


Нет. Хотя, возможно. Это зависит от используемой реализации

Некоторые разработчики делают так:

Task t;
SynchronizationContext old = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);
try
{
    t = CallCodeThatUsesAwaitAsync(); // await'ы здесь не увидят оригинальный контекст
}
finally { SynchronizationContext.SetSynchronizationContext(old); }
await t; // будет по-прежнему нацелен на исходный контекст


в надежде, что это заставит код внутри CallCodeThatUsesAwaitAsync рассматривать текущий контекст как null. Так и будет. Однако этот вариант не повлияет на то, какой await видит TaskScheduler.Current. Поэтому если код выполняется в специальном TaskScheduler, await’ы внутри CallCodeThatUsesAwaitAsync будут видеть и становиться в очередь к этому специальному TaskScheduler.

Как и в Task.Run FAQ, здесь применимы все те же оговорки: есть определенные последствия такого подхода, и код внутри блока try может также помешать этим попыткам, задав другой контекст (или вызывая код с помощью нестандартного планировщика задач).

При таком шаблоне также нужно быть осторожным с незначительными изменениями:

SynchronizationContext old = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);
try
{
    await t;
}
finally { SynchronizationContext.SetSynchronizationContext(old); }

Видите в чем проблема? Немного трудно заметить, но это впечатляет. Нет гарантии, что ожидание в итоге вызовет обратный вызов/продолжение в исходном потоке. Это значит, что возврат SynchronizationContext к исходному может не произойти в первоначальном потоке, что может привести к тому, что последующие рабочие элементы в этом потоке увидят неправильный контекст. Для противодействия этому, хорошо написанные модели приложений, которые задают специальный контекст, как правило, добавляют код для ручного сброса его перед вызовом любого дополнительного пользовательского кода. И даже если это происходит в одном потоке, может понадобиться некоторое время, в течение которого контекст может не быть соответствующим образом восстановлен. А если он работает в ином потоке, это может привести к установке неправильного контекста. И так далее. Довольно далеко от идеала.

Нужно ли использовать ConfigureAwait(false) если я использую GetAwaiter ().GetResult ()?


Нет. ConfigureAwait затрагивает только коллбэки. В частности, шаблон awaiter требует, чтобы awaiter’ы предоставляли свойство IsCompleted, методы GetResult и OnCompleted (опционально с методом UnsafeOnCompleted). ConfigureAwait влияет только на поведение {Unsafe}OnCompleted, так что если вы напрямую вызываете GetResult(), независимо от того делаете это через TaskAwaiter или ConfiguredTaskAwaitable.ConfiguredTaskAwaiter разницы в поведении нет. Поэтому если вы видите task.ConfigureAwait(false).GetAwaiter().GetResult() вы можете заменить его на task.GetAwaiter().GetResult() (кроме того подумайте, действительно ли вам нужна именно такая реализация).

Я знаю, что код выполняется в среде, в которой никогда не будет специального SynchronizationContext или специального TaskScheduler. Можно ли не использовать ConfigureAwait(false)?


Возможно. Это зависит от того, насколько вы уверены по части «никогда». Как упоминалось в предыдущих вопросах, только то, что модель приложения, в которой вы работаете, не задает специальный SynchronizationContext и не вызывает ваш код в специальном TaskScheduler, не означает, что код другого пользователя или библиотеки их не использует. Так что нужно быть в этом уверенным, или хотя бы признать риск, что такой вариант возможен.

Я слышал, что в .NET Core нет необходимости применять ConfigureAwait (false). Так ли это?


Не так. Она необходима при работе в .NET Core по тем же причинам, что и при работе в .NET Framework. В этом плане ничего не изменилось.

Изменилось то, публикуют ли определенные среды собственный SynchronizationContext. В частности, в то время как классический ASP.NET в .NET Framework имеет свой SynchronizationContext, у ASP.NET Core его нет. Это означает, что код, запущенный в приложении ASP.NET Core по умолчанию не будет видеть специальный SynchronizationContext, что уменьшает необходимость в ConfigureAwait(false) в данной среде.

Однако это не значит, что никогда не будет присутствовать пользовательский SynchronizationContext или TaskScheduler. Если какой-либо код пользователя (или другой код библиотеки, используемый приложением) задает пользовательский контекст и вызывает ваш код или вызывает ваш код в Задаче, запланированной в специальном планировщике задач, тогда await’ы в ASP.NET Core будут видеть нестандартный контекст или планировщик, который может привести к необходимости использования ConfigureAwait(false). Конечно, в ситуациях, когда вы избегаете синхронных блокировок (что в любом случае нужно делать в веб-приложениях) и если вы не против небольших накладных расходов в производительности в некоторых случаях, вы можете обойтись без использования ConfigureAwait(false).

Могу ли я использовать ConfigureAwait, когда «ожидаю выполнения foreach» над IAsyncEnumerable?


Да. Пример см. в статье MSDN.

Await foreach соответствует шаблону и, таким образом, может использоваться для перечисления в IAsyncEnumerable<T>. Он также может использоваться для перечисления элементов, которые представляют правильную область API. Библиотеки времени выполнения .NET включают метод расширения ConfigureAwait для IAsyncEnumerable<T>, который возвращает специальный тип, который оборачивает IAsyncEnumerable<T> и Boolean и соответствует правильному шаблону. Когда компилятор генерирует вызовы к MoveNextAsync и DisposeAsync перечислителя. Эти вызовы относятся к возвращенному сконфигурированному типу структуры перечислителя, который в свою очередь, выполняет ожидания нужным образом.

Можно ли использовать ConfigureAwait, при ‘await using’ IAsyncDisposable?


Да, пусть и с небольшим усложнением.

Как и с IAsyncEnumerable<T>, .NET библиотеки времени выполнения предоставляют метод расширения ConfigureAwait для IAsyncDisposable и await using, будет отлично работать, поскольку он реализует соответствующий шаблон (а именно, предоставляет соответствующий метод DisposeAsync):

await using (var c = new MyAsyncDisposableClass().ConfigureAwait(false))
{
    ...
}

Проблема здесь состоит в том, что тип c — теперь не MyAsyncDisposableClass, а скорее System.Runtime.CompilerServices.ConfiguredAsyncDisposable, который возвратился из метода расширения ConfigureAwait для IAsyncDisposable.

Чтобы обойти это, нужно добавить строку:

var c = new MyAsyncDisposableClass();
await using (c.ConfigureAwait(false))
{
    ...
}

Теперь тип c снова является желаемым MyAsyncDisposableClass. Что также имеет эффект увеличения области действия для c; если нужно, вы можете обернуть все это в фигурные скобки.

Я использовал ConfigureAwait (false), но мой AsyncLocal все равно перетек в код после ожидания. Это баг?


Нет, это вполне ожидаемо. Поток данных AsyncLocal<T> являются частью ExecutionContext, который отделен от SynchronizationContext. Если вы явно не отключили поток ExecutionContext с помощью ExecutionContext.SuppressFlow(), ExecutionContext (и, таким образом, данные AsyncLocal <T>) всегда будет проходить через awaits, независимо от того, используется ли ConfigureAwait во избежание захвата исходного SynchronizationContext. Более подробно рассмотрено в этой статье.

Могут ли языковые средства помочь мне избежать необходимости явно использовать ConfigureAwait(false) в моей библиотеке?


Разработчики библиотек иногда выражают недовольство необходимостью использовать ConfigureAwait(false) и просят менее инвазивные альтернативы.

В настоящее время их нет, по крайней мере, они не встроены в язык/компилятор/среду выполнения. Однако существует множество предложений относительно того, как это можно реализовать, например: 1, 2, 3, 4.

Если рассмотренная тема вам интересна, если у вас есть новые и интересные идеи, автор оригинальной статьи приглашает вас к обсуждению.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 17

    0

    спасибо! как раз нужна была такая статья!

      0

      Отлично написано, очень много полезной информации, спасибо автор!

        0

        Мне вот интересно, как сочетаются следующие факты:


        • EntityFramework — не thread-safe
          • даже если сделать EF таковым, все равно нельзя на одном sql-connection, т. е. в одной транзакции, выполнять 2 запроса одновременно.
          • в asp.net core убрали synchronization context, который скидывал все в один поток

        Прав ли я, что EF + async — работать не должны by design?


        Прав ли я, что async не даст никакой пользы при <1000 запросов в секунду?


        Т.е. прав ли я, что async-и в типичном приложении приносят риски отстрела конечностей, и при этом — не дадут никаких никаких реальных плюсов?

          0
          одном sql-connection, т. е. в одной транзакции, выполнять 2 запроса одновременно.
          Замечу что Соединение и Транзакция не одно и тоже. Можно выполнять несколько запросов одновременно смотрим что такое Multiple Active Result Sets (MARS).
            0
            Я знаю про MARS, но это специфичная для MSSQL фича. В том же PostgreSQL, ее нет.
              0
              В firebird есть, в SQLite есть. В принципе, хороший дизайн — хендл на statement, и можно открывать их сколько угодно (в пределах лимита ресурсов сервера).
            –1
            в asp.net core убрали synchronization context, который скидывал все в один поток

            Не скидывал. Вы путаете с WinForms/WPF

            Прав ли я, что EF + async — работать не должны by design?

            Почему?

            Прав ли я, что async не даст никакой пользы при <1000 запросов в секунду?

            Только если у вас нет I/O-bound операций.

            Т.е. прав ли я, что async-и в типичном приложении приносят риски отстрела конечностей, и при этом — не дадут никаких никаких реальных плюсов?

            Какие риски вы имеете ввиду?
              +1
              Асинхронность != параллельность.
              await дает потоку, отправившему запрос в базу, вернуться в пул и начать выполнять что угодно другое. А потом какой-то поток, может даже тот же, вернется получить результаты запроса.
              В один момент времени с контекстом как работал один поток, так и продолжает.
                0
                Теоретически — да. Практически, рассчитывать на то, что никто и никогда не поставит какой-нибудь Task.WaitAll в коде, кишашем async-ами — мне кажется дюже наивно.
                  0
                  А зачем что-то где-то вручную ставить? В 99% случаев все Async методы EF вызываются исключительно с await. А уж если программист сделал два подряд FirstOrDefaultAsync и не подождал первый await'ом, то он сам себе злобный буратино, что ж тут поделать.

                  Task.WaitAll может вообще понадобиться только в каких-то достаточно экзотических случаях, когда надо условно сделать запрос по сети и почитать из базы, и ты знаешь, что и то и другое долго, а запросы не зависят друг от друга и их можно сделать параллельно. Для чисто работы с базой код пишется так же, как если бы он писался синхронным, только всё возможные методы меняются на их *Async друзей, и перед ними ставится await. На этом все преобразования можно закончить, и никаких ошибок не будет. Программа просто сможет продолжать делать что-то абсолютно другое из другого запроса, а не зависнет всеми потоками в ожиданиях чтения из базы/по сети.
                    –1
                    Могут и await забыть. И может сразу и не упадёт, а упадет в продакшне. Там же как повезёт — скинет в другой поток, или в том же выполнится. Я разгребал такого рода проблемы — там может быть всё очень нетривиально.

                    Ну и дальше берем какое-нибудь типичное веб-приложение: где 100 запросов в секунду от силы, производительность упирается в БД, а не в C#-код, и оптимизировать там надо совсем не скорость переключения потоков, а SQL-запросы. И берем лида, который не очень понимает в многопоточности вообще. Ну, например мне тут один загонял что без async/await-а ядра процессора будут простаивать (!!!) в ожидании завершения IO. Короче ему на хабре нашептали, что без async/await не модно, но при этом как это все работает и зачем оно нужно — он не понимает.

                    И вопрос — надо ли async/await среднестатистическим .NET-чикам — которые пилят ненагруженные приложения в стиле достань-положи JSON в БД. Или лучше сверху над этой темой написать большими красными буквами:
                    Если вы не хотите выжимать >1к RPS, и не разбираетесь досконально в теме — пишите как обычно, и не трогайте async-и. Tread Pool прекрасно работает, и отлично справляется со скедулингом операций и без async/await. Это вам не node.js — где всего один поток, и без них просто нельзя.

                    Мне кажется так будет честнее, чем навязывать эту тему вообще всем подряд, как это делается сейчас.
                      0

                      Асинхронное программирование — нереально удобная штука в правильных руках. И дело тут даже не в производительности, а в продуктивности — удобстве и скорости написания кода. Так-то да, при небольшой нагрузке можно обойтись и синхронными операциями, но асинхронные мне нравятся больше. Вот список преимуществ с моей точки зрения:


                      1. Возможность управления задачами с помощью CancellationToken. В случае синхронных операций обычная практика — тупо закрыть сокет/поток/объект и перехватить ошибку. Для асинхронных операций же предусмотрен цивилизованный механизм остановки операции.


                      2. Лёгкость взаимодействия с UI. Можно забыть про BeginInvoke и просто писать компактный код.


                      3. Возможность масштабирования до тысяч запросов в секунду вообще без каких-либо усилий. Зачем переписывать код с синхронного на асинхронный, когда можно сразу писать асинхронный код, да ещё с меньшими трудозатратами?


                0
                Прав ли я, что EF + async — работать не должны by design?

                Выдается ошибка выполнения при попытке в том же DB context выполнить второй async не дожидаясь await первого, если я правильно понял о чем идет речь.

                0

                Я как-то давно написал собственный кросс-платформенный однопоточный планировщик для эффективной работы с сетью, использующий IOCP и epoll — в те времена велосипед был необходимостью. По сравнению с универсальным штатным планировщиком, IOPS моего решения оказался почти на порядок выше.


                Основной причиной повышения производительности стал отказ от выполнения задач на пуле потоков, так как раньше львиная доля ресурсов расходовалась на переключение контекста и межпотоковую синхронизацию, а теперь в режиме кооперативной многозадачности с ручным переключением это оказалось не нужно. При этом возможность отправить CPU-bound задачи на пул потоков сохранилась.


                Недостатком же оказалась невозможность использования моего планировщика в библиотеках из-за жёсткой зависимости от конкретных реализаций, например, использования системного Socket вместо универсального Stream и того самого ConfigureAwait(false);, используемого не по делу. В итоге библиотечный код просто работал в планировщике по умолчанию. Как по мне, так не вызываемый код должен решать, в каком контексте он будет работать, а вызывающий.

                  0
                  Про эту боль была статья )))
                  0
                  Я привык явно всегда и везде вызывать ConfigureAwait(), даже когда это не нужно т.е. ConfigureAwait( true ), на самом деле это стандартный code style для Resharper и просто не поставить не получится, будет все перечеркнуто warnings, а если в jenkins эти warnings подняты до уровня error, то вообще билд не будет делаться (у нас так строго, да)
                  С точки зрения программирования это тоже полезно, потому что заставляет разработчика указать explicit intention, хочет или нет он, чтобы сохранялся контекст для выполнения continuation на нем.
                    +1
                    на самом деле это стандартный code style для Resharper и просто не поставить не получится, будет все перечеркнуто warnings, а если в jenkins эти warnings подняты до уровня error, то вообще билд не будет делаться (у нас так строго, да)

                    Идиотизм, если честно.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое