Как на самом деле работает Async/Await в C# (Часть 2)

от автора

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

Disclaimer: Я не являюсь профессиональным переводчиком, перевод подготовлен скорее для себя и коллег. Я буду благодарен за любые исправления и помощь в переводе, статья очень интересная давайте сделаем её доступной на русском языке.

  1. Часть 1: В самом начале…

  2. Часть 2: Асинхронная модель на основе событий (EAP)

  3. Часть 3: Появление Tasks (Асинхронная модель на основе задач (TAP)

  4. Часть 4: …и ValueTasks

  5. Часть 5: Итераторы C# в помощь

  6. Часть 6: Async/await: Внутреннее устройство

  7. Часть 7: SynchronizationContext и ConfigureAwait и поля в State Machine

Асинхронная модель на основе событий (EAP)

В NET Framework 2.0 было представлено несколько API, реализующих другой паттерн для обработки асинхронных операций, предназначенный в первую очередь для выполнения их в контексте клиентских приложений. Этот Event-based Asynchronous Pattern, или EAP, также состоял из пары членов (как минимум, возможно, больше), на этот раз метода для инициирования асинхронной операции и события для прослушивания ее завершения. Таким образом, наш предыдущий пример DoStuff мог бы быть представлен в виде набора членов, подобных этому:

class Handler {     public int DoStuff(string arg);      public void DoStuffAsync(string arg, object? userToken);     public event DoStuffEventHandler? DoStuffCompleted; }  public delegate void DoStuffEventHandler(object sender, DoStuffEventArgs e);  public class DoStuffEventArgs : AsyncCompletedEventArgs {     public DoStuffEventArgs(int result, Exception? error, bool canceled, object? userToken) :         base(error, canceled, usertoken) => Result = result;      public int Result { get; } }

Вы регистрируете свою работу по продолжению с событием DoStuffCompleted, а затем вызываете метод DoStuffAsync; он инициирует операцию, и по ее завершении событие DoStuffCompleted будет асинхронно поднято вызывающей стороной. После этого обработчик может продолжить свою работу, вероятно, проверяя, что предоставленный userToken соответствует ожидаемому, что позволяет подключить к событию несколько обработчиков одновременно.

Этот паттерн немного упростил некоторые случаи использования, но при этом значительно усложнил другие (а учитывая предыдущий пример APM CopyStreamToStream, это о чем-то говорит). Он не получил широкого распространения, а появился и исчез фактически в одном выпуске .NET Framework, хотя и оставил после себя API, добавленные во время его существования, такие как Ping.SendAsync/Ping.PingCompleted:

public class Ping : Component {     public void SendAsync(string hostNameOrAddress, object? userToken);     public event PingCompletedEventHandler? PingCompleted;     ... }

Тем не менее, он добавил одно заметное достижение, которое не было учтено в модели APM, и которое сохранилось в моделях, которые мы используем сегодня: SynchronizationContext.

SynchronizationContext был также представлен в .NET Framework 2.0 в качестве абстракции для общего планировщика. В частности, наиболее используемым методом SynchronizationContext является Post, который ставит рабочий элемент в очередь к любому планировщику, представленному этим контекстом.

Базовая реализация SynchronizationContext, например, просто представляет ThreadPool, и поэтому базовая реализация SynchronizationContext.Post просто делегирует ThreadPool.QueueUserWorkItem, который используется, чтобы попросить ThreadPool вызвать предоставленный обратный вызов с соответствующим состоянием на одном из потоков пула. Однако суть SynchronizationContext заключается не только в поддержке произвольных планировщиков, а скорее в поддержке планирования таким образом, чтобы оно работало в соответствии с потребностями различных моделей приложений.

Рассмотрим такую структуру пользовательского интерфейса, как Windows Forms. Как и в большинстве фреймворков пользовательского интерфейса Windows, элементы управления связаны с определенным потоком, и этот поток запускает цикл обработки сообщений, который выполняет работу, способную взаимодействовать с этими элементами управления: только этот поток должен пытаться манипулировать этими элементами управления, а любой другой поток, который хочет взаимодействовать с элементами управления, должен сделать это, отправив сообщение, которое будет потреблено циклом обработки сообщений потока пользовательского интерфейса. Windows Forms упрощает эту задачу с помощью таких методов, как Control.BeginInvoke, который ставит в очередь предоставленный делегат и аргументы для выполнения любым потоком, связанным с данным элементом управления. Таким образом, вы можете написать код, подобный этому:

private void button1_Click(object sender, EventArgs e) {     ThreadPool.QueueUserWorkItem(_ =>     {         string message = ComputeMessage();         button1.BeginInvoke(() =>         {             button1.Text = message;         });     }); }

Это позволит разгрузить работу ComputeMessage() для выполнения в потоке ThreadPool (чтобы пользовательский интерфейс оставался отзывчивым во время обработки), а затем, когда эта работа завершится, передать делегат обратно в поток, связанный с button1, для обновления метки button1. Достаточно просто. В WPF есть нечто подобное, только с типом Dispatcher:

private void button1_Click(object sender, RoutedEventArgs e) {     ThreadPool.QueueUserWorkItem(_ =>     {         string message = ComputeMessage();         button1.Dispatcher.InvokeAsync(() =>         {             button1.Content = message;         });     }); }

И в .NET MAUI есть нечто подобное. Но что если я хочу поместить эту логику во вспомогательный метод? Например:

// Call ComputeMessage and then invoke the update action to update controls. internal static void ComputeMessageAndInvokeUpdate(Action<string> update) { ... }

Затем я могу использовать это следующим образом:

private void button1_Click(object sender, EventArgs e) {     ComputeMessageAndInvokeUpdate(message => button1.Text = message); }

но как можно реализовать ComputeMessageAndInvokeUpdate таким образом, чтобы он мог работать в любом из этих приложений? Нужно ли его жестко кодировать, чтобы он знал о каждом возможном фреймворке пользовательского интерфейса? Вот где SynchronizationContext нам поможет. Мы можем реализовать метод следующим образом:

internal static void ComputeMessageAndInvokeUpdate(Action<string> update) {     SynchronizationContext? sc = SynchronizationContext.Current;     ThreadPool.QueueUserWorkItem(_ =>     {         string message = ComputeMessage();         if (sc is not null)         {             sc.Post(_ => update(message), null);         }         else         {             update(message);         }     }); }

Это использует SynchronizationContext как абстракцию, чтобы нацелить любой «планировщик», который должен быть использован, чтобы вернуться к необходимой среде для взаимодействия с пользовательским интерфейсом. Затем каждая модель приложения обеспечивает публикацию в качестве SynchronizationContext.Current производного от SynchronizationContext типа, который делает «правильную вещь». Например, Windows Forms имеет следующее:

public sealed class WindowsFormsSynchronizationContext : SynchronizationContext, IDisposable {     public override void Post(SendOrPostCallback d, object? state) =>         _controlToSendTo?.BeginInvoke(d, new object?[] { state });     ... }

и в WPF это есть:

public sealed class DispatcherSynchronizationContext : SynchronizationContext {     public override void Post(SendOrPostCallback d, Object state) =>         _dispatcher.BeginInvoke(_priority, d, state);     ... }

Раньше в ASP.NET был один, который не заботился о том, в каком потоке выполняется работа, а скорее о том, чтобы работа, связанная с данным запросом, была сериализована таким образом, чтобы несколько потоков не могли одновременно обращаться к данному HttpContext:

internal sealed class AspNetSynchronizationContext : AspNetSynchronizationContextBase {     public override void Post(SendOrPostCallback callback, Object state) =>         _state.Helper.QueueAsynchronous(() => callback(state));     ... }

Это также не ограничивается такими основными моделями приложений. Например, xunit — это популярный фреймворк модульного тестирования, который используется в основных репозиториях .NET для модульного тестирования, и он также использует несколько пользовательских SynchronizationContexts. Вы можете, например, разрешить тестам выполняться параллельно, но ограничить количество тестов, которые могут выполняться одновременно. Как это можно сделать? С помощью SynchronizationContext:

public class MaxConcurrencySyncContext : SynchronizationContext, IDisposable {     public override void Post(SendOrPostCallback d, object? state)     {         var context = ExecutionContext.Capture();         workQueue.Enqueue((d, state, context));         workReady.Set();     } }

Метод Post контекста MaxConcurrencySyncContext просто ставит работу в свою собственную внутреннюю очередь, которую затем обрабатывает на своих собственных рабочих потоках, где он контролирует их количество в зависимости от желаемого максимального параллелизма. Вы поняли идею.

Как это связано с Event-based Asynchronous Pattern? EAP и SynchronizationContext были введены одновременно, и EAP диктовал, что события завершения должны быть поставлены в очередь к тому SynchronizationContext, который был текущим, когда была инициирована асинхронная операция.

Чтобы немного упростить эту задачу (и, вероятно, не настолько, чтобы оправдать дополнительную сложность), в System.ComponentModel также были введены некоторые вспомогательные типы, в частности AsyncOperation и AsyncOperationManager. Первый был просто кортежем, который обертывал предоставленный пользователем объект состояния и захваченный SynchronizationContext, а второй просто служил простой фабрикой для выполнения захвата и создания экземпляра AsyncOperation. Затем реализации EAP использовали их, например, Ping.SendAsync вызывал AsyncOperationManager.CreateOperation для захвата SynchronizationContext, а затем, когда операция завершалась, вызывался метод PostOperationCompleted AsyncOperation для вызова метода Post сохраненного SynchronizationContext.

SynchronizationContext предоставляет еще несколько «финтифлюшек», о которых стоит упомянуть, поскольку они еще не раз появятся. В частности, он раскрывает методы OperationStarted и OperationCompleted. Базовая реализация этих виртуальных методов пуста, ничего не делает, но производная реализация может переопределить их, чтобы знать об операциях в ходе выполнения. Это означает, что реализации EAP будут также вызывать эти OperationStarted/OperationCompleted в начале и конце каждой операции, чтобы информировать любой присутствующий SynchronizationContext и позволить ему отслеживать работу. Это особенно актуально для паттерна EAP, поскольку методы, инициирующие асинхронные операции, возвращают пустоту: вы не получаете ничего, что позволило бы вам отслеживать работу по отдельности. К этому мы еще вернемся.

Итак, нам нужно было что-то лучшее, чем шаблон APM, а EAP, который появился следом, ввел некоторые новые вещи, но не решил основные проблемы, с которыми мы столкнулись. Нам все еще нужно было что-то лучшее.


ссылка на оригинал статьи https://habr.com/ru/articles/727886/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *