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

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

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

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

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

    1. ...и ValueTasks

  4. Итераторы C# в помощь

    1. Async/await: Внутреннее устройство

      1. Преобразования компилятора

      2. SynchronizationContext и ConfigureAwait

      3. Поля в State Machine

  5. Заключение

Асинхронная модель на основе событий (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, который появился следом, ввел некоторые новые вещи, но не решил основные проблемы, с которыми мы столкнулись. Нам все еще нужно было что-то лучшее.

Комментарии (7)


  1. OlegTar
    11.04.2023 21:57

    В свое время изучал как работает async await, читал Рихтера, Албахари, статьи, смотрел видосы, экспериментировал.

    И тоже узнал, что async await использует под капотом synchronizationcontext, который в свою очередь посылает код на выполнение основному потоку, и в зависимости от фреймворка (wpf, winforms, asp.net) делает это по-разному).

    Но тут в статье даже какие-то внутренности показаны для asp.net


  1. hartmeyerorum888
    11.04.2023 21:57
    +1

    Автор, у вас дублируются параграфы: "Как это связано с Event-based Asynchronous Pattern..."


    1. nkz-soft Автор
      11.04.2023 21:57

      Спасибо, исправил.


  1. AlekseiNesterov3211
    11.04.2023 21:57
    -4

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

    Async работает следующим образом:

    1. Создается асинхронная функция, которая обычно помечается ключевым словом async.

    2. Внутри этой функции могут быть выполнены асинхронные операции, такие как чтение или запись файлов, отправка запросов на сервер и т.д.

    3. Вместо того, чтобы ждать завершения каждой операции, асинхронная функция продолжает работу и возвращает объект Promise.

    4. Когда все асинхронные операции завершены, Promise переходит в состояние resolved и возвращает результат выполнения функции.

    5. Если произошла ошибка во время выполнения асинхронных операций, Promise переходит в состояние rejected и возвращает объект ошибки.

    6. Для обработки результата асинхронной функции используются методы then и catch объекта Promise.

    Async позволяет увеличить производительность приложения, поскольку выполнение задач происходит параллельно, а также улучшает пользовательский опыт, поскольку приложение не блокируется во время выполнения длительных операций.


    1. OlegTar
      11.04.2023 21:57

      Мы тут про C#. Здесь Таски , а не Промисы.

      Это текст из ChatGPT?


  1. mvv-rus
    11.04.2023 21:57

    чтобы знать об операциях в полете
    А можно это на русский перевести? Чтобы читателям не приходилось переводить сначала дословно на английский, а потом — по смыслу на русский.
    PS Это не единственный огрех перевода, но самый выразительный IMHO


    1. nkz-soft Автор
      11.04.2023 21:57

      Большое спасибо!
      Могу я вас попросить написать в личку по проблемам перевода и мы попробуем вместе сделать перевод лучше.