Как на самом деле Async/Await работают в C#. Часть 2 Артефакты от EAP шаблона, SynchronizationContext
Насколько я понял из комментариев к своим предыдущим статьям по этой теме:
Часть 1. Проблемы модели асинхронного программирования (APM)
Уроки по асинхронному программированию из первой половины работы
Параллельные вычисления — Все дело в контексте-синхронизации (SynchronizationContext)
Async/Await из C#. Головоломка для разработчиков компилятора и для нас
и по количеству просмотров, тема все еще вызывает интерес, поэтому я хочу попробовать продолжить, но не просто перевод, а перевод с пояснениями, хотя и сам перевод тоже должен отличаться от первоначального варианта, поскольку я его не читал, а только по результатам, мельком, глянул пару абзацев. К тому же автор того первоначального перевода просил помощи с переводом, поэтому я надеюсь, мой вариант в чем-то сможет помочь в этом смысле или просто будет интересен с точки зрения сравнения.
Еще, мне кажется, что есть несколько читателей, которым будет интересен именно мой вариант перевода, вот для них, в первую очередь, я и продолжаю писать.
В .NET Framework 2.0 появилось несколько API, реализующих разные шаблоны для работы с асинхронными операциями, один из них изначально предназначен для такой работы в контексте клиентских приложений. Этот основанный на Событиях Асинхронный Шаблон, или 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 соответствует ожидаемому, позволяя подключать к событию несколько обработчиков одновременно.
комментарий
<ред: собственно, само использование события‑event позволяет подключать несколько обработчиков — это предопределенная функция событий, можно было не упоминать специально про нее>
Этот шаблон немного упростил некоторые из вариантов использования, в то время как другие варианты использования, наоборот, значительно усложнились (и, глядя на предыдущий пример CopyStreamToStream для APM , вы тоже можете оценить эти трудности). В общем эта техника не получила широкого распространения, эта техника успешно была реализована в единственном релизе .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 как абстракция для действующего планировщика (scheduler). В частности, наиболее часто используемым методом SynchronizationContext является Post, который ставит рабочий элемент в очередь для того планировщика, который представлен этим контекстом.
комментарий
<ред: видимо подразумевается, что существует объект планировщик, который является реализацией для абстрактного класса SynchronizationContext, например далее ThreadPool объявляется одной из реализаций SynchronizationContext>
Базовая реализация SynchronizationContext, например, просто представляет ThreadPool и, соответственно, базовая реализация SynchronizationContext.Post:
public virtual void Post(SendOrPostCallback d, object? state) => ThreadPool.QueueUserWorkItem(static s => s.d(s.state), (d, state), preferLocal: false);
просто делегирует <ред: подменяется методом> ThreadPool.QueueUserWorkItem, который используется чтобы заставить ThreadPool вызвать "Callback d
" из параметров Post с соответствующим состоянием в одном из потоков ThreadPool. Однако суть SynchronizationContext заключается не только в поддержке произвольных планировщиков, скорее, в поддержке такого метода планирования, которое работало бы в соответствии с потребностями различных моделей организации асинхронных операций в приложении.
<ред: мне кажется, автор здесь немного перемудрил, вроде как, и то, и другое одинаково важно, возможно, он и хотел просто озвучить обе эти цели, а получилось, что он им расставил приоритеты>
Рассмотрим UI фреймворк, такой как Windows Forms. Как и в большинстве UI фреймворков в Windows, элементы управления связаны с определенным потоком, и этот поток запускает процедуру обработки сообщений <ред: на английском процедура обработки сообщений, получается, называется коротко "pump", как "откачка" или "отработка" одним словом на русский>, которая выполняет работу (заданную в сообщении), способную взаимодействовать с этими элементами управления: только этот поток должен осуществлять манипуляции с этими элементами управления, а любой другой поток, который хочет взаимодействовать с элементами управления (воздействовать на них), должен делать это с помощью отправки сообщения, которое будет выполнено процедурой обработки сообщений UI потока. Windows Forms упрощает это с помощью таких методов, как Control.BeginInvoke, который ставит в очередь предоставленный делегат и аргументы для запуска потоком из сообщения, только потоком, связанным с этим элементом управления. Таким образом, вы можете написать код, подобный этому:
private void button1_Click(object sender, EventArgs e)
{
ThreadPool.QueueUserWorkItem(_ =>
{
string message = ComputeMessage();
button1.BeginInvoke(() =>
{
button1.Text = message;
});
});
}
Это позволит выполнять работу ComputeMessage() в одном из потоков ThreadPool (чтобы пользовательский интерфейс оставался восприимчивым пока ComputeMessage() выполняется), а затем, когда эта работа будет завершена, лямбда-операция (продолжение
) с элементом управления button1 будет поставлена в очередь обратно в поток, связанный с button1, чтобы обновить надпись для button1. Вроде как, все достаточно просто.
<ред: получается, что тут мы используем сразу два SynchronizationContext: один ThreadPool, второй – что-то на основе процедуры обработки сообщений потока, связанного с button1. Поэтому сначала, я не совсем понял как и почему автор Поста перескочил с SynchronizationContext на асинхронные операции для UI, как будто есть какой-то разрыв в логике повествования, но этот разрыв разъясняется дальше>
В WPF есть нечто подобное, только с типом диспетчера:
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);
}
но как можно было бы реализовать вычисление сообщения и вызов обновления таким образом, чтобы оно могло работать в любом из этих приложений (для Windows Forms, для WPF, для .NET MAUI, ...)? Нужно ли его жестко кодировать, чтобы знать обо всех возможных фреймворках пользовательского интерфейса? Вот где засияет звезда 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 позволяет скрыть или абстрагироваться от вот такой разницы в коде:
button1.BeginInvoke(() =>
против:
button1.Dispatcher.InvokeAsync(() =>
согласитесь такую разницу трудно заметить пока вам ее пальцем не покажут, или…
или пока она вам не выльется в какие-то ощутимые проблемы, с которыми мне, например, пришлось разбираться, первый раз, больше 20 лет назад (в контексте задачи, которая, конечно, никак не связана с SynchronizationContext или с async/await, и даже не в C#, а в C++). С тех пор у меня появилась способность замечать такую разницу. И я вас берусь уверить, что эта абстракция действительно имеет смысл в определенном контексте. Обратите внимание нам не показали код третьей реализации из .NET MAUI, а там эта разница может быть намного более существенной.
Мне кажется уже здесь выражена основная мысль этого параграфа, дальше автор расписывает это более многословно с разных сторон и перечисляет специальные применения абстракции, которые, как мне кажется, призваны убедить вас в значимости этой идеи, и в том, в чем я пытался только что вас уверить.
Также мне показалось интересным, что ThreadPool в реализации SynchronizationContext используется именно через делегирование, а не через наследование>
Теперь наша функция использует 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 });
...
}
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 просто помещает работу в свою собственную внутреннюю очередь работ (операций), по которой он затем отрабатывает в своих собственных рабочих потоках, где он контролирует их количество на основе желаемого максимального параллелизма. Ну… вы поняли идею.
Как это связано с асинхронным шаблоном, основанным на событиях? И 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, потому что методы, которые инициируют асинхронные операции, возвращают значение void: вы не получаете обратно ничего, что позволяет вам отслеживать такую работу индивидуально. Мы вернемся к этому позже.
<ред: похоже EAP все-таки не отменили после единственного релиза в котором он появился, поскольку его реализации все также вызывают эти OperationStarted/OperationCompleted как мы увидим в следующих главах Поста, получается что он ушел как бы вглубь ядра или библиотек .NET>
Итак, нам нужно было что-то лучшее, чем шаблон APM, и следующий EAP привнес некоторые новые вещи, но на самом деле не решал основные проблемы, с которыми мы столкнулись. Нам все еще нужно было что-то лучшее.