...

суббота, 28 декабря 2019 г.

[Из песочницы] 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.

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

Let's block ads! (Why?)

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

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