Найти тему
155 подписчиков

Как на самом деле работает Async/Await в C# ч.10

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.

SynchronizationContext и ConfigureAwait

Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится. SynchronizationContext позволяет вызывать повторно используемые вспомогательные функции и автоматически запланировать их выполнение в любое время и в любом месте, где это считает нужным вызывающая среда. В результате естественно ожидать, что это "просто работает" с async/await, и это так и есть. Вернемся к обработчику клика по кнопке из нашего предыдущего обсуждения:

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-2

С async/await мы бы хотели написать это следующим образом:

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-3

Это вызов ComputeMessage переносится на пул потоков, и после завершения метода выполнение возвращается обратно на поток пользовательского интерфейса, связанный с кнопкой, и установка свойства Text происходит на этом потоке.

Интеграция с SynchronizationContext оставляется на усмотрение реализации ожидания (код, сгенерированный для машины состояний, ничего не знает о SynchronizationContext), поскольку именно ожидание отвечает за фактическое вызов или постановку в очередь предоставленного продолжения, когда завершается представленная асинхронная операция. Хотя пользовательский ожидание не обязано уважать SynchronizationContext.Current, ожидания для Task, Task<TResult>, ValueTask и ValueTask<TResult> все это делают. Это означает, что по умолчанию, когда вы ожидаете Task, Task<TResult>, ValueTask, ValueTask<TResult> или даже результат вызова Task.Yield(), ожидание по умолчанию будет искать текущий SynchronizationContext и, если успешно получит не по умолчанию, в конечном итоге поставит продолжение в эту контекст.

Мы можем увидеть это, если посмотрим на код, связанный с TaskAwaiter. Вот фрагмент соответствующего кода из Corelib:

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-4

Это часть метода, который определяет, какой объект сохранять в Task в качестве продолжения. Он передает stateMachineBox, который, как было упомянуто ранее, может быть непосредственно сохранен в списке продолжений Task. Однако эта специальная логика может обернуть этот IAsyncStateMachineBox, чтобы также включить планировщик, если он присутствует. Он проверяет, есть ли в данный момент не по умолчанию SynchronizationContext, и если есть, создает SynchronizationContextAwaitTaskContinuation в качестве фактического объекта, который будет сохранен в качестве продолжения; этот объект, в свою очередь, оборачивает оригинал и захваченный SynchronizationContext и знает, как вызвать MoveNext оригинала в рабочем элементе, поставленном в последний. Это позволяет вам ожидать в рамках некоторого обработчика событий в приложении пользовательского интерфейса и продолжить код после завершения ожидания на правильном потоке. Следующее интересное замечание здесь - это не просто внимание к SynchronizationContext: если не удалось найти пользовательский SynchronizationContext для использования, он также проверяет, используется ли тип TaskScheduler, который используется задачами, и есть ли в нем непо умолчанию, который нужно учесть. Как и с SynchronizationContext, если есть не по умолчанию, он затем оборачивается с оригинальным box в TaskSchedulerAwaitTaskContinuation, который используется в качестве объекта продолжения.

Но, возможно, самое интересное замечание здесь - это самая первая строка тела метода: if (continueOnCapturedContext). Мы делаем эти проверки для SynchronizationContext/TaskScheduler только в том случае, если continueOnCapturedContext истинно; если это ложно, реализация ведет себя так, как будто оба были по умолчанию и игнорирует их. Что, спрашиваете, устанавливает continueOnCapturedContext в ложь? Вы, вероятно, догадались: использование всегда популярного ConfigureAwait(false).

Я говорю о ConfigureAwait подробно в FAQ по ConfigureAwait, поэтому я бы посоветовал вам прочитать это для получения дополнительной информации. Достаточно сказать, что единственное, что делает ConfigureAwait(false) в рамках ожидания, это передает его аргумент Boolean в эту функцию (и другие подобные) в качестве значения continueOnCapturedContext, чтобы пропустить проверки на SynchronizationContext/TaskScheduler и вести себя так, как будто ни один из них не существует. В случае с задачами это затем позволяет задаче вызывать свои продолжения там, где она считает нужным, вместо того чтобы быть вынужденной ставить их в очередь для выполнения на некотором конкретном планировщике.

Я ранее упоминал еще одну сторону SynchronizationContext, и я сказал, что мы увидим ее снова: OperationStarted/OperationCompleted. Теперь пришло время. Они появляются как часть функции, которую все любят ненавидеть: async void. Конфигурация ожидания в сторону, async void, безусловно, одна из самых спорных функций, добавленных в рамках async/await. Он был добавлен только по одной причине и только по одной причине: обработчики событий. В приложении пользовательского интерфейса вы хотите иметь возможность писать код следующего вида:

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-5

Но если бы все асинхронные методы должны были иметь тип возврата, как Task, вы бы не могли это сделать. Событие Click имеет сигнатуру public event EventHandler? Click;, где EventHandler определен как public delegate void EventHandler(object? sender, EventArgs e);, и поэтому для предоставления метода, соответствующего этой сигнатуре, метод должен возвращать void.

Существует множество причин, по которым считается плохой практикой использование async void, почему статьи рекомендуют избегать его по возможности, и почему анализаторы появились для выявления его использования. Одной из самых больших проблем является проблема с выводом делегатов. Рассмотрим этот программный код:

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-6

Можно было бы легко ожидать, что это выведет время, прошедшее как минимум 10 секунд, но если вы запустите это, вы найдете вывод, похожий на следующий:

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-7

Конечно, исходя из всего, обсужденного в этой статье, должно быть понятно, в чем проблема. Асинхронная лямбда-функция на самом деле является асинхронным методом void. Асинхронные методы возвращают управление вызывающему методу в момент, когда они сталкиваются с первым точкой приостановки. Если бы это был асинхронный метод Task, именно тогда возвращался бы Task. Но в случае асинхронного метода void ничего не возвращается. Все, что знает метод Time, это то, что он вызвал action(); и вызов делегата вернул; он не имеет представления о том, что асинхронный метод на самом деле все еще "запущен" и будет асинхронно завершен позже.

Вот где вступают в игру OperationStarted/OperationCompleted. Такие асинхронные методы void по своей природе похожи на обсуждаемые ранее методы EAP: инициация таких методов является void, и поэтому вам нужен другой механизм, чтобы иметь возможность отслеживать все такие операции, которые в данный момент выполняются. Реализации EAP вызывают OperationStarted текущего SynchronizationContext, когда операция инициируется, и OperationCompleted, когда она завершается, и асинхронный метод void делает то же самое. Строитель, связанный с асинхронным методом void, называется AsyncVoidMethodBuilder. Помните, как в точке входа асинхронного метода генерируемый компилятором код вызывает статический метод Create у строителя, чтобы получить соответствующий экземпляр строителя? AsyncVoidMethodBuilder использует это, чтобы подключить создание и вызвать OperationStarted:

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-8

Также, когда строитель помечается для завершения через SetResult или SetException, он вызывает соответствующий метод OperationCompleted. Это позволяет фреймворку для модульного тестирования, например xunit, иметь асинхронные методы void тестов и все же использовать максимальный уровень параллелизма при параллельном выполнении тестов, например, в AsyncTestSyncContext xunit.

Исходя из этого знания, мы теперь можем переписать наш пример с замерами времени:

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-9
SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-10

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

SynchronizationContext и ConfigureAwait Мы обсуждали SynchronizationContext ранее в контексте шаблона EAP и упоминали, что он снова появится.-11

Источник

to be continued...