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

Я изучаю async/await и после того, как прочитал эту статью Не блокировать код Async

и этот Является async/await подходящим для методов, которые связаны как с IO, так и с ЦП.

Я замечаю один совет из статьи @Stephen Cleary.

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

Он появился снова в коде сообщения, как я уже говорил выше.

public async Task<HtmlDocument> LoadPage(Uri address)
{
    using (var httpResponse = await new HttpClient().GetAsync(address)
        .ConfigureAwait(continueOnCapturedContext: false)) //IO-bound
    using (var responseContent = httpResponse.Content)
    using (var contentStream = await responseContent.ReadAsStreamAsync()
        .ConfigureAwait(continueOnCapturedContext: false)) //IO-bound
        return LoadHtmlDocument(contentStream); //CPU-bound
}

Насколько я знаю, когда мы используем ConfigureAwait (false), остальная часть метода async будет запущена в пуле потоков. Почему мы должны добавить его в каждую погоду в транзитивном закрытии? Я сам думаю, что это правильная версия, как я знал.

public async Task<HtmlDocument> LoadPage(Uri address)
{
    using (var httpResponse = await new HttpClient().GetAsync(address)
        .ConfigureAwait(continueOnCapturedContext: false)) //IO-bound
    using (var responseContent = httpResponse.Content)
    using (var contentStream = await responseContent.ReadAsStreamAsync()) //IO-bound
        return LoadHtmlDocument(contentStream); //CPU-bound
}

Это означает, что второе использование ConfigureAwait (false) в использовании блока бесполезно. Пожалуйста, скажите мне правильный путь. Спасибо заранее.

Ответы

Ответ 1

Насколько я знаю, когда мы используем ConfigureAwait(false), оставшийся метод async будет запущен в пуле потоков.

Закрыть, но есть важная оговорка, которую вы пропускаете. Когда вы возобновите работу после выполнения задачи с помощью ConfigureAwait(false), вы вернетесь на произвольный поток. Обратите внимание на слова "когда вы возобновляете".

Позвольте мне кое-что показать:

public async Task<string> GetValueAsync()
{
    return "Cached Value";
}

public async Task Example1()
{
    await this.GetValueAsync().ConfigureAwait(false);
}

Рассмотрим await в Example1. Хотя вы ожидаете метод async, этот метод фактически не выполняет асинхронную работу. Если метод async не имеет значения await, он выполняется синхронно, и awaiter никогда не возобновляется, потому что он никогда не приостанавливается в первую очередь. Как показывает этот пример, вызовы ConfigureAwait(false) могут быть излишними: они могут вообще не иметь никакого эффекта. В этом примере любой контекст, который вы использовали при вводе Example1, - это контекст, который вы включите после await.

Не совсем то, что вы ожидали, верно? И все же это не совсем необычно. Многие методы async могут содержать быстрые пути, которые не требуют приостановки вызова вызывающего абонента. Доступность кэшированного ресурса - хороший пример (спасибо, @jakub-dąbek!), Но есть много других причин, по которым метод async может заложить раньше. Мы часто проверяем различные условия в начале метода, чтобы увидеть, можем ли мы избежать ненужной работы, а методы async не отличаются.

Посмотрим на другой пример, на этот раз из приложения WPF:

async Task DoSomethingBenignAsync()
{
    await Task.Yield();
}

Task DoSomethingUnexpectedAsync()
{
    var tcs = new TaskCompletionSource<string>();
    Dispatcher.BeginInvoke(Action(() => tcs.SetResult("Done!")));
    return tcs.Task;
}

async Task Example2()
{
    await DoSomethingBenignAsync().ConfigureAwait(false);
    await DoSomethingUnexpectedAsync();
}

Взгляните на Example2. Первый метод we await всегда выполняется асинхронно. К тому времени, когда мы нажмем второй await, мы знаем, что мы работаем в потоке пула потоков, так что нет необходимости ConfigureAwait(false) во втором вызове, правильно? Неправильно. Несмотря на наличие async в имени и возврат Task, наш второй метод не был написан с использованием async и await. Вместо этого он выполняет собственное планирование и использует TaskCompletionSource для передачи результата. Когда вы вернетесь из своего await, вы можете [1] закончить работу над любым потоком, предоставившим результат, который в этом случае является потоком диспетчера WPF. Упс.

Ключевым выводом здесь является то, что вы часто не знаете точно, что делает "ожидаемый" метод. С помощью или без CongifureAwait вы можете запустить что-то неожиданное. Это может произойти на любом уровне стека вызовов async, поэтому самый верный способ избежать непреднамеренного владения однопоточным контекстом - использовать ConfigureAwait(false) с каждым await, то есть во всем транзитивном закрытии.

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

[1] Это может варьироваться в зависимости от версии фреймворка и компилятора.