Параллельные вычисления

Все дело в SynchronizationContext

Стефен Клиэри

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

Независимо от платформы — будь то ASP.NET, Windows Forms, Windows Presentation Foundation (WPF), Silverlight или нечто другое — все .NET-программы включают концепцию SynchronizationContext, и все программисты многопоточных приложений могут получить выигрыш от его изучения и применения.

Потребность в SynchronizationContext

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

Когда выпустили первую версию .NET Framework, этот распространенный шаблон был стандартизован. В то время .NET Framework поддерживала единственный тип GUI — Windows Forms. Однако проектировщики этой инфраструктуры предвидели появление других моделей и разработали обобщенное решение. Так родился ISynchronizeInvoke.

Идея ISynchronizeInvoke в том, что поток-«источник» может поставить делегат в очередь к потоку-«приемнику» и, как необязательный вариант, ожидать завершения этого делегата. В ISynchronizeInvoke также было свойство, которое позволяло определять, выполняется ли уже текущий код в потоке-«приемнике»; в этом случае ставить делегат в очередь было бы излишне. Windows Forms предоставляла единственную реализацию ISynchronizeInvoke, а шаблон был предназначен для разработки асинхронных компонентов, поэтому все были на седьмом небе от счастья.

В .NET Framework 2.0 появилось много существенных изменений. Одно из крупных усовершенствований заключалось во введении поддержки асинхронных страниц в архитектуру ASP.NET. До .NET Framework 2.0 каждый ASP.NET-запрос требовал выполнения в своем потоке. Это было неэффективной тратой потоков, так как создание веб-страницы часто зависело от запросов к базе данных и вызовов веб-сервисов, а поток, обрабатывающий запрос, был вынужден ждать завершения каждой из этих операций. Благодаря поддержке асинхронных страниц поток, обрабатывающий запрос, мог запускать каждую из операций, а затем возвращаться в пул потоков ASP.NET; по окончании всех операций из пула ASP.NET выделялся другой поток, который завершал обработку запроса.

Однако ISynchronizeInvoke не слишком хорошо подходил для архитектуры ASP.NET с поддержкой асинхронных страниц. Асинхронные компоненты, разработанные с использованием шаблона ISynchronizeInvoke, не смогли бы корректно работать в страницах ASP.NET из-за того, что асинхронные ASP.NET-страницы не сопоставлены с единственным потоком. Вместо размещения работы в очереди к исходному потоку поддержке асинхронных страниц нужно было лишь управлять счетчиком незавершенных операций, чтобы определить, когда можно закончить обработку запроса страницы. После долгих размышлений и тщательного продумывания проекта ISynchronizeInvoke заменили на SynchronizationContext.

Концепция SynchronizationContext

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

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

Другой аспект SynchronizationContext — у каждого потока есть «текущий» контекст. Контекст потока не обязательно уникален; экземпляр его контекста может использоваться совместно с другими потоками. Поток может менять свой текущий контекст, но делается это крайне редко.

Третий аспект SynchronizationContext — хранение счетчика незавершенных асинхронных операций. Это позволяет использовать асинхронные ASP.NET-страницы и любой другой хост, где нужен счетчик такого рода. В большинстве случаев значение счетчика увеличивается на 1, когда захватывается текущий SynchronizationContext, и уменьшается на 1, когда захваченный SynchronizationContext используется для размещения уведомления о завершении в очереди контекста.

Есть и другие аспекты SynchronizationContext, но они менее важны для большинства программистов. Наиболее важные аспекты показаны на рис. 1.

Рис. 1. Аспекты SynchronizationContext API

// The important aspects of the SynchronizationContext APIclass SynchronizationContext

{

  // Dispatch work to the context.

  void Post(..); // (asynchronously)

  void Send(..); // (synchronously)

  // Keep track of the number of asynchronous operations.

  void OperationStarted();

  void OperationCompleted();

  // Each thread has a current context.

  // If "Current" is null, then the thread's current context is


  // "new SynchronizationContext()", by convention.

  static SynchronizationContext Current { get; }

  static void SetSynchronizationContext(SynchronizationContext);
}

Реализации SynchronizationContext

Четкого определения истинного «контекста» SynchronizationContext нет. В разных инфраструктурах и хостах могут быть определены свои контексты. Понимание разных реализаций и их ограничений прояснит вам, что концепция SynchronizationContext гарантирует и чего она не гарантирует. Давайте кратко обсудим некоторые из этих реализаций.

WindowsFormsSynchronizationContext (System.Windows.Forms.dll: System.Windows.Forms) Приложения Windows Forms будут создавать и устанавливать WindowsFormsSynchronizationContext в качестве текущего контекста для любого потока, который создает UI-элементы. Этот SynchronizationContext использует методы ISynchronizeInvoke применительно к UI-элементу, который передает делегаты нижележащему Win32-циклу обработки сообщений. Контекст для WindowsFormsSynchronizationContext — единственный UI-поток.

Все делегаты, которые ставятся в очередь к WindowsFormsSynchronizationContext, выполняются по одному конкретным UI-потоком в том порядке, в каком они были поставлены в очередь. Текущая реализация создает один WindowsFormsSynchronizationContext для каждого UI-потока.

DispatcherSynchronizationContext (WindowsBase.dll: System.Windows.Threading) Приложения WPF и Silverlight используют DispatcherSynchronizationContext, который ставит делегаты в очередь Dispatcher, принадлежащего UI-потоку, с приоритетом Normal (Обычный). Этот SynchronizationContext устанавливается как текущий контекст, когда поток начинает свой цикл Dispatcher вызовом Dispatcher.Run. Контекст для DispatcherSynchronizationContext — единственный UI-поток.

Все делегаты, которые ставятся в очередь к DispatcherSynchronizationContext, выполняются по одному конкретным UI-потоком в том порядке, в каком они были поставлены в очередь. Текущая реализация создает один DispatcherSynchronizationContext для каждого окна верхнего уровня, даже если все они используют один и тот же нижележащий Dispatcher.

SynchronizationContext по умолчанию (ThreadPool) (mscorlib.dll: System.Threading) Это объект SynchronizationContext, сконструированный по умолчанию. По соглашению, если текущий SynchronizationContext потока равен null, тогда этому потоку неявно назначен SynchronizationContext по умолчанию.

SynchronizationContext по умолчанию помещает свои асинхронные делегаты в очередь ThreadPool, а синхронные делегаты выполняет непосредственно в вызвавшем потоке. Поэтому его контекст охватывает все потоки ThreadPool, а также любой поток, который вызывает Send. SynchronizationContext «заимствует» потоки, вызвавшие Send, и переводит их в свой контекст до завершения делегата. В этом смысле контекст по умолчанию может включать любой поток в процессе.

SynchronizationContext по умолчанию применяется к потокам ThreadPool, если только код на размещен в ASP.NET. SynchronizationContext по умолчанию также неявно применяется к явным образом созданным дочерним потокам (экземплярам класса Thread), если только дочерний поток не устанавливает собственный контекст. Таким образом, в UI-приложениях обычно имеется два контекста синхронизации: UI SynchronizationContext, охватывающий UI-поток, и SynchronizationContext по умолчанию, охватывающий потоки ThreadPool.

Многие асинхронные компоненты, использующие события, некорректно работают с SynchronizationContext по умолчанию. Печально известный пример — UI-приложение, где один BackgroundWorker запускает другой BackgroundWorker. Каждый BackgroundWorker захватывает и использует SynchronizationContext потока, который вызывает RunWorkerAsync, а впоследствии обрабатывает его событие RunWorkerCompleted в этом контексте. В случае единственного BackgroundWorker это обычно SynchronizationContext на основе UI, поэтому RunWorkerCompleted выполняется в контексте UI, захваченном RunWorkerAsync (рис. 2).

image: A Single BackgroundWorker in a UI Context

Рис. 2. Единственный BackgroundWorker в контексте UI

Однако, есть BackgroundWorker запускает другой BackgroundWorker из своего обработчика DoWork, тогда вложенный BackgroundWorker не захватывает UI SynchronizationContext. DoWork выполняется потоком из ThreadPool с SynchronizationContext по умолчанию. В этом случае вложенный RunWorkerAsync получит SynchronizationContext по умолчанию и поэтому будет обрабатывать событие RunWorkerCompleted в потоке ThreadPool вместо UI-потока (рис. 3).

image: Nested BackgroundWorkers in a UI Context

Рис. 3. Вложенные BackgroundWorker в контексте UI

По умолчанию все потоки в консольных приложениях и Windows-службах имеют только SynchronizationContext по умолчанию. Это приводит к тому, что некоторые асинхронные компоненты, использующие события, начинают сбоить. Одно из решений этой проблемы — создать дочерний поток явным образом и установить SynchronizationContext в нем, а тот потом предоставит этот контекст для таких компонентов. Реализация SynchronizationContext выходит за рамки данной статьи, но класс ActionThread из библиотеки Nito.Async (nitoasync.codeplex.com) можно использовать как универсальную реализацию SynchronizationContext.

AspNetSynchronizationContext (System.Web.dll: System.Web [внутренний класс]) SynchronizationContext в ASP.NET устанавливается в потоках из пула, когда они выполняют код страницы. Делегат, помещаемый в очередь полученного AspNetSynchronizationContext, восстанавливает идентификацию и культуру исходной страницы, а затем напрямую выполняет делегат. Этот делегат вызывается напрямую, даже если он ставится в очередь «асинхронно» вызовом Post.

С концептуальной точки зрения, контекст AspNetSynchronizationContext весьма сложен. В течение срока жизни асинхронной страницы контекст начинает всего с одним потоком из пула потоков ASP.NET. После выдачи асинхронных запросов контекст не включает никаких потоков. По мере завершения асинхронных запросов потоки из пула, выполняющие свои процедуры завершения, входят в контекст. Это могут быть те же потоки, которые инициировали запросы, но с большей вероятностью они представляют собой свободные потоки, которые были на момент завершения операций.

Если в одном приложении одновременно выполняется несколько операций, AspNetSynchronizationContext обеспечит их поочередное выполнение (по одной за раз). Они могут выполняться любым потоком, но этот поток получит идентификацию и культуру исходной страницы.

Один из распространенных примеров — WebClient, используемый из асинхронной веб-страницы. DownloadDataAsync будет захватывать текущий SynchronizationContext, а потом обрабатывать в этом контексте его событие DownloadDataCompleted. Когда начинается выполнение страницы, ASP.NET выделит один из своих потоков для исполнения ее кода. Страница может вызвать DownloadDataAsync, а затем вернуть управление; ASP.NET поддерживает счетчик незавершенных асинхронных операций, поэтому она понимает, что страница не закончила свою работу. Когда объект WebClient загрузил запрошенные данные, он получит уведомление в потоке из пула. Этот поток сгенерирует событие DownloadDataCompleted в захваченном контексте. Контекст останется в том же потоке, но обеспечит выполнение обработчика события с корректной идентификацией и культурой.

Некоторые соображения по реализациям SynchronizationContext

SynchronizationContext позволяет писать компоненты, которые могут работать во многих инфраструктурах. BackgroundWorker и WebClient — два примера таких компонентов, которые равно чувствуют себя как дома в приложениях Windows Forms, WPF, Silverlight, ASP.NET и консольных программах. Однако при разработке таких многократно используемых компонентов следует помнить о некоторых вещах.

В целом, реализации SynchronizationContext не равноценны. То есть эквивалента ISynchronizeInvoke.InvokeRequired нет.Однако это не является значимым недостатком; код четче и проще в проверке, если он всегда выполняется в известном контексте и не пытается манипулировать несколькими контекстами.

Не все реализации SynchronizationContext гарантируют порядок выполнения делегатов или их синхронизацию. Реализации SynchronizationContext на основе UI этим условиям удовлетворяют, тогда как ASP.NET SynchronizationContext обеспечивает только синхронизацию. SynchronizationContext по умолчанию не гарантирует ни порядка выполнения, ни синхронизации.

Соответствия «один в один» между экземплярами SynchronizationContext и потоками нет. Такое соответствие есть лишь для WindowsFormsSynchronizationContext (пока не вызывается SynchronizationContext.CreateCopy). В целом, лучше всего не полагаться на то, что какой-то экземпляр контекста будет выполняться в определенном потоке.

Наконец, метод SynchronizationContext.Post не обязательно асинхронный. В большинстве реализаций он создается как асинхронный, но AspNetSynchronizationContext является характерным исключением. Это может вызывать неожиданные проблемы, связанные с реентерабельностью. Сводное описание различных реализаций дано в табл. 4.

Табл. 4. Сводное описание реализаций SynchronizationContext

  Выполнение делегатов в определенном потоке Делегаты выполняются по одному за раз Делегаты выполняются в порядке очереди Send может напрямую вызывать делегат Post может напрямую вызывать делегат
Windows Forms Да Да Да Если вызывается из UI-потока Никогда
WPF/Silverlight Да Да Да Если вызывается из UI-потока Никогда
По умолчанию Нет Нет Нет Всегда Никогда
ASP.NET Нет Да Нет Всегда Всегда

AsyncOperationManager и AsyncOperation

Классы AsyncOperationManager и AsyncOperation в .NET Framework являются облегченными оболочками, заключающими в себе абстракцию SynchronizationContext. AsyncOperationManager получает текущий SynchronizationContext, когда первый раз создает AsyncOperation, заменяя SynchronizationContext по умолчанию, если текущий равен null. AsyncOperation асинхронно передает делегаты в полученный SynchronizationContext.

В большинстве реализаций асинхронных компонентов, работающих на основе событий, используют AsyncOperationManager и AsyncOperation. Это хорошо работает для асинхронных операций с определенной точкой завершения, т. е. асинхронная операция начинается в одной точке и заканчивается с событием в другой. У других асинхронных операций может не быть определенной точки завершения, например при подписке какого-либо типа, когда операция начинается в одной точке, а затем продолжается неопределенное время. Для операций такого типа SynchronizationContext можно захватывать и использовать напрямую.

Новые компоненты не должны использовать асинхронный шаблон на основе событий. CTP-версия Visual Studio Asynchronous включает документ, описывающий асинхронный шаблон на основе задач, в котором компоненты возвращают объекты Task и Task<TResult>, а не генерируют события через SynchronizationContext. API на основе задач — это будущее асинхронного программирования в .NET.

Примеры библиотечной поддержки SynchronizationContext

Такие простые компоненты, как BackgroundWorker и WebClient, являются неявно портируемыми, скрывая захват и применение SynchronizationContext. Во многих библиотеках SynchronizationContext используют более явным образом. Через API, работающие с SynchronizationContext, эти библиотеки не только обеспечивают независимость от конкретной инфраструктуры, но и предоставляют точку расширения для «продвинутых» конечных пользователей.

Текущий SynchronizationContext считается частью ExecutionContext не только в библиотеках, которые мы сейчас рассмотрим. Любая система, которая захватывает ExecutionContext потока, получает и текущий SynchronizationContext. Когда ExecutionContext восстанавливается, обычно восстанавливается и SynchronizationContext.

Windows Communication Foundation (WCF):UseSynchronizationContext В WCF есть два атрибута, используемых для настройки поведения сервера и клиента: ServiceBehaviorAttribute и CallbackBehaviorAttribute. Оба эти атрибута имеют булево свойство UseSynchronizationContext. По умолчанию оно равно true, указывая, что текущий SynchronizationContext захватывается при создании коммуникационного канала и что этот SynchronizationContext используется для постановки в очередь методов контракта.

Обычно это поведение — как раз то, что нужно: серверы используют SynchronizationContext по умолчанию, а обратные вызовы на клиентской стороне — соответствующий UI SynchronizationContext. Однако это может вызвать проблемы, если нужна реентерабельность, например клиент вызывает метод сервера, который инициирует обратный вызов клиента. В этом и подобных случаях можно отключить автоматическое использование SynchronizationContext в WCF, установив свойство UseSynchronizationContext в false.

Это лишь краткое описание того, как WCF применяет SynchronizationContext. Более детальное описание см. в статье «Synchronization Contexts in WCF» ((msdn.microsoft.com/magazine/cc163321) в номере MSDN Magazine за ноябрь 2007 г.

Windows Workflow Foundation (WF): WorkflowInstance.SynchronizationContext WF-хосты изначально использовали WorkflowSchedulerService и производные типы для управления планированием операций рабочих процессов в потоках. В .NET Framework 4 в класс WorkflowInstance и производный от него класс WorkflowApplication включено свойство SynchronizationContext.

SynchronizationContext можно установить напрямую, если хост-процесс создает собственный WorkflowInstance. SynchronizationContext также используется WorkflowInvoker.InvokeAsync, который захватывает текущий SynchronizationContext и передает его своему WorkflowApplication. Этот SynchronizationContext затем применяется для асинхронной передачи события завершения рабочего процесса, а также операций рабочего процесса.

Task Parallel Library (TPL): TaskScheduler.FromCurrentSynchronizationContext и CancellationToken.Register TPL использует объекты-задачи как единицы работы и выполняет их с помощью TaskScheduler. Стандартный TaskScheduler (по умолчанию) действует подобно SynchronizationContext по умолчанию, ставя задачи в очередь к ThreadPool. Есть и другой TaskScheduler, который ставит задачи в очередь к SynchronizationContext. Поддержку отчета о прогрессе, отображаемом в UI, можно реализовать с помощью вложенной задачи, как показано на рис. 4.

Рис. 5. Отчет о прогрессе на основе обновлений UI

private void button1_Click(object sender, EventArgs e)
{
  // This TaskScheduler captures SynchronizationContext.Current.
  TaskScheduler taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
  // Start a new task (this uses the default TaskScheduler, 
  // so it will run on a ThreadPool thread).
  Task.Factory.StartNew(() =>
  {
    // We are running on a ThreadPool thread here.

  
    ; // Do some work.


  // Report progress to the UI.
    Task reportProgressTask = Task.Factory.StartNew(() =>
      {
        // We are running on the UI thread here.

        ; // Update the UI with our progress.
      },
      CancellationToken.None,
      TaskCreationOptions.None,
      taskScheduler);
    reportProgressTask.Wait();
  
    ; // Do more work.
  });
}

Для всех видов отмены в .NET Framework 4 применяется класс CancellationToken.Для интеграции с существующими формами отмены этот класс позволяет регистрировать делегат, который вызывается при запросе отмены. После регистрации делегата можно передать SynchronizationContext. Когда запрашивается отмена, CancellationToken — вместо того чтобы выполнять делегат напрямую — ставит его в очередь к SynchronizationContext.

Microsoft Reactive Extensions (Rx): ObserveOn, SubscribeOn and SynchronizationContextScheduler Rx — это библиотека, которая обрабатывает события как потоки данных. Через SynchronizationContext оператор ObserveOn ставит в очередь события, а оператор SubscribeOn — подписки на эти события. ObserveOn широко применяется для обновления UI при приеме событий, а SubscribeOn — для использования событий от UI-объектов.

В Rx также есть свой способ размещения единиц работы в очереди: интерфейс IScheduler. Rx включает SynchronizationContextScheduler — реализацию IScheduler, который ставит в очередь к SynchronizationContext.

Visual Studio Async CTP: await, ConfigureAwait, SwitchTo и EventProgress<T> Поддержка Visual Studio асинхронных преобразований кода была объявлена на конференции Microsoft Professional Developers Conference 2010. По умолчанию текущий SynchronizationContext захватывается в точке ожидания (await), и этот контекст используется при возобновлении после ожидания (точнее, текущий SynchronizationContext захватывается, если он не равен null, иначе захватывается текущий TaskScheduler):

private async void button1_Click(object sender, EventArgs e)
{
  // SynchronizationContext.Current is implicitly captured by await.
  var data = await webClient.DownloadStringTaskAsync(uri);

  // At this point, the captured SynchronizationContext was used to resume
  // execution, so we can freely update UI objects.
}

ConfigureAwait позволяет избавиться от захвата SynchronizationContext по умолчанию; передача false в параметре flowContext запрещает использовать SynchronizationContext для возобновления выполнения после await. Также есть метод расширения SwitchTo в экземплярах SynchronizationContext; вызовом SwitchTo любой асинхронный метод может менять контекст на другой SynchronizationContext.

Асинхронная CTP-версия вводит общий шаблон, позволяющий сообщать о прогрессе из асинхронных операций: интерфейс IProgress<T> и его реализация EventProgress<T>. Этот класс захватывает текущий SynchronizationContext при его конструировании и генерирует событие ProgressChanged в этом контексте.

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

Ограничения и гарантии

Понимание SynchronizationContext полезно для любого программиста. Существующие компоненты, рассчитанные на несколько инфраструктур, используют его для синхронизации своих событий. Библиотеки могут предоставлять его для большей гибкости. Грамотный кодировщик, понимающий ограничения и гарантии SynchronizationContext, способен писать и использовать такие классы гораздо эффективнее.

Стефен Клиэри (Stephen Cleary) увлечен многопоточным программированием с тех пор, как впервые услышал об этой концепции. Создал множество многозадачных систем повышенной ответственности для крупных клиентов, в том числе Syracuse News, R. R. Donnelley и BlueScope Steel. Регулярно выступает на собраниях групп пользователей .NET, мероприятиях BarCamps и Day of .NET, проводимых неподалеку от его дома в Северном Мичигане; обычно его выступления посвящены тематике многопоточности. Ведет блог для программистов nitoprograms.com.

Выражаю благодарность за рецензирование статьи эксперту Эрику Ейлбрехту (Eric Eilebrecht)