并行计算

SynchronizationContext 综述

Stephen Cleary

多线程编程相当困难,而且要进行多线程编程需要了解无数概念和工具。为此,Microsoft .NET Framework 提供了 SynchronizationContext 类。很遗憾,很多开发人员甚至不知道这个有用的工具。

无论是什么平台(ASP.NET、Windows 窗体、Windows Presentation Foundation (WPF)、Silverlight 或其他),所有 .NET 程序都包含 SynchronizationContext 概念,并且所有多线程编程人员都可以通过理解和应用它获益。

SynchronizationContext 的必要性

多线程程序在 .NET Framework 出现之前就存在了。这些程序通常需要一个线程将一个工作单元传递给另一个线程。Windows 程序围绕消息循环进行,因此很多编程人员使用这一内置队列传递工作单元。每个要以这种方式使用 Windows 消息队列的多线程程序都必须定义自己的自定义 Windows 消息以及处理约定。

当 .NET Framework 首次发布时,这一通用模式是标准化模式。那时,.NET 唯一支持的 GUI 应用程序类型是 Windows 窗体。不过,框架设计人员期待其他模型,他们开发出了一种通用的解决方案。ISynchronizeInvoke 诞生了。

ISynchronizeInvoke 的原理是,一个“源”线程可以将一个委托列入“目标”线程队列,选择等待该委托完成。ISynchronizeInvoke 还提供了一个属性来确定当前代码是否已在目标线程上运行;这样,就不必使委托继续排队。Windows 窗体提供了唯一的 ISynchronizeInvoke 实现,并且开发了一种模式来设计异步组件,这样皆大欢喜。

.NET Framework 2.0 版包含很多重大改动。其中一项重要改进是在 ASP.NET 体系结构中引入了异步页面。在 .NET Framework 2.0 之前的版本中,每个 ASP.NET 请求都需要一个线程,直到该请求完成。这会造成线程利用率低下,因为创建网页通常依赖于数据库查询和 Web 服务调用,并且处理请求的线程必须等待,直到所有这些操作结束。使用异步页面,处理请求的线程可以开始每个操作,然后返回到 ASP.NET 线程池;当操作结束时,ASP.NET 线程池的另一个线程可以完成该请求。

但是,ISynchronizeInvoke 不太适合 ASP.NET 异步页面体系结构。使用 ISynchronizeInvoke 模式开发的异步组件在 ASP.NET 页面内无法正常工作,因为 ASP.NET 异步页面不与单个线程关联。无须将工作排入原来的线程队列,异步页面只需对未完成的操作进行计数 以确定页面请求何时可以完成。经过精心设计,SynchronizationContext 取代了 ISynchronizeInvoke。

SynchronizationContext 的概念

ISynchronizeInvoke 满足了两点需求:确定是否必须同步,使工作单元从一个线程列队等候另一个线程。设计 SynchronizationContext 是为了替代 ISynchronizeInvoke,但完成设计后,它就不仅仅是一个替代品了。

一方面,SynchronizationContext 提供了一种方式,可以使工作单元列队列入上下文。请注意,工作单元是列入上下文,而不是某个特定线程。这一区别非常重要,因为很多 SynchronizationContext 实现都不是基于单个特定线程的。SynchronizationContext 不包含用来确定是否必须同步的机制,因为这是不可能的。

SynchronizationContext 的另一方面是每个线程都有“当前”上下文。线程上下文不一定唯一;其上下文实例可以与多个其他线程共享。线程可以更改其当前上下文,但这样的情况非常少见。

SynchronizationContext 的第三个方面是它保持未完成操作的计数。这样,就可以使用 ASP.NET 异步页面和需要此类计数的任何其他主机。大多数情况下,捕获到当前 SynchronizationContext 时,计数递增;捕获到的 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 概念可以和不可以实现的功能。我将简单讨论部分实现。

WindowsFormsSynchronizationContextSystem.Windows.Forms.dll:System.Windows.Forms)Windows 窗体应用程序会创建并安装一个 WindowsFormsSynchronizationContext 作为创建 UI 控件的任意线程的当前上下文。这一 SynchronizationContext 使用 UI 控件的 ISynchronizeInvoke 方法,该方法将委托传递给基础 Win32 消息循环。WindowsFormsSynchronizationContext 的上下文是一个单独的 UI 线程。

在 WindowsFormsSynchronizationContext 列队的所有委托一次一个地执行;它们通过一个特定 UI 线程执行以便列队。当前实现为每个 UI 线程创建一个 WindowsFormsSynchronizationContext。

DispatcherSynchronizationContextWindowsBase.dll:System.Windows.Threading)WPF 和 Silverlight 应用程序使用 DispatcherSynchronizationContext,这样,委托按“常规”优先级在 UI 线程的调度程序中列队。当一个线程通过调用 Dispatcher.Run 开始其调度程序时,这一 SynchronizationContext 作为当前上下文安装。DispatcherSynchronizationContext 的上下文是一个单独的 UI 线程。

在 DispatcherSynchronizationContext 列队的所有委托一次一个地执行;它们通过一个特定 UI 线程执行以便列队。当前实现为每个顶层窗口创建一个 DispatcherSynchronizationContext,即使它们都使用相同的基础调度程序也是如此。

默认 (ThreadPool) SynchronizationContextmscorlib.dll:System.Threading)默认 SynchronizationContext 是默认构造的 SynchronizationContext 对象。根据惯例,如果一个线程的当前 SynchronizationContext 为 null,那么它隐式具有一个默认 SynchronizationContext。

默认 SynchronizationContext 将其异步委托列队到 ThreadPool,但在调用线程上直接执行其同步委托。因此,其上下文包含所有 ThreadPool 线程以及调用 Send 的任何线程。此上下文“借用”调用 Send 的线程,将它们放入其上下文,直至委托完成。从这种意义上讲,默认上下文可以包含进程中的所有 线程。

默认 SynchronizationContext 应用于 ThreadPool 线程,除非代码由 ASP.NET 承载。默认 SynchronizationContext 还隐式应用于显式子线程(Thread 类的实例),除非子线程设置自己的 SynchronizationContext。因此,UI 应用程序通常有两个同步上下文:包含 UI 线程的 UI SynchronizationContext 和包含 ThreadPool 线程的默认 SynchronizationContext。

很多基于事件的异步组件使用默认 SynchronizationContext 无法正常工作。一个 BackgroundWorker 启动另一个 BackgroundWorker 这样的 UI 应用程序就是一个糟糕的示例。每个 BackgroundWorker 都捕获并使用调用 RunWorkerAsync 的线程的 SynchronizationContext,之后在该上下文中执行其 RunWorkerCompleted 事件。在只有一个 BackgroundWorker 的情况下,这通常是基于 UI 的 SynchronizationContext,因此 RunWorkerCompleted 在 RunWorkerAsync 捕获的 UI 上下文中执行(请参见图 2)。

图 2 UI 上下文中只有一个 BackgroundWorker

但是,如果 BackgroundWorker 从其 DoWork 处理程序启动另一个 BackgroundWorker,那么嵌套的 BackgroundWorker 不会捕获 UI SynchronizationContext。DoWork 由 ThreadPool 线程使用默认 SynchronizationContext 执行。在这种情况下,嵌套的 RunWorkerAsync 将捕获默认 SynchronizationContext,因此它将对一个 ThreadPool 线程而不是 UI 线程执行其 RunWorkerCompleted(请参见图 3)。

图 3 UI 上下文中的嵌套 BackgroundWorker

默认情况下,控制台应用程序和 Windows 服务中的所有线程都只有默认 SynchronizationContext。这会导致一些基于事件的异步组件失败。要解决这个问题,可以创建一个显式子线程,然后将 SynchronizationContext 安装在该线程上,这样就可以为这些组件提供上下文。本文不介绍如何实现 SynchronizationContext,不过,Nito.Async 库的 ActionThread 类 (nitoasync.codeplex.com) 可用作通用 SynchronizationContext 实现。

AspNetSynchronizationContext (System.Web.dll:System.Web [internal class]) ASP.NET SynchronizationContext 在线程池线程执行页面代码时安装在上面。当委托列对到捕获的 AspNetSynchronizationContext 中时,它恢复原始页面的标识和区域,然后直接执行委托。即使委托是通过调用 Post“异步”列队的,也会直接调用委托。

从概念上讲,AspNetSynchronizationContext 的上下文非常复杂。在异步页面的生存期中,该上下文从来自 ASP.NET 线程池的一个线程开始。异步请求开始后,该上下文不包含任何线程。异步请求结束时,执行其完成例程的线程池线程进入该上下文。这些可能是启动请求的线程,但更可能是操作完成时处于空闲状态的任何线程。

如果同一应用程序的多项操作同时完成,AspNetSynchronizationContext 确保一次只执行其中一项。它们可以在任意线程上执行,但该线程将具有原始页面的标识和区域。

一个常见的示例是在异步网页中使用 WebClient。DownloadDataAsync 将捕获当前 SynchronizationContext,之后在该上下文中执行其 DownloadDataCompleted 事件。当页面开始执行时,ASP.NET 会分配它的一个线程执行该页面中的代码。该页面可能调用 DownloadDataAsync,然后返回;ASP.NET 对未完成的异步操作进行计数,以便了解页面是否完成。当 WebClient 对象下载所请求的数据后,它将接收到一个线程池线程的通知。此线程将在捕获的上下文中引发 DownloadDataCompleted。该上下文将保持在相同的线程中,但会确保事件处理程序使用正确的标识和区域运行。

有关 SynchronizationContext 实现的注意事项

SynchronizationContext 提供了一种途径,可以在很多不同框架中编写组件。BackgroundWorker 和 WebClient 就是两个在 Windows 窗体、WPF、Silverlight、控制台和 ASP.NET 应用程序中同样应用自如的示例。但是,在设计这类可重用组件时,必须注意几点。

一般而言,SynchronizationContext 实现无法进行相等性比较。也就是说,ISynchronizeInvoke.InvokeRequired 没有等效项。不过,这不是多大的缺点;代码更为清晰,并且更容易验证它是否始终在已知上下文中执行,而不是试图处理多个上下文。

不是所有 SynchronizationContext 实现都可以保证委托执行顺序或委托同步顺序。基于 UI 的 SynchronizationContext 实现确实满足这些条件,但 ASP.NET SynchronizationContext 只提供同步。默认 SynchronizationContext 不保证执行顺序或同步顺序。

SynchronizationContext 实例和线程之间没有 1:1 的对应关系。WindowsFormsSynchronizationContext 确实 1:1 映射到一个线程(只要不调用 SynchronizationContext.CreateCopy),但任何其他实现都不是这样。一般而言,最好不要假设任何上下文实例将在任何指定线程上运行。

最后,SynchronizationContext.Post 方法不一定是异步的。大多数实现异步实现此方法,但 AspNetSynchronizationContext 是一个明显的例外。这会导致无法预料的重入问题。图 4 总结了这些不同的实现。

图 4 SynchronizationContext 实现摘要

  使用特定线程执行委托 独占(一次执行一个委托) 有序(委托按队列顺序执行) Send 可以直接调用委托 Post 可以直接调用委托
Windows 窗体 如果从 UI 线程调用 從不
WPF/Silverlight 如果从 UI 线程调用 從不
默认 不能 不能 不能 Always 從不
ASP.NET 不能 不能 Always Always

AsyncOperationManager 和 AsyncOperation

.NET Framework 中的 AsyncOperationManager 和 AsyncOperation 类是 SynchronizationContext 抽象的轻型包装。AsyncOperationManager 在第一次创建 AsyncOperation 时捕获当前 SynchronizationContext,如果当前 SynchronizationContext 为 null,则使用默认 SynchronizationContext。AsyncOperation 将委托异步发布到捕获的 SynchronizationContext。

大多数基于事件的异步组件都在其实现中使用 AsyncOperationManager 和 AsyncOperation。这些对于具有明确完成点的异步操作(即异步操作从一个点开始,以另一个点的事件结束)非常有效。其他异步通知可能没有明确的完成点;它们可能是一种订阅类型,在一个点开始,然后无限期持续。对于这些类型的操作,可以直接捕获和使用 SynchronizationContext。

新组件不应使用基于事件的异步模式。Visual Studio 异步社区技术预览 (CTP) 包含一篇描述基于任务的异步模式的文档,在这种模式下,组件返回 Task 和 Task<TResult> 对象,而不是通过 SynchronizationContext 引发事件。基于任务的 API 是 .NET 中异步编程的发展方向。

SynchronizationContext 的库支持示例

像 BackgroundWorker 和 WebClient 这样的简单组件是隐式自带的,隐藏了 SynchronizationContext 捕获和使用。很多库以更可见的方式使用 SynchronizationContext。通过使用 SynchronizationContext 公开 API,库不仅获得了框架独立性,而且为高级最终用户提供了一个可扩展点。

除了下面讨论的库,当前 SynchronizationContext 也被视为 ExecutionContext 的一部分。任何捕获线程的 ExecutionContext 的系统都会捕获当前 SynchronizationContext。当恢复 ExecutionContext 时,通常也会恢复 SynchronizationContext。

Windows Communication Foundation (WCF):UseSynchronizationContext WCF 有两个用于配置服务器和客户端行为的特性:ServiceBehaviorAttribute 和 CallbackBehaviorAttribute。这两个特性都有一个 Boolean 属性:UseSynchronizationContext。此特性的默认值为 true,这表示在创建通信通道时捕获当前 SynchronizationContext,这一捕获的 SynchronizationContext 用于使约定方法列队。

通常,这一行为正是我们所需要的:服务器使用默认 SynchronizationContext,客户端回调使用相应的 UI SynchronizationContext。在需要重入时,这会导致问题,如客户端调用的服务器方法调用一个客户端回调。在这类情况下,将 UseSynchronizationContext 设置为 false 可以禁止 WCF 自动使用 SynchronizationContext。

上面只是简单介绍了 WCF 如何使用 SynchronizationContext。有关详细信息,请参阅 MSDN 杂志 2007 年 11 月刊中的文章“WCF 中的同步上下文”(msdn.microsoft.com/magazine/cc163321)。

Windows Workflow Foundation (WF):WorkflowInstance.SynchronizationContext WF 主机最初使用 WorkflowSchedulerService 和派生类型控制如何在线程上安排工作流活动。部分 .NET Framework 4 升级在 WorkflowInstance 类及其派生类 derived WorkflowApplication 上包含 SynchronizationContext 属性。

如果承载进程创建自己的 WorkflowInstance,则可以直接设置 SynchronizationContext。WorkflowInvoker.InvokeAsync 也使用 SynchronizationContext,它捕获当前 SynchronizationContext 并将其传递给其内部 WorkflowApplication。然后该 SynchronizationContext 用于发布工作流完成事件以及工作流活动。

任务并行库 (TPL):TaskScheduler.FromCurrentSynchronizationContext 和 CancellationToken.Register TPL 使用任务对象作为其工作单元并通过 TaskScheduler 执行。默认 TaskScheduler 的作用类似于默认 SynchronizationContext,将任务在 ThreadPool 中列队。TPL 队列还提供了另一个 TaskScheduler,将任务在 SynchronizationContext 中列队。UI 更新的进度报告可以在一个嵌套任务中完成,如图 5 所示。

图 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 被动扩展 (Rx):ObserveOn、 SubscribeOn 和 SynchronizationContextScheduler Rx 是一个库,它将事件视为数据流。 ObserveOn 运算符通过一个 SynchronizationContext 将事件列队,SubscribeOn 运算符通过一个 SynchronizationContext 将对这些事件的订阅 列队。 ObserveOn 通常用于使用传入事件更新 UI,SubscribeOn 用于从 UI 对象使用事件。

Rx 还有它自己的工作单元列队方法:IScheduler 接口。 Rx 包含 SynchronizationContextScheduler,这是一个列入 SynchronizationContext 的 IScheduler 实现。

Visual Studio Async CTP:await、ConfigureAwait、 SwitchTo 和 EventProgress<T> Visual Studio 对异步代码转换的支持是在 2010 年 Microsoft 专业开发人员大会上发布的。 默认情况下,当前 SynchronizationContext 在一个等待点捕获,此 SynchronizationContext 用于在等待后继续(更确切地说,仅当它不为 null 时,才捕获当前 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 捕获;为 flowContext 参数传递 false 会阻止使用 SynchronizationContext 在等待后继续执行。 SynchronizationContext 实例还有一种扩展方法 SwitchTo;使用该方法,任何异步方法都可以通过调用 SwitchTo 并等待结果,更改为不同的 SynchronizationContext。

异步 CTP 引入了报告异步操作进展的通用模式:IProgress<T> 接口及其实现 EventProgress<T>。 该类在构造时捕获当前 SynchronizationContext 并在此上下文中引发其 ProgressChanged 事件。

除了这一支持外,返回 void 的异步方法还在异步操作开始时递增计数,在异步操作结束后递减计数。 这一行为使返回 void 的异步方法类似于顶层异步操作。

限制和功能

了解 SynchronizationContext 对任何编程人员来说都是有益的。 现有跨框架组件使用它同步其事件。 库可以将它公开以获得更高的灵活性。 技术精湛的编程人员了解 SynchronizationContext 限制和功能后,可以更好地编写和利用这些类。

Stephen Cleary 自第一次听到多线程这个概念,就对它具有浓厚的兴趣。他为很多主要客户完成了很多关键业务多任务处理系统,这些客户包括 Syracuse News、R. R. Donnelley 和 BlueScope Steel。他经常在 .NET 用户组、BarCamp 和北密歇根他家附近的 .NET 日活动上发言,主题通常与多线程有关。他的编程博客位于 nitoprograms.com

衷心感谢以下技术专家对本文的审阅:Eric Eilebrecht