 MSDN Magazine October 2007

MSDN Magazine October 2007

池线程: 使用新的线程池 API 提高可伸缩性

Windows Vista 和即将发布的 Windows Server 2008 中的新线程池 API 提供了一些改进，甚至连本机 Windows 开发人员也能从中受益。

Robert Saccone WPF 线程: 使用调度程序构建反应速度更快的应用程序

理解 Windows Presentation Foundation 线程模型有助于创建具有吸引力和响应性的用户界面。

Shawn Wildermuth Parallel LINQ: 在多核处理器上运行查询

通过并行语言集成查询 (PLINQ) 可利用多处理器计算机和新一代多核处理器的优势。

Joe Duffy and Ed Essey 并行性能: 优化多核计算机的托管代码

虽然多核处理器日益普遍，但大部分的应用程序并没能充分利用多核。下面将概述如何创建受益于这些新处理器的多线程应用程序。

Daan Leijen and Judd Hall 移动应用程序: 调整铃声音量，避免环境噪音

下面将概述如何设计和安装 Windows Mobile 应用程序，该应用程序可监控周围环境中的噪音水平，并对 Pocket PC 的音量进行相应调整。

Chris Mitchell 编者按: 复杂性之复杂性

本月，我们的新总编 Howard Dierking 仔细研究了隐喻和抽象为软件设计和杂志制作所提供的便利性。

Howard Dierking 工具箱: 管理数据库、更便捷的 FTP，以及群集缓存

本月产品包括 DatabaseSpy、FileZilla、NCache 等。

Scott Mitchell CLR 完全介绍: IronPython 和动态语言运行时

本专栏使用 .NET 版的 Python 编程语言 (IronPython) 展示动态语言和新的动态语言运行时的价值。

Bill Chiles 基本技术: Visual Basic 2008 中的类型推断

Visual Basic 2008 可提供类型推断，使语言做好利用 LINQ 的充分准备并使您的代码功能更强大。

Bill Horst 数据点: 通用表表达式

在 T-SQL 函数中，视图和派生表有相似的用途及各自优势。但是如果使用 SQL Server 2005，则有第三种方案可供选择，就是使用通用表表达式 (CTE)。John Papa 主讲。

John Papa 领先技术: AJAX 应用程序体系结构，第 2 部分

本系列共分为两个部分，第二部分将深入探讨脚本服务编程模型，如果您正在寻找构建 AJAX 应用程序的全面模式转换，这部分的内容将非常有用。

Dino Esposito 测试运行: Windows XP Embedded 的测试自动化

本月专栏探讨如何为运行在 Windows XP Embedded 上的软件系统创建轻型而强大的 UI 测试自动化。

Dr. James McCaffrey and Mike Hall 基础: WPF 中的三维文字

本月 Charles Petzold 将探讨有关在 Windows Presentation Foundation 中生成三维文字的技术。

Charles Petzold 借助 C++ 进行 Windows 开发: Task Scheduler 2.0

Windows Vista 中的新 Task Scheduler 的功能比以前版本大大增强。我们将介绍一些基本概念和构造块，在您自己的计划任务项目中可以使用它们。

Kenny Kerr 从 C++ 到 .NET: EEK!-osystem 之详解

本专栏将创建一个 XML 文档来保存老鼠环境模拟数据，并展示如何使用 DataSet 将 XML 文件并入程序中。

Stanley B. Lippman .NET 相关问题: 死锁监控

本月 Stephen Toub 将讨论同步线程时可能产生的死锁。

Stephen Toub { End Bracket }: 婚礼事务

看看我们的网站经理 Mike Richter 如何利用 Virtual Earth、Microsoft MapPoint Web 服务和 Visual Studio，来自动创建他未来婚礼的网站和管理宾客名单。

Michael Richter


池线程: 使用新的线程池 API 提高可伸缩性

池线程

使用新的线程池 API 提高可伸缩性

Robert Saccone
本文的部分内容是以 Windows Server 2008 的预发布版为基础。文中包含的详细信息均有可能变更。
代码下载位置: VistaThreadPools2007_10.exe (175KB)
本文讨论:
  • 线程池功能
  • 传统线程池的局限性
  • 新线程池 API
  • 线程池对象
本文使用了以下技术:
Windows Vista, Windows Server 2008
目录
线程池概述
线程池对象
回调环境对象
工作对象
等待对象
计时器对象
I/O 完成对象
使用清理组简化清理
回调实例 API
使用示例应用程序尝试一下

随
着 Windows Vista® 的发布和 Windows Server® 2008 的即将问世，Microsoft 为托管应用程序的开发人员提供了改进的 Windows® 平台，它包含多种新技术，使得令原本 Windows 开发人员从中受益的改进能被广为利用。例如，自从 Windows 2000 发布以来，已是该平台组成部分的线程池组件经历了一次完整的体系结构重建。新的实现包含一个新的线程池 API，该 API 可以让开发人员更加轻松地编写正确的代码。传统的 API 仍受支持，所以传统的应用程序可以继续运行，但是正如您将看到的那样，移至新的 API 会带来很多好处。

线程池概述

在探究新的 API 之前，让我们一起回顾一下线程池组件所提供的功能的类型。简言之，在发送内核对象信号时，以及在异步 I/O 请求完成时，线程池允许按时间间隔异步调用各个函数。每当必须调用其中一个回调函数时，都会有一个回调函数的调用请求被添加到线程池维护的先进先出 (FIFO) 队列的末端。由线程池内部管理的工作线程可以从该队列中删除项目，并调用回调函数。由于线程池可以管理工作线程的生存期，因此应用程序开发人员无需显式管理线程的生存期。这同时也意味着该应用程序永远不能使用 TerminateThread 函数或从正在工作线程上执行的函数调用 ExitThread 函数，来终止线程池的工作线程。

应用程序通常会创建专用的线程，这些线程会花费大量时间等待事件或定期提醒要轮询某一状态。线程池可提高此操作的效率。对于正在尝试处理大量客户端请求的服务器类应用程序来说，线程池会启用 Windows 平台的首选服务器并发模型，系统中有多少个处理器，该模型就会同时处理多少个请求。我曾见到开发人员尝试过的另一个模型是针对每个要服务的客户端专门提供一个线程，但是该模型不能很好地扩展到应付大量的客户端。大量的线程不仅会消耗大量资源，而且在它们之间进行上下文切换所带来的成本也会显著增加，并且会影响到每个客户端接受到的服务的整体质量。

新的线程池组件克服了传统线程池的诸多局限性。例如，新的线程池允许您为每个进程创建多个线程池，而以前的模型只允许创建一个。这可让您根据某些选定的条件将应用程序执行的任务隔离开来。例如，假设您在编写一个分布式服务器应用程序，该应用程序将使用线程池，通过网络连接处理异步 I/O 完成。这些网络连接属于两种类型，一种是来自客户端应用程序，另一种是来自运行在另一台计算机上的服务器应用程序的其他实例。我们进一步假设针对每台服务器您必须支持大量同步客户端连接，并且客户端请求的数量要远远超出服务器到服务器的连接数，不过服务器到服务器的连接具有更高的优先级。

如果客户端和服务器的网络连接都是在单个的线程池上进行处理，就会存在一个所有的完成通知都在其中排队的单个队列。这意味着来自服务器连接的 I/O 完成一直要等到队列中排在它前面的所有 I/O 完成都已处理后（包括来自任何客户端的所有 I/O 完成），才会处理它。当系统面临较重的客户端负载时，这会导致处理服务器连接时发生严重延迟。拥有专用的客户端连接线程池和服务器连接线程池就能实现隔离，因为每个线程池都有它自己的队列和工作线程集。操作系统中的调度程序将确保这些处理器能在两个池的线程之间合理共享。但是，由于每个进程创建过多线程池会损害性能，并减少吞吐量，所以明智地使用多个线程池非常重要。

传统的线程池使用两种类型的工作线程：I/O 和非 I/O，它们有时会引起混淆。如果开发人员不了解它们之间的区别，就可能导致性能受限或不正确的行为。而且，有两个不同的线程分组意味着线程池实现本身的效率就不会太高，因为它不能在不同类型的回调函数之间共享线程。在新的线程池中，不再有这个区别，并且所有的工作线程都相同。

在传统的 API 中，QueueUserWorkItem 用于将要在线程池工作线程上异步调用的函数排入队列。传统的 API 无法让该应用程序确定工作线程结束执行回调函数的时间。如果回调函数位于 DLL 中，要确保能安全卸载该 DLL 实际上是不可能的。这表示有时候卸载 DLL 会导致宿主进程崩溃。同样，也无法取消位于线程池的队列中等待执行的回调，因此除了等待所有的请求清空以外别无选择，而这会带来严重的延迟。

最后，传统的 API 没有将资源分配与资源使用分开。由于这些资源分配可能会失败，而且也确实失败了，所以传统的 API 使开发具有可靠性保障的系统变得非常困难。新的线程池 API 将资源分配和使用进行了明确的区分，因此一旦资源分配成功，当这些资源付诸使用时，编写严谨的代码实际上都不大可能会失败。

新的线程池 API 是基于对象的，其中每个类型的对象都有一组用于创建、清理以及修改属性的函数。图 1 汇总了 API 公开的对象类型。有一点必须要了解，那就是该线程池创建的所有对象以及由它管理的所有工作线程都成为使用它们的应用程序进程的一部分。

Figure 1 线程池对象的类型 (Close Figure 1)

对象类型 | 说明
TP_POOL | 用于执行回调的线程池。
TP_TIMER | 在到期时间调用回调函数。
TP_WAIT | 在发出内核对象信号或等待超时的时候调用回调函数。
TP_WORK | 异步调用回调函数。
TP_IO | 异步 I/O 完成后调用回调函数。
TP_CLEANUP_GROUP | 跟踪一个或多个线程池回调对象。
TP_CALLBACK_ENVIRON | 将线程池绑定到其回调对象，并可选择是否绑定到清理组。

线程池对象

准备要使用的线程池的第一步是使用 CreateThreadpool 函数创建一个线程池，如图 2 所示。该函数取必须为空值的保留参数。如果该函数成功，会返回一个 PTP_POOL，表示新分配的线程池。如果该函数失败，会返回空值，可使用 GetLastError 获取详细的错误信息。

Figure 2 线程池对象 API (Close Figure 2)
PTP_POOL WINAPI CreateThreadpool(PVOID reserved);

BOOL WINAPI SetThreadpoolThreadMinimum(PTP_POOL ptpp,
                      DWORD cthrdMin);

VOID WINAPI SetThreadpoolThreadMaximum(PTP_POOL ptpp,
                      DWORD cthrdMost);

VOID WINAPI CloseThreadpool(PTP_POOL ptpp);

一旦创建线程池，就可以控制该池将使用两个新 API 进行管理的线程的最小数量和最大数量。默认情况下，该线程池的最小数量为 0，最大数量为 500。选择这些数值是为了能与传统线程池进行向后兼容，因为有些使用传统线程池的应用程序需要大量的线程。这些默认值在 Windows 未来的版本中可能会有所改变，因为由于过多的上下文切换，使用如此多线程的应用程序可能会导致性能低下。

这些 API 有一些值得注意的有趣功能。请注意，SetThreadpoolThreadMinimum 会返回一个布尔值，而 SetThreadpoolThreadMaximum 则不会返回任何内容。这是因为如果设置线程的最小数量要求 SetThreadpoolThreadMinimum 增加该池的工作线程的数量以满足新的最小数量，并且分配这些线程时发生失败，那么这个失败的消息就会被报告给调用方。设置该池的线程最大数量不会导致任何资源分配，因为所有的函数都是根据该线程池可以创建的线程数来设置上限的。该线程池会根据线程池的实际工作负荷，在最小值和最大值之间进行选择来确定线程数量。遗憾的是，在 Windows Vista 中，当 SetThreadpoolThreadMinimum 需要增加该池的工作线程数量时，它会出现一个 Bug：函数会在没有即刻创建其他线程的情况下返回。这个 Bug 将在 Windows Vista Service Pack 1 中修复，并且在 Windows Server 2008 中不再是问题。但是对于现在的 Windows Vista（除了工作线程创建失败的问题以外），一旦排列了足够的工作而导致线程池创建指定的最小线程数量，线程池便会采用这个最小值，并且至少会维护该池中那些数量的线程。

将线程的最小数量和最大数量设置成相同的值会创建一个持久线程池，该线程池直到该池关闭后才会终止。这对于使用具有函数（例如 RegNotifyChangeKeyValue）的线程池来说非常方便，该函数必须从持久线程中调用。有关 RegNotifyChangeKeyValue 的详细信息，请参阅 msdn2.microsoft.com/ms724892。

注意：调用最小值大于目前最大值的 SetThreadpoolThreadMinimum 不仅会导致设置新的最小值，还会导致将这个最大值设置成那个新的最小值。调用最大值小于最小值的 SetThreadpoolThreadMaximum 会导致将该最小值设置成新的最大值。线程计数参数在这两个函数中被指定为 DWORD，但是 DWORD 的整个范围不可用于该线程计数。在内部，该值会被当作 LONG，并且要验证它是大于等于 0。

现在应当解释一下线程池 API 的错误报告原理。如果某个操作是一种可以被合理预期为失败的操作，那么就可以通过被调用函数的返回代码来报告结果。但是，异常的错误则通过结构化的异常来进行报告。例如，对于函数 SetThreadpoolThreadMinimum 和 SetThreadpoolThreadMaximum，传递无效的工作线程数量值或空线程池指针会导致出现结构化异常。但是，在调用 SetThreadpoolThreadMinimum 期间无法创建新工作线程，这一失败结果会作为 FALSE 向调用方报告，因为资源分配失败是一个应该预期到的错误。将结构化异常处理程序封装到对线程池 API 的调用周围可能比较有吸引力，这样应用程序便不会因为未处理的异常而终止，但其实这并不是一个好方法，因为捕获这些异常并继续执行程序只会隐藏该应用程序本身的问题。这只会使得应用程序最终失败时更加难以诊断问题的原因。

为了正确使用这些 API，了解如何实现该线程池的并发模型将会对您大有裨益。该模型基于系统中可用处理器的数量。例如，如果系统有两个物理处理器，而每个处理器又都是双核的，那么在大多数时间只有四个可运行的线程是最好的状态。这会减少上下文切换带来的系统开销。然而，如果应用程序在大多数情况只有一个未完成的回调，将线程池的大小调整为只有一个线程是非常明智的选择。让我们回到双处理器双内核的示例中来，假设应用程序有很多未完成的回调，池中至少应该有四个工作线程，允许每个内核处理一个项目。但是，为了维护最佳并发，可能需要让线程池包含四个以上的工作线程。如果想知道原因，可考虑这样一种情形，四个内核中的每个内核都已忙于执行回调函数，而线程池的队列中也有挂起的回调。如果执行回调块的其中一个工作线程阻塞会出现什么情况呢？会有三种可能性：

  • 如果线程池在池中有一个可用的线程，它会调度另一个线程将下一个项目从队列中删除，并调用挂起的回调函数。
  • 如果线程池没有其他可用的工作线程，且已创建的工作线程的数量小于最大线程数，那么经过短期的延迟之后，它会创建一个新的工作线程，该线程将被调度来执行挂起的回调函数。
  • 如果线程池没有其他可用的工作线程，且已创建的工作线程的数量已达到最大线程数，那么将不会创建其他线程，并且挂起的回调项目会保留在队列中，直到前一个调度的工作线程结束运行其回调函数并返回到线程池，确定是否有其他要执行的项目为止。

因此，如果回调函数在执行的时候可能会阻塞，那么线程池的最大大小必须要大于系统中可用处理器的数量，以便实现该硬件支持的最大并发。理想情况下，回调函数应该永远不会阻塞。阻塞的回调函数不仅会减少并发，而且还会降低工作线程的重用级别。因此，想办法减少或最小化回调函数阻塞的时间是绝对值得的。如果您了解回调阻止的对象，就可以缩短这个时间。例如，如果回调在执行同步 I/O，可考虑将它更改成可以在线程池上完成的异步 I/O。另外请注意最小化任何必须在回调函数之间发生的同步，因为锁争用可能引起阻塞。

如果大多数的回调函数都不阻塞，则可以将线程池的大小调整成包含更少的线程，但要超过可用处理器的数量。如果大多数回调函数准备终止阻塞，则应该调整线程池的大小，使其包含的工作线程数大大超过可用处理器的数量。在这种情况下，大多数线程都会在处于等待状态的情况下结束。当回调函数完成后，为了最大化整体吞吐量，该线程池会阻止工作线程，将可运行线程的数量减少到一个最适合处理器配置的数量。关键是您需要调整线程池的大小，以便在有任务要运行，并且有足够的处理器带宽可用时，该池可以创建更多的线程来执行工作。但是，如果您发现您的应用程序实际上使用了非常多的线程，比如默认设置中的 500，或者如果存在过多的上下文切换成本，那么说明该应用程序的设计不合理，您需要使用性能监视器和代码探查器来检查您的代码，以确定哪里需要改进。

您处理完该线程池以后，就应该使用 CloseThreadpool 函数将其关闭。如果没有绑定到该线程池的未完成的回调对象，该线程池就会立即关闭。如果有，就会在释放那些未完成的对象时异步释放该线程池。

回调环境对象

既然您掌握了如何创建线程池，下一步就要了解回调环境对象是什么，以及应如何使用它。回调环境对象用于将线程池实例绑定到线程池回调对象的实例，您的应用程序创建该回调对象是为了在该线程池上执行实际工作。回调环境对象还允许您附加清理组对象，这使线程池回调对象的清理变得更加简单。本文稍后将对清理组进行详细介绍。回调环境 API 如图 3 所示。

Figure 3 回调环境 API (Close Figure 3)
VOID InitializeThreadpoolEnvironment(PTP_CALLBACK_ENVIRON pcbe);
VOID DestroyThreadpoolEnvironment(PTP_CALLBACK_ENVIRON pcbe);
VOID SetThreadpoolCallbackPool(PTP_CALLBACK_ENVIRON pcbe,
                    PTP_POOL ptpp);
VOID SetThreadpoolCallbackLibrary(PTP_CALLBACK_ENVIRON pcbe,
                    PVOID mod);
VOID SetThreadpoolCallbackRunsLong(PTP_CALLBACK_ENVIRON pcbe);

VOID SetThreadpoolCallbackCleanupGroup(PTP_CALLBACK_ENVIRON pcbe,
                    PTP_CLEANUP_GROUP ptpcg,
                    PTP_CLEANUP_GROUP_CANCEL_CALLBACK pfng);

创建回调环境的第一步是在堆栈上的静态存储区中声明 TP_CALL_BACK_ENVIRON 结构，或从堆中分配一个。第二步是使用 InitializeThreadpoolEnvironment 函数将它初始化，这会使指针指向 TP_CALLBACK_ENVIRON。最后一步是使用 SetThreadpoolCallbackPool 将线程池与回调环境关联起来。如果您不将线程池与回调环境关联起来，或者如果在调用 SetThreadpoolCallbackPool 时指定的是空值，则会使用该进程的默认线程池。正如您将看到的那样，该回调环境最终用于创建各种线程池回调对象实例。一旦您的应用程序修改完回调环境并创建完所需回调对象的全部实例，就应使用 DestroyThreadpoolEnvironment 函数来销毁该回调环境。

SetThreadpoolCallbackRunsLong API 用于提示线程池与该环境相关联的回调函数可能不会迅速返回。您稍后会明白这对从调用 API function CallbackMayRunLong 返回的结果有什么样的影响。

我之前提到过，如果应用程序需要确定何时可以安全卸载 DLL（它包含将在线程池上执行的回调函数），传统的线程池缺少这方面的支持。新线程池 API 提供了 SetThreadpoolCallbackLibrary 函数，可保证只要 DLL 内的回调函数仍在执行，库就保持加载状态。实际出现的情况是：在调用回调函数之前，会获得操作系统的加载程序锁（当 DLL 位于待加载或待卸载的进程中时，该锁始终锁定），并且 DLL 的引用计数会递增；然后加载程序锁也会解除锁定。回调执行完成后，会再次获得加载程序锁以减少引用数。这使得在执行回调函数时无法卸载 DLL。注意，当回调在线程池的队列中挂起时，DLL 上的引用计数保持不变。这表示可以在回调挂起的情况下卸载 DLL。但是它是 DllMain 函数的工作，您编写该函数就是为了确保能处理 DLL_PROCESS_DETACH 事件并取消所有挂起的回调。我稍后将谈到这一点。

工作对象

工作对象用于促使线程池在工作线程上异步调用回调函数。CreateThreadpoolWork 函数（如图 4 所示）以及与 API 相关的所有其他工作对象都可用于创建工作对象。

Figure 4 工作对象 API (Close Figure 4)
PTP_WORK WINAPI CreateThreadpoolWork(PTP_WORK_CALLBACK pfnwk,
                    PVOID Context,
                    PTP_CALLBACK_ENVIRON pcbe);
VOID WINAPI SubmitThreadpoolWork(PTP_WORK pwk);
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE Instance,
                    PVOID Context,
                    PTP_WORK Work);
VOID WINAPI WaitForThreadpoolWorkCallbacks(PTP_WORK pwk,
                    BOOL fCancelPendingCallbacks);
VOID WINAPI CloseThreadpoolWork(PTP_WORK pwk);

BOOL WINAPI TrySubmitThreadpoolCallback(PTP_SIMPLE_CALLBACK pfns,
                    PVOID pv,
                    PTP_CALLBACK_ENVIRON pcbe);
VOID CALLBACK SimpleCallback(PTP_CALLBACK_INSTANCE Instance,
                    PVOID Context);

第一个参数 pfnwk 是指向工作线程要执行的回调函数的指针。第二个参数 Context 的类型为 PVOID，可用于提供回调函数需要的任何应用程序特定的数据。最后一个参数是指向 TP_CALLBACK_ENVIRON 的指针。工作对象将会绑定到线程池和已与回调环境关联起来的清理组（如果有）。

工作对象创建完成之后，它可以通过 SubmitThreadpoolWork 函数列入线程池队列。最后，工作线程会从该队列中删除该工作对象，并调用其相关的回调函数。每次调用 SubmitThreadpoolWork 都会产生一个对工作项目的回调函数的调用。

工作对象的 WorkCallback 函数采用三个参数。第一个是 Instance，它用于确定回调函数的执行的特定实例，并仅对该回调函数的执行保持有效。Context 参数是作为第二个参数提供给 CreateThreadpoolWork 的指针。最后一个参数是 Work，它是要被调用的回调函数的工作对象的实例。

回调函数可以被线程池中的任何工作线程调用，了解这一点很重要。要遵守的经验法则是：回调函数不应对它执行所处的工作线程进行任何假设，应让工作线程的状态与它在回调函数调用之前所处的状态保持一致。例如，如果回调函数准备使用 COM，那它必须在每次被调用时调用 CoInitializeEx。回调函数还应该在返回之前调用 CoUninitialize，因为工作线程可以重用，从而为许多不同的任务调度回调函数，或者当它被返回到线程池时甚至可能会被终止。这可以防止资源泄漏和放任状态信息。资源泄漏和状态信息的放任都可能会负面影响下一个可能拥有不同的线程执行环境要求的回调函数的执行。Application Verifier 是一个针对未托管代码的运行时验证工具，可以帮助查找一些一般应用程序测试可能难以识别的细小编程错误。在帮助查找与线程池相关的编程错误方面有了提高。它可以检测到的错误包括：不平衡的 CoInitializeEx 和 CoUninitialize 调用、在将工作线程返回到线程池之前尚未恢复的线程优先级和关联变化、尚未恢复的模拟，以及孤立的关键部分。如需完整列表，请查阅与 Application Verifier 一起安装的文档，它可从 microsoft.com/downloads/details.aspx?familyid=bd02c19c-1250-433c-8c1b-2619bd93b3a2 下载。

WaitForThreadpoolWorkCallbacks 函数会阻止调用线程，直到工作对象所有未完成的回调函数都完成执行为止。第二个参数控制是应该允许挂起的回调执行还是应该取消该挂起的回调；挂起的回调是指被列入线程池队列但尚未分配给工作线程进行执行的回调。请谨慎使用该 API——在工作回调函数内部使用它会导致死锁。

除了与工作对象相关的 API 集以外，还有 CloseThreadpoolWork 函数。如果没有未完成的回调，该工作对象会被立即释放；否则一旦回调完成，工作对象就会被异步释放。这也意味着将会取消线程池队列中任何在等待执行的挂起回调。

图 4 中剩下的函数 TrySubmitThreadpoolCallback 可以提供以下等效功能：创建工作对象、将其提交到线程池、一旦 pfns 参数指定的回调完成执行后确保该工作对象会关闭。该回调函数的签名必须与图 4 中 SimpleCallback 的签名一致。它和工作对象所关联的回调稍有不同。由于该线程池负责在内部分配和释放工作对象，所以只将回调函数实例指针和应用程序定义的上下文（在 TrySubmitThreadpoolCallback 的 Context 参数中指定）传递给该回调函数。由于 TrySubmitThreadpoolCallback 分配资源来执行它的工作，因此有可能会失败，这就是它会返回布尔型返回代码的原因。

等待对象

等待对象用于在发送内核对象信号后或当指定的等待期间超时的时候调用回调函数。CreateThreadpoolWait 函数可用来创建等待对象。它的参数会遵循与 CreateThreadpoolWork 相同的模式，例外情况是，指向第一个参数中提供的回调函数的指针必须与 WaitCallback 函数的签名相匹配，如图 5 所示。

Figure 5 等待对象 API (Close Figure 5)
PTP_WAIT WINAPI CreateThreadpoolWait(PTP_WAIT_CALLBACK pfnwa,
             PVOID pv,
             PTP_CALLBACK_ENVIRON pcbe);
VOID WINAPI SetThreadpoolWait(PTP_WAIT pwa,
             HANDLE h,
             PFILETIME pftTimeout);
VOID CALLBACK WaitCallback(PTP_CALLBACK_INSTANCE Instance,
             PVOID Context,
             PTP_WAIT Wait,
             TP_WAIT_RESULT WaitResult);
VOID WINAPI WaitForThreadpoolWaitCallbacks(PTP_WAIT pwa,
             BOOL fCancelPendingCallbacks);
VOID WINAPI CloseThreadpoolWait(PTP_WAIT pwa);

创建等待对象后，下一步是促使该线程池等待发送内核对象信号。SetThreadpoolWait 函数可用于实现这一点。第一个参数 pwa 是指向要设置的等待对象实例的指针。第二个参数是要等待的内核对象的 HANDLE。最后一个参数 pftTimeout 是指向 FILETIME 结构的指针，它指出了线程池必须等待发送内核对象信号的时间。等待时间可以用绝对值或相对期间表示，以 100 纳秒为单位。传递一个正值表示超时是一个自 1/1/1600 开始的绝对时间。传递一个负值表示是一个相对于调用该函数的当前时间的时间。传递 0 值表示该等待会立即超时。最后，传递空值表示一个永不超时的无限等待。

一旦发送了内核对象信号或等待超时，该线程池就会调用该等待对象的 WaitCallback 函数。最开始的两个参数与之前介绍的 WorkCallback 函数中的参数一模一样。Wait 参数指示要为哪个等待对象调用该回调，而 WaitResult 则用来指示调用的原因。WaitResult 将是 WAIT_ABANDONED_0、WAIT_OBJECT_0 或 WAIT_TIMEOUT。如果将 WaitResult 设置成 WAIT_OBJECT_0，表示已发送内核对象信号，并且可满足该等待。

如果 WaitResult 设置为 WAIT_TIMEOUT，这表示该等待不满足，并且已经过了对 SetThreadpoolWait 的调用中指定的超时间隔。WAIT_ABANDONED_0 的结果表示指定的对象是互斥量，在它终止之前拥有该互斥量对象的线程没有将它释放。但是，应该避免对等待对象使用互斥量，因为调用 WaitCallback 函数的工作线程并不是在互斥量上执行等待的线程。该线程池会使用另一个类型的线程（称为 waiter 线程）来实际执行内核对象上的等待。实际上是 waiter 线程拥有该互斥量。Waiter 线程未提供给应用程序，因此一旦 waiter 线程获得它的所有权，则无法释放该互斥量。

为等待对象调用 WaitCallback 函数后，必须再次调用 SetThreadpoolWait，以便重用该等待对象并使该线程池再次等待内核对象收到信号。注意当再次调用 SetThreadpoolWait 以重用等待对象时，您可以再次指定要等待的任何内核对象句柄。如果您想等待另一个内核对象，就不必使用在第一次调用 SetThreadpoolWait 时指定的句柄。最后，剩下的等待对象 API：WaitForThreadpoolWaitCallbacks 和 CloseThreadpoolWait，它们的行为方式和它们工作对象的对应部分一模一样。

计时器对象

当计时器对象到达过期时间时，计时器对象便可调用回调函数。它们是使用 CreateThreadpoolTimer 函数创建的，如图 6 所示，同时还有其他与计时器对象相关的 API。再次重申，这些参数遵循与 CreateThreadpoolWork 函数相同的模式（参见图 4），例外是指向第一个参数中所提供回调函数的指针必须与 TimerCallback 函数的签名相匹配，如图 6 所示。

Figure 6 计时器对象 API (Close Figure 6)
PTP_TIMER WINAPI CreateThreadpoolTimer(PTP_TIMER_CALLBACK pfnti,
              PVOID pv,
              PTP_CALLBACK_ENVIRON pcbe);
VOID WINAPI SetThreadpoolTimer(PTP_TIMER pti,
              PFILETIME pftDueTime,
              DWORD msPeriod,
              DWORD msWindowLength);
VOID CALLBACK TimerCallback(PTP_CALLBACK_INSTANCE Instance,
              PVOID Context,
              PTP_TIMER Timer);
VOID WINAPI WaitForThreadpoolTimerCallbacks(PTP_TIMER pti,
              BOOL fCancelPendingCallbacks);
BOOL WINAPI IsThreadpoolTimerSet(PTP_TIMER pti);
VOID WINAPI CloseThreadpoolTimer(PTP_TIMER pti);

创建计时器对象以后，下一步是使用 SetThreadpoolTimer 函数来对它进行设置。pftDueTime 参数用于设置计时器首次到期的时间。这个时间的表示方式和上面介绍的 SetThreadpoolWait 函数中的超时一样。顾名思义，msPeriod 参数可以设置一个定期触发的计时器，以毫秒表示。因此，一旦由 pftDueTime 表示的第一个时间间隔过期，并引起回调被列入线程池的队列，则每个由 msPeriod 定义的后续时间间隔过期时间都会引起另一个回调被列入队列。

msWindowLength 参数可指定一个以毫秒表示的时间窗口，在这期间线程池可能会延迟计时器的过期时间。当您使用大量的计时器并且过期时间不必非常精确时，此参数可以提高效率。该时间窗口是一种附加因素，它允许系统将属于该范围的所有计时器过期时间进行合并，以便对它们进行批处理。这比唤醒线程、使计时器过期、休眠、唤醒线程、使另一个计时器过期、休眠等等更有效率。仅当该窗口中没有任何肯定会过期的计时器时，才会发生延迟。为了更好地了解 msWindowLength 的使用方法，请考虑这样一种服务器应用程序：它具有大量传入的客户端连接，而为了减少资源使用，必须关闭五分钟之内一直不活动的连接。在这种情况下，为不活动的计时器指定一个非零的窗口长度可能是可以接受的，因为这可导致在稍微超过过期时间的时候仍能保持连接。

SetThreadpoolTimer 也可以用来为以前设置的计时器设置新的过期时间、期间和窗口长度。如果 pftDueTime 参数为空值，它将停止将回调列入 TimerCallback 函数的队列，而已排队的回调仍然会被执行。这样您就可以在不关闭计时器对象的情况下取消计时器，以便可以重用。

一旦该计时器对象到期，调用该回调的请求就会被列入该线程池队列中。工作线程将选取该请求，并调用在调用 SetThreadpoolTimer 时提供的 TimerCallback 函数。该回调函数的参数与之前介绍的工作对象调用函数的那些参数相同，除了第三个参数是 PTP_TIMER，而不是 PTP_WORK 以外。

在图 5 剩余的计时器 API 函数当中，IsThreadpoolTimerSet 函数（顾名思义）在计时器已设置时返回 TRUE，否则就返回 FALSE。其他两个函数 WaitForThreadpoolTimerCallbacks 和 CloseThreadpoolTimer 的行为方式与它们对应的工作对象的行为方式一模一样。

I/O 完成对象

线程池支持的最后一个类型的回调对象是 I/O 完成对象。图 7 列出了所有与 I/O 完成对象相关的 API。I/O 完成对象可用来将文件句柄绑定到线程池，以便将异步 I/O 完成通知列入该线程池的队列，由工作线程进行处理。CreateThreadpoolIo 函数的各个参数都遵循与其他创建函数相似的模式，只是它多了一个 HANDLE 参数，必须对重叠的 I/O 开放。

Figure 7 I/O 完成对象 API (Close Figure 7)
PTP_IO WINAPI CreateThreadpoolIo(HANDLE fl,
                PTP_WIN32_IO_CALLBACK pfnio,
                PVOID pv,
                PTP_CALLBACK_ENVIRON pcbe);
VOID WINAPI StartThreadpoolIo(PTP_IO pio);
VOID CALLBACK IoCompletionCallback(PTP_CALLBACK_INSTANCE Instance,
                PVOID Context,
                PVOID Overlapped,
                ULONG IoResult,
                ULONG_PTR NumberOfBytesTransferred,
                PTP_IO Io);
VOID WINAPI WaitForThreadpoolIoCallbacks(PTP_IO pio,
                BOOL fCancelPendingCallbacks);
VOID WINAPI CancelThreadpoolIo(PTP_IO pio);
VOID WINAPI WaitForThreadpoolIoCallbacks(PTP_IO pio,
                BOOL fCancelPendingCallbacks);
VOID WINAPI CloseThreadpoolIo(PTP_IO pio);

如图 7 所示，CreateThreadpoolIo 的第一个参数是可以接收完成通知的 HANDLE，而第二、三、四个参数分别表示指向要被调用的回调函数的指针、可选的应用程序特定上下文，以及指向回调环境的指针。如果成功，CreateThreadpoolIo 会返回一个非空指针，否则就返回一个空指针。

为了促使由线程池处理 I/O 完成通知，必须在对句柄发出每个 I/O 异步请求操作之前调用 StartThreadpoolIo 函数。忘记做这一步会带来严重的后果，因为在这种情况下该线程池会忽略该 I/O 完成，同时会引起内存损坏。当启动异步 I/O 操作的调用返回一个失败，而不是 ERROR_IO_PENDING 时，您必须调用一个相关的函数 CancelThreadpoolIo。不小心忘记调用 CancelThreadpoolIo 会导致该线程池泄漏内存。

一旦发生 I/O 完成，工作线程就会调用与 I/O 完成对象相关的 IoCompletionCallback。该函数的签名遵循的约定和所有其他回调一样：该函数最开始的两个参数分别是指向回调实例的指针和用来创建函数的 Context 指针。第三个参数 Overlapped 是指向启动异步 I/O 操作时所提供的重叠结构的指针。第四个参数是 IoResult，它包含该操作的结果。如果该操作成功完成，IoResult 就会包含 NO_ERROR；否则会包含一个系统错误代码（请参见 msdn2.microsoft.com/ms681381）。第五个参数 NumberOfBytesTransferred 包含 I/O 操作期间传输的字节数，而第六个参数则是指向 I/O 完成对象本身的指针。

同样，图 7 中剩余的函数 WaitForThreadpoolIoCallbacks 和 CloseThreadpoolIo 的行为方式和它们工作对象的对应部分类似。

使用清理组简化清理

现在让我们了解一下清理组可以如何帮助简化清理应用程序创建的线程池回调对象这一过程。通过将清理组和线程池回调环境关联起来，使用回调环境创建的所有线程池回调对象都可以被清理组跟踪。一旦您的应用程序使用完回调对象，接下来它要确保每个对象都已关闭，因此它需要为每个回调对象执行一个函数调用，而不是一个关闭调用。事实上，如果回调对象与清理组相关联，就不应该调用它的关闭 API。

使用清理组的第一步是使用 CreateThreadpoolCleanupGroup 函数创建一个清理组对象，如图 8 所示。

Figure 8 清理组 API (Close Figure 8)
PTP_CLEANUP_GROUP WINAPI CreateThreadpoolCleanupGroup(void);
VOID WINAPI CloseThreadpoolCleanupGroupMembers(PTP_CLEANUP_GROUP ptpcg,
                       BOOL fCancelPendingCallbacks,
                       PVOID pvCleanupContext);
VOID CALLBACK CleanupGroupCancelCallback(PVOID ObjectContext,
                       PVOID CleanupContext);
VOID WINAPI CloseThreadpoolCleanupGroup(PTP_CLEANUP_GROUP ptpcg);

第二步是将该清理组和要与之一起使用的回调环境关联起来。图 3 中的 SetThreadpoolCallbackCleanupGroup 函数会设置这个关联。

该函数的第一个参数是指向该回调环境的指针。第二个参数是 PTP_CLEANUP_GROUP，第三个参数是一个指针，指向调用 CloseThreadpoolCleanupGroupMembers 函数时将被调用的回调函数。

一旦应用程序使用完被清理组跟踪的线程池回调对象，它唯一需要做的就是调用 CloseThreadpoolGroupMembers（它将一直阻塞，直到当前所有回调函数都完成执行之后）。如果 fCancelPendingCallbacks 为 TRUE，已列入线程池队列但尚未开始执行的回调也会被取消。如果 fCancelPendingCallbacks 为 FALSE，那么在所有挂起的回调函数都被调度给工作线程并完成执行之后 CloseThreadpoolCleanupGroupMembers 才会返回。pvCleanupContext 参数用于将应用程序数据传递给调用 SetThreadpoolCallbackCleanupGroup 时指定的可选取消清理回调函数。取消回调函数会针对每个被清理的线程池回调对象调用一次。取消回调的签名必须与图 3 中显示的 CleanupGroupCancelCallback 函数匹配。第一个参数 ObjectContext 是为正被清理的线程池回调对象的创建函数提供的可选数据。第二个参数 CleanupContext 是调用方为 CloseThreadpoolGroupMembers 函数提供的可选数据。调用 CloseThreadpoolCleanupGroupMembers 后，CloseThreadpoolCleanupGroup 函数可用来关闭该清理组并释放与其相关的所有资源。当清理组还有成员的时候，不要调用 CloseThreadpoolCleanupGroup，这一点非常重要；如果调用，可能会导致资源泄漏。

回调实例 API

最后一组要探讨的 API 如图 9 所示。它们的设计初衷是从正执行回调对象的回调函数的工作线程使用，因为它们都要求将 PTP_CALLBACK_INSTANCE 传递到回调函数中。第一个函数 CallbackMayRunLong 用于通知线程池回调函数想延长一段运行时间。该线程池会跟踪执行长期运行回调的工作线程的数量。回想一下之前的内容，我们使用了 SetThreadpoolCallbackRunsLong 函数通知线程池：与提供的回调环境相关联的回调函数都是长期运行回调。

Figure 9 回调实例 API (Close Figure 9)

BOOL WINAPI CallbackMayRunLong(
                PTP_CALLBACK_INSTANCE Instance);
VOID WINAPI DisassociateCurrentThreadFromCallback(
                PTP_CALLBACK_INSTANCE Instance);
VOID WINAPI SetEventWhenCallbackReturns(
                PTP_CALLBACK_INSTANCE Instance,
                HANDLE evt);
VOID WINAPI ReleaseSemaphoreWhenCallbackReturns(
                PTP_CALLBACK_INSTANCE Instance,
                HANDLE sem,
                DWORD crel);
VOID WINAPI LeaveCriticalSectionWhenCallbackReturns(
                PTP_CALLBACK_INSTANCE Instance,
                PCRITICAL_SECTION pcs);
VOID WINAPI ReleaseMutexWhenCallbackReturns(
                PTP_CALLBACK_INSTANCE Instance,
                HANDLE mut);
VOID WINAPI FreeLibraryWhenCallbackReturns(
                PTP_CALLBACK_INSTANCE Instance,
                HMODULE mod);

来自 CallbackMayRunLong 的 TRUE 结果表示线程池有可用于处理长期运行回调的工作线程。在考虑工作线程的可用性时，线程池仅考虑在调用该函数时存在的当前这一组工作线程。FALSE 返回代码表示所有可用的工作线程都正忙于执行长期运行回调。在这种情况下，回调函数应尽快返回，并且如果想让工作线程仍可用于执行短期运行回调，则将长期运行工作延迟到稍后某个时间。

图 9 中的下一个函数是 DisassociateCurrentThreadFromCallback，它可中断当前执行的回调函数和启动该回调的对象之间的关联。当前的线程将不再计为代表对象执行回调。例如，如果某工作对象的回调函数调用 DisassociateCurrentThreadFromCallback，它就可以使用到该工作对象的指针（已传递到回调函数中）调用 WaitForThreadpoolWorkCallbacks，而不会有死锁风险。但是，DisassociateCurrentThreadFromCallback 确实保留当前执行的回调与该回调环境的清理组的关联，所以如果另一个线程已调用 CloseThreadpoolCleanupGroupMembers，该函数就会等待执行回调函数的线程返回线程池。这可确保当 DLL 中仍有线程执行代码时，不会卸载 DLL。有一点您需要注意，如果您调用 DisassociateCurrentThreadFromCallback，并且计划在回调函数内重用对象（比方说为工作对象调用 SubmitThreadpoolWork 之类的函数），这必须与任何对 CloseThreadPoolCleanupGroupMembers 的调用同步，因为一旦 CloseThreadPoolCleanupGroupMembers 开始执行后，试图重用该对象会导致回调函数内部发生异常。

下一组函数会使用同步对象协调回调函数执行的完成。SetEventWhenCallbackReturns 函数用于在当前回调完成后，将事件对象设置成已发送信号的状态。与之类似，函数 ReleaseSemaphoreWhenCallbackReturns、ReleaseMutexWhenCallbackReturns 和 LeaveCriticalSectionWhenCallbackReturns 都旨在于当前回调函数完成后，释放不同类型的锁对象。这些函数可以确保不管回调函数返回什么内容，指定的锁都会被释放，从而帮助减少编程错误。但愿在 Windows 的未来版本中会增加一些新函数，以支持 Slim 读取器锁/写入器锁。

FreeLibraryWhenCallbackReturns 函数用于让线程池在指定的回调实例完成执行后，对传递的模块句柄调用 FreeLibrary。这要靠该应用程序来确保所有的未完成回调都已完成执行，并且在该回调函数返回之前线程池队列中的所有挂起回调都已取消。

以前，SetThreadpoolCallbackLibrary 函数被描述成一种当回调函数仍在执行 DLL 内的代码时防止 DLL 被过早卸载的方法。这种保险措施的成本相当高，因为这涉及到在调用回调函数之前和之后获得和释放加载程序锁的系统开销。还需注意，在该进程中有一个单个的加载程序锁，对它可能会有较严重的争用，这意味着依靠该线程池的机制来确保 DLL 不会因过早卸载而对该应用程序的性能产生负面影响。根据应用程序方案的不同，通过结合使用在此部分介绍的函数和 Window 的同步化基元，来构建可提供 DLL 安全卸载的应用程序特定机制可能会更有效。

使用示例应用程序尝试一下

正如您所看到的那样，新的线程池组件有了很多改进，可以帮助您轻松地编写具有高可靠性和可伸缩性的应用程序。这些改进中最关键部分的是可以为每个进程承载多个线程池，其中每个线程池都有自己独立的一组特征，所以您可以根据执行的工作类型对进程进行分区。

我希望新线程池的这个概述已激发起您的兴趣，让您想去探究如何使用它来优化您的应用程序。为了帮助您入门，我在本文中附上了两个示例应用程序。第一个是 ThreadPoolDemo，让您可以体验一下工作、等待和计时器这些对象，以便您可以探究它们是如何工作的。ThreadPoolDemo 既可以运行在进程的默认线程池上，也可以运行在自定义的线程池上，允许通过命令行参数来指定最小和最大线程数。

通过指定 –Work 命令行选项执行的工作对象演示可用于创建工作对象。通过使用命令行参数 –I 提供的计数，它会按照指定的次数将工作项提交到线程池。您可以使用 -B 选项来定义回调函数应阻塞多长时间，使用 -E 选项来指定回调函数应执行多长时间。默认情况下，该程序会一直运行，直到所有提交的工作项都执行完毕。在命令行指定 –CC 或 –CW 会导致在提交工作项的次数达到指定的次数后，该应用程序会立即调用 CloseThreadpoolCleanupGroupMembers。两个选项之间的差别在于：–CC 可以取消尚未启用的所有回调，而 –CW 会在从 CloseThreadpoolCleanupGroupMembers 调用返回之前等待它们执行。

您还可以试验一下计时器对象。您可以配置多个计时器对象，其中每个对象都可以有自己的过期时间和可选期间以及窗口。使用具有不同窗口大小的多个计时器，可让您看到将计时器过期结合在一起的系统的效果。

最后，等待对象演示可让您为要等待的线程池定义一个或多个事件规范。每个事件规范都可包括一个可选过期时间，用来指示发送该事件信号的时间；还可以包括一个可选超时间隔，用来定义等待应过期的时间。这些选项可让您试验发送信号的事件、等待超时的事件或永不发送信号和永不超时的事件的组合。

如需每个回调对象类型的命令行参数，请在命令提示符下键入下面其中一个命令：“ThreadPoolDemo –Work ?”、“ThreadPoolDemo –Timer ?”或“ThreadPoolDemo –Wait ?”。

本文包含的第二个示例应用程序叫 CopyFile。实际上，它是 Windows SDK 文件复制示例的更新版本，用于演示完成端口是如何工作的。CopyFile 会通过使用线程池将源文件复制到目标文件，并演示 I/O 完成对象是如何工作的。在命令行中键入“CopyFile –Usage”可以在控制台窗口中显示程序参数的完整说明。

我很感谢 Rob Earhart、Eric Li 和 Sandeep Ranade 回答我的问题，并给出了有见解的回馈；同时我也很感谢 Rob Shewan 审阅本文的内容，并向我提出宝贵的意见。

Robert Saccone是 Forefront Server Security 团队的一名首席架构师。他感兴趣的领域是大型软件设计、分布式系统和操作系统实现。您可以通过 robsaccone@hotmail.com 与他联系。

© 2007 Microsoft Corporation 与 CMP Media, LLC.保留所有权利；不得对全文或部分内容进行复制.


WPF 线程: 使用调度程序构建反应速度更快的应用程序

WPF 线程

使用调度程序构建反应速度更快的应用程序

Shawn Wildermuth
本文讨论:
  • 在 WPF 中执行线程
  • 使用调度程序
  • 非 UI 线程处理
  • 使用计时器
本文使用了以下技术:
.NET Framework 3.0, WIndows Presentation Foundation
目录
线程模型
DispatcherObject
使用调度程序
BackgroundWorker
DispatcherTimer

如
果您在创建一个直观、自然甚至精美的界面上花费了数月时间，但结果是用户不得不在他们的组合办公桌上敲打着手指等待程序响应，这会让人觉得丢脸。由于长时间运行的进程导致应用程序的屏幕停滞不动，看到这样的情况是一件痛苦的事情。然而，创建响应迅速的应用程序需要进行认真的规划，这通常需要使长时间运行的进程在其他线程中工作，以便释放出 UI 线程，使其随时跟上用户的进度。

我第一次真正体验响应速度可追溯到 Visual C++® 与 MFC 以及我曾经编写的第一个网格。当时，我正在帮助编写一个药学应用程序，该程序必须能够将每种药物显示在复杂的处方中。问题是有 30,000 种药物，因此我们决定先在 UI 线程中填充第一个满屏药物（时间大约为 50 毫秒），给人一种反应迅速的印象，然后使用后台线程完成填充不可见的药物（时间大约为 10 秒）。项目运行良好，而且我学到了非常宝贵的经验，那就是用户感知可以比现实更重要。

在创建具有吸引力的用户界面方面，Windows® Presentation Foundation (WPF) 是一项出色的技术，但这并不意味着您就不需要考虑应用程序的响应性。不管相关的长时间运行进程的类型为何（不管是从数据库获取大量结果，进行异步 Web 服务调用，还是任何数量的其他潜在密集型操作），简单的事实就是，响应更快的应用程序是让用户更满意的长期保证。但是，开始在 WPF 应用程序中使用异步编程模型之前，了解 WPF 线程模型非常重要。在本文中，我不但将会向您介绍此线程模型，还会向您展示基于调度程序的对象的工作原理，以及解释如何使用 BackgroundWorker 以便创建具有吸引力和响应性的用户界面。

线程模型

所有 WPF 应用程序启动时都会加载两个重要的线程：一个用于呈现用户界面，另一个用于管理用户界面。呈现线程是一个在后台运行的隐藏线程，因此您通常面对的唯一线程就是 UI 线程。WPF 要求将其大多数对象与 UI 线程进行关联。这称之为线程关联，意味着要使用一个 WPF 对象，只能在创建它的线程上使用。在其他线程上使用它会导致引发运行时异常。注意，WPF 线程模型可与基于 Win32® 的 API 进行顺畅的交互。这意味着 WPF 可以承载或承载于任何基于 HWND 的 API（Windows Forms、Visual Basic®、MFC，甚至是 Win32）。

线程关联由 Dispatcher 类处理，该类即是用于 WPF 应用程序的、按优先级排列的消息循环。通常，WPF 项目有单个 Dispatcher 对象（因此有单个 UI 线程），所有用户界面工作均以其为通道。

与典型的消息循环不同，发送到 WPF 的每个工作项目都以特定的优先级通过 Dispatcher 进行发送。这就能够按优先级对项目排序，并延迟某种类型的工作，直到系统有时间来处理它们。（例如，有些工作项目可被延迟到系统或应用程序处于空闲状态时。） 支持项目优先顺序使 WPF 能够让某种类型的工作拥有更多的权限，因此在线程上拥有比其他工作更多的时间。

在本文的后面，我将会阐明，呈现引擎在更新用户界面方面比输入系统具备更高的优先级。这意味着不管用户是否正在使用鼠标、键盘或墨水打印系统，动画都将会继续更新用户界面。这可以使用户界面看起来响应更快。例如，让我们假定您正在编写一个音乐播放应用程序（类似于 Windows Media® Player）。不管用户是否正在使用界面，您最有可能希望显示有关音乐播放的信息（包括进度条和其他信息）。对用户来说，这可以使界面看起来对他们最感兴趣的事情（在此例中为听音乐）响应更快。

除了使用 Dispatcher 的消息循环将工作项目引导至用户界面线程之外，每个 WPF 对象也可感知对其负责的 Dispatcher（以及它由此所依赖的 UI 线程）。这意味着任何从第二个线程更新 WPF 对象的尝试均会失败。这就是 DispatcherObject 类的职责。

DispatcherObject

在 WPF 的类层次结构中，大部分都集中派生于 DispatcherObject 类（通过其他类）。如图 1 所示，您可以看到 DispatcherObject 虚拟类正好位于 Object 下方和大多数 WPF 类的层次结构之间。

图 1 DispatcherObject 派生
图 1 DispatcherObject 派生

DispatcherObject 类有两个主要职责：提供对对象所关联的当前 Dispatcher 的访问权限，以及提供方法以检查 (CheckAccess) 和验证 (VerifyAccess) 某个线程是否有权访问对象（派生于 DispatcherObject）。CheckAccess 与 VerifyAccess 的区别在于 CheckAccess 返回一个布尔值，表示当前线程是否可以使用对象，而 VerifyAccess 则在线程无权访问对象的情况下引发异常。通过提供这些基本的功能，所有 WPF 对象都支持对是否可在特定线程（特别是 UI 线程）上使用它们加以确定。如果您正在编写您自己的 WPF 对象（诸如控件），那么您使用的所有方法都应在执行任何工作之前调用 VerifyAccess。这可确保您的对象仅在 UI 线程上使用，如图 2 所示。

Figure 2 使用 VerifyAccess 与 CheckAccess (Close Figure 2)
public class MyWpfObject : DispatcherObject
{
    public void DoSomething()
    {
        VerifyAccess();

        // Do some work
    }

    public void DoSomethingElse()
    {
        if (CheckAccess())
        {
            // Something, only if called
            // on the right thread
        }
    }
}

为此，在调用 Control、Window、Panel 之类的任何 DispatcherObject 派生对象时，应注意要处在 UI 线程上。如果您从非 UI 线程调用 DispatcherObject，就会引发异常。相反，如果您正在某个非 UI 线程上工作，就需要使用 Dispatcher 来更新 DispatcherObjects。

使用调度程序

Dispatcher 类提供了到 WPF 中消息泵的通道，还提供了一种机制来路由供 UI 线程处理的工作。这对满足线程关联要求是必要的，但是对通过 Dispatcher 路由的每个工作来说，UI 线程都被阻止，因此使 Dispatcher 完成的工作小而快非常重要。最好将用户界面的大块工作拆分为较小的离散块，以便 Dispatcher 执行。任何不需要在 UI 线程上完成的工作应移到其他线程上，以便在后台进行处理。

通常，您将会使用 Dispatcher 类将工作项目发送到 UI 线程进行处理。例如，如果您想要使用 Thread 类在单独的线程上进行一些工作，那么可以创建一个 ThreadStart 委托，在新的线程上进行一些工作，如图 3 所示。

Figure 3 用非 UI 线程更新 UI——错误的方法 (Close Figure 3)
// The Work to perform on another thread
ThreadStart start = delegate()
{
    // ...

    // This will throw an exception
    // (it's on the wrong thread)
    statusText.Text = "From Other Thread";
};

// Create the thread and kick it started!
new Thread(start).Start();

此代码执行失败，原因是当前没有在 UI 线程上调用对 statusText 控件（一种 TextBlock）的 Text 属性的设置。当该代码尝试设置 TextBlock 上的 Text 时，TextBlock 类会在内部调用其 VerifyAccess 方法以确保该调用来自 UI 线程。当它确定调用是来自不同的线程时，则会引发异常。那么您如何使用 Dispatcher 在 UI 线程上进行调用呢？

Dispatcher 类提供了在 UI 线程上直接调用代码的权限。图 4 展示了使用 Dispatcher 的 Invoke 方法来调用名叫 SetStatus 的方法，从而更改 TextBlock 的 Text 属性。

Figure 4 更新 UI (Close Figure 4)
// The Work to perform on another thread
ThreadStart start = delegate()
{
  // ...

  // Sets the Text on a TextBlock Control.
  // This will work as its using the dispatcher
  Dispatcher.Invoke(DispatcherPriority.Normal,
                    new Action<string>(SetStatus),
                    "From Other Thread");
};
// Create the thread and kick it started!
new Thread(start).Start();

该 Invoke 调用包含三条信息：要执行的项目的优先级、说明要执行何种工作的委托，以及任何传递给第二个参数中所述委托的参数。通过调用 Invoke，它将要在 UI 线程上调用的委托排入队列。使用 Invoke 方法可确保在 UI 线程上执行工作之前保持阻止。

作为一种异步使用 Dispatcher 的替代方法，您可以使用 Dispatcher 的 BeginInvoke 方法为 UI 线程异步排队工作项目。调用 BeginInvoke 方法会返回一个 DispatcherOperation 类的实例，其中包含有关执行工作项目的信息，包括工作项目的当前状态和执行的结果（如果工作项目已完成）。BeginInvoke 方法和 DispatcherOperation 类的使用如图 5 所示。

Figure 5 异步更新 UI (Close Figure 5)
// The Work to perform on another thread
ThreadStart start = delegate()
{
    // ...

    // This will work as its using the dispatcher
    DispatcherOperation op = Dispatcher.BeginInvoke(
        DispatcherPriority.Normal,
        new Action<string>(SetStatus),
        "From Other Thread (Async)");

    DispatcherOperationStatus status = op.Status;
    while (status != DispatcherOperationStatus.Completed)
    {
        status = op.Wait(TimeSpan.FromMilliseconds(1000));
        if (status == DispatcherOperationStatus.Aborted)
        {
            // Alert Someone
        }
    }
};

// Create the thread and kick it started!
new Thread(start).Start();

与典型的消息泵实现不同，Dispatcher 是基于优先级的工作项目队列。这就能够实现更好的响应性，因为重要性更高的工作能够在重要性较低的工作之前执行。优先顺序的本质可通过 DispatchPriority 枚举中指定的优先级加以例证（如图 6 所示）。

Figure 6 DispatchPriority 优先级别（按优先级次序） (Close Figure 6)

优先级 | 说明
非活动 | 工作项目已排队但未处理。
SystemIdle | 仅当系统空闲时才将工作项目调度到 UI 线程。这是实际得到处理的项目的最低优先级。
ApplicationIdle | 仅当应用程序本身空闲时才将工作项目调度到 UI 线程。
ContextIdle | 仅在优先级更高的工作项目得到处理后才将工作项目调度到 UI 线程。
后台 | 在所有布局、呈现和输入项目都得到处理后才将工作项目调度到 UI 线程。
输入 | 以与用户输入相同的优先级将工作项目调度到 UI 线程。
已加载 | 在所有布局和呈现都完成后才将工作项目调度到 UI 线程。
呈现 | 以与呈现引擎相同的优先级将工作项目调度到 UI 线程。
DataBind | 以与数据绑定相同的优先级将工作项目调度到 UI 线程。
正常 | 以正常优先级将工作项目调度到 UI 线程。这是调度大多数应用程序工作项目时的优先级。
发送 | 以最高优先级将工作项目调度到 UI 线程。

一般来说，对于更新 UI 外观的工作项目（如我之前使用的示例），您应始终使用 DispatcherPriority.Normal 优先级。但也有时候应该使用不同的优先级。其中尤其令人感兴趣的是三个空闲优先级（ContextIdle、ApplicationIdle 和 SystemIdle）。通过这些优先级可以指定仅在工作负载很低的情况下执行的工作项目。

BackgroundWorker

现在您对 Dispatcher 的工作原理已有所了解，那么如果得知在大多数情况下都不会使用它，您可能会感到惊讶。在 Windows Forms 2.0 中，Microsoft 引入了一个用于非 UI 线程处理的类来为用户界面开发人员简化开发模型。此类称为 BackgroundWorker。图 7 显示了 BackgroundWorker 类的典型用法。

Figure 7 在 WPF 中使用 BackgroundWorker (Close Figure 7)
BackgroundWorker _backgroundWorker = new BackgroundWorker();

...

// Set up the Background Worker Events
_backgroundWorker.DoWork += _backgroundWorker_DoWork;
backgroundWorker.RunWorkerCompleted +=
    _backgroundWorker_RunWorkerCompleted;

// Run the Background Worker
_backgroundWorker.RunWorkerAsync(5000);

...

// Worker Method
void _backgroundWorker_DoWork(object sender, DoWorkEventArgs e)
{
    // Do something
}

// Completed Method
void _backgroundWorker_RunWorkerCompleted(
    object sender,
    RunWorkerCompletedEventArgs e)
{
    if (e.Cancelled)
    {
        statusText.Text = "Cancelled";
    }
    else if (e.Error != null)
    {
        statusText.Text = "Exception Thrown";
    }
    else
    {
        statusText.Text = "Completed";
    }
}

BackgroundWorker 组件与 WPF 的配合非常好，因为在后台它使用了 AsyncOperationManager 类，该类随之又使用 SynchronizationContext 类来处理同步。在 Windows Forms 中，AsyncOperationManager 递交从 SynchronizationContext 类派生的 WindowsFormsSynchronizationContext 类。同样，在 ASP.NET 中，它与 SynchronizationContext 的不同派生（称为 AspNetSynchronizationContext）配合使用。这些 SynchronizationContext 派生的类知道如何处理方法调用的跨线程同步。

在 WPF 中，可用 DispatcherSynchronizationContext 类来扩展此模型。通过使用 BackgroundWorker，可自动应用 Dispatcher 来调用跨线程方法调用。好消息是，由于您可能已经熟悉了这个常见的模式，因此可以继续在新的 WPF 项目中使用 BackgroundWorker。

DispatcherTimer
WPF 线程资源
  • Windows Presentation Foundation 虚拟实验室
  • .NET Framework 开发中心的 WPF
  • WPF 基础：线程模型，MSDN 库
  • “Programming the Windows Presentation Foundation”（Windows Presentation Foundation 编程），作者 Chris Sells 和 Ian Griffiths (O'Reilly, 2005)

在 Microsoft® .NET Framework 中定期执行代码是开发中的一项常见任务，但是在 .NET 中使用计时器仍令人困惑。如果您在 .NET Framework 基类库 (BCL) 中查找 Timer 类，那么至少会找到 3 种 Timer 类：System.Threading.Timer、System.Timers.Timer 和 System.Windows.Forms.Timer。每种计时器均有所不同。Alex Calvo 在《MSDN 杂志》中的文章解释了何时使用这些 Timer 类中的每个类（请参见 msdn.microsoft.com/msdnmag/issues/04/02/TimersinNET）。

对于 WPF 应用程序来说，有一种使用 Dispatcher（即 DispatcherTimer 类）的新型计时器。与其他计时器类似，DispatcherTimer 类支持指定滴答之间的间隔，以及在计时器事件触发时要运行的代码。在图 8 中可以看到一种相当常见的 DispatcherTimer 使用方法。

Figure 8 运行中的 DispatcherTimer 类 (Close Figure 8)
// Create a Timer with a Normal Priority
_timer = new DispatcherTimer();

// Set the Interval to 2 seconds
_timer.Interval = TimeSpan.FromMilliseconds(2000);

// Set the callback to just show the time ticking away
// NOTE: We are using a control so this has to run on
// the UI thread
_timer.Tick += new EventHandler(delegate(object s, EventArgs a)
{
    statusText.Text = string.Format(
        "Timer Ticked:  {0}ms", Environment.TickCount);
});

// Start the timer
_timer.Start();

因为 DispatcherTimer 类与 Dispatcher 相关联，因此还可以指定 DispatcherPriority 以及要使用的 Dispatcher。DispatcherTimer 类使用“正常”优先级作为当前 Dispatcher 的默认优先级，但是您可以覆盖这些值：

_timer = new DispatcherTimer(
    DispatcherPriority.SystemIdle, form1.Dispatcher);

规划工作进程以获得响应更快的应用程序，其中的一切努力都是非常值得的。开展一些初期研究工作可以使规划更成功。我建议您在开始之前浏览一下“WPF 线程参考”侧栏中提到的一些网站以及本文章，它们会为您开发响应更快的应用程序打下良好的基础。

Shawn Wildermuth不仅是 Microsoft MVP、MCSD.NET 和 MCT 的创始人，而且还是 Wildermuth Consulting Services 的创始人。此外，Shawn 是“Pragmatic ADO.NET”（实用 ADO.NET）(Addison-Wesley, 2002) 的作者，而且还与他人合著了四套 Microsoft 认证培训系列教材以及即将出版的“Prescriptive Data Architectures”（规定性数据体系结构）。您可以通过其网站与他联系，网址为 wildermuthconsulting.com。


Parallel LINQ: 在多核处理器上运行查询

Parallel LINQ

在多核处理器上运行查询

Joe Duffy and Ed Essey
本文基于目前正在开发中的 Parallel FX 库。文中包含的所有信息均有可能变更。
本文讨论:
  • PLINQ 基础知识
  • PLINQ 编程模型
  • 并发异常
  • 输出排序
本文使用了以下技术:
Parallel FX 库
目录
从 LINQ 到 PLINQ
PLINQ 编程模型
处理查询输出
并发异常
输出结果排序
副作用
开始执行 PLINQ

多
核处理器的时代已经来临。多核处理器曾经主要普遍应用于服务器和台式计算机，现在正转向移动电话和 PDA，这为节省耗电量带来了极大的好处。面对市场上多核处理器平台的日益增多，并行语言集成查询 (PLINQ) 为利用并行硬件（包括传统的多处理器计算机和新一代的多核处理器）的优势提供了一种简单的方法。

PLINQ 是一个查询执行引擎，它接受任何“LINQ 到对象”或“LINQ 到 XML”的查询，并在多处理器或多核可用时自动将其用于执行。编程模型中的变化微乎其微，这意味着您无需成为并发专家便可使用它。事实上，除非您真的想要深入了解所有相关工作原理，否则线程和锁的问题甚至都不会接触到。PLINQ 是 Parallel FX 的关键组件，后者则是 Microsoft® .NET Framework 中的下一代并发支持。

在确保未来并行微处理器体系结构的软件可伸缩性方面，使用 PLINQ 之类的技术将变得日益重要。如果现在就在整个应用程序中的首选位置采用 LINQ（例如在可表达为查询的数据或计算密集型运算之处），即可确保在 PLINQ 成为可用且运行您应用程序的计算机从 2 处理器升级到 4 处理器，再升级到 32 处理器甚至更多处理器时，您程序的片段会继续更好地执行。而且即使您只在单处理器计算机上运行该代码，PLINQ 的开销通常也很小，您甚至不会察觉出差异。除此之外，PLINQ 的数据并行特性可确保您的程序会随着数据集规模的增加而继续扩展。

在本文中，我们将回顾 PLINQ 技术的目标、该技术与更多种 .NET Framework 及其他并发产品的接合点，以及 LINQ 开发人员对该技术的看法。最后，我们将结合一些示例情景进行总结，从中可以看出 PLINQ 已经显现出巨大的价值。

请注意，包含 PLINQ 的 Parallel FX 库目前仍处于开发阶段，但是社区技术预览版 (CTP) 将在 2007 年秋季的 MSDN® 中首次推出。请阅读博客 blogs.msdn.com/somasegar 以了解详细信息。

从 LINQ 到 PLINQ

人们初次听到 PLINQ 项目时，通常会问：为什么要并行化 LINQ？简单的答案是，LINQ 具有表达计算的一次处理一个集合 (set-at-a-time) 的编程模型，侧重的是指定要实现的目标，而不是实现方式。如果没有 LINQ，实现部分通常会通过使用循环和中间数据结构来表达，但是在编码这么多特定信息的情况下，编译器和运行时就无法轻松地并行化。另一方面，LINQ 的声明性特性使 PLINQ 之类的智能化实现可以更灵活地利用并行化来取得相同的结果。

接下来必然会问到这样的问题：如果您打算查询足够的数据以证明 PLINQ 的意义，那为什么只用一个数据库？我们将以一个反问句来回答这个问题：如果不使用 LINQ，您会编写什么代码？您可能会做同样多的数据和计算密集型工作，但可能会陷入一系列杂乱无章的循环、函数调用等等。所以，这个问题说明所有程序都应驻留在数据库中，但是我相信没有多少人会同意这个观点。

有了 PLINQ，您不必将整个数据库服务器处理逻辑移到客户端的内存中“LINQ 到对象”查询。相反，PLINQ 提供了一种增量式方法，利用现有解决方案到现有问题的并行性。如果您的问题数据密集性足够高，而且传统上会要求您寻求数据库解决方案，那么您应仍然寻求这样的解决方案。PLINQ 几乎没有改变这一点。但是，如果有若干想要一起查询的数据源（可能是异类、跨平台数据库、XML 文件等），PLINQ 可在数据位于客户端后接管并并行化它们。

人们接着自然会问：“LINQ 到对象”本身为什么没有并行运行查询？并行性是完全隐藏的，开发人员无需更改任何一行代码便能获得并行性的好处。遗憾的是，当从 LINQ 迁移到 PLINQ 时可能会出现一些细微的限制，使我们无法实现此愿景，至少对 PLINQ 1.0 来说是这样的。本文稍后将更详细地讨论这些问题。

PLINQ 编程模型

使用 PLINQ 几乎就与使用“LINQ 到对象”和“LINQ 到 XML”一模一样。您可以使用 C# 3.0 和 Visual Basic® 9.0 查询理解语法或 System.Linq.Enumerable 类提供的任何运算符，包括 OrderBy、Join、Select、Where 等。PLINQ 支持所有 LINQ 运算符，而不仅仅支持那些语言理解中的运算符。此外，除了使用 System.Xml.Linq API 加载的 XML 文档外，您还可以查询任何内存中集合，如 T[], List<T>，或任何其他类型的 IEnumerable<T>。如果已经拥有大量的 LINQ 查询，则 PLINQ 有能力来运行它们。

“LINQ 到 SQL”和“LINQ 到实体”查询将仍然由各自的数据库和查询提供程序执行，因此，PLINQ 并不提供并行化这些查询的方法。如果您希望在内存中处理这些查询的结果（包括联接许多异类查询的输出），那么 PLINQ 的用处将很大。

除了用您习惯的同一方法编写 LINQ 查询之外，使用 PLINQ 还需要两个额外的步骤：

  1. 在编译期间引用 System.Concurrency.dll 程序集。
  2. 通过调用 System.Linq.ParallelEnumerable.AsParallel 扩展方法，将数据源封装到 IParallelEnumerable<T> 中。

在步骤 2 中调用 AsParallel 扩展方法可确保 C# 或 Visual Basic 编译器绑定到标准查询运算符的 System.Linq.ParallelEnumerable 版本，而非 System.Linq.Enumerable。这使得 PLINQ 能够取得控制权并并行执行查询。AsParallel 定义为接受任何 IEnumerable<T>：

public static class System.Linq.ParallelEnumerable {
    public static IParallelEnumerable<T> AsParallel<T>(
        this IEnumerable<T> source);
    ... the other standard query operators ...
}

IParallelEnumerable<T> 从 IEnumerable<T> 派生而来，但并无多大补充，它存在的目的只是为了帮助绑定到 PLINQ 的 ParallelEnumerable 查询提供程序，以利用 C# 3.0 和 Visual Basic .NET 9.0 中的新扩展方法支持。该接口派生自 IEnumerable<T>，以便您仍然可以对实例执行 foreach，并将它们传递给其他接受 IEnumerable<T> 的 API。在 ParallelEnumerable 上定义的标准查询运算符是 Enumerable 上运算符的镜像，其唯一差别在于每个运算符都取 IParallelEnumerable<T> 而不是 IEnumerable<T> 来作为其扩展源参数，然后返回 IParallelEnumerable<T> 而非 IEnumerable<T>（只返回简单类型的聚合除外），当然，还会在内部采用并行性以进行查询评估。

以 C# 中定义的一个简单 LINQ 查询为例：

IEnumerable<T> data = ...;
var q = data.Where(x => p(x)).Orderby(x => k(x)).Select(x => f(x));
foreach (var e in q) a(e);

此例中使用 PLINQ 所要求的全部条件是添加一个对数据的 AsParallel 调用：

IEnumerable<T> data = ...;
var q = data.AsParallel().Where(x => p(x)).Orderby(x => k(x)).Select(x => f(x));
foreach (var e in q) a(e);

使用 C# 查询理解语法可以更加简洁地编写此代码，此时的 PLINQ 版本如下所示：

IEnumerable<T> data = ...;
var q = from x in data.AsParallel() where p(x) orderby k(x) select f(x);
foreach (var e in q) a(e);

一旦您进行此更改后，PLINQ 将使用典型的数据并行评估技术，在所有可用处理器上透明地执行 Where、OrderBy 和 Select。PLINQ 就像 LINQ 一样采用延迟执行，这表示在对查询执行 foreach、直接调用 GetEnumerator 或通过 ToList 或 ToDictionary 之类的某些其他 API 将结果强制列入某个列表之后，该查询才会开始运行。当查询执行时，PLINQ 将通过对多线程的隐藏使用，将查询的各个部分安排在可用处理器上运行。您甚至不必了解这一切是如何完成的；您只用看到更高的性能和对可用处理器利用率的提高就可以了。

虽然不是很明显，上文所示的普通“LINQ 到对象”和 PLINQ 查询之间的 q 推断类型是不同的。在第一个示例中，q 被类型化为 IEnumerable<U>，其中 U 是 f 方法传递给 Select 运算符的任何类型。但在第二个示例中，q 被类型化为 IParallelEnumerable<U>。通常这无关紧要：例如，如果您已经将 q 的类型显式声明为 IEnumerable<U>，那么对 AsParallel 的更改将仍然有效，因为 IParallelEnumerable<U> 派生自 IEnumerable<U>。但是，这确实表示 q 的任何后续使用会被当作 IParallelEnumerable<U>。例如，如果您以后查询它，PLINQ 会被选为查询提供程序。

请注意，有些 LINQ 运算符是二元的——它们取两个 IEnumerable<T> 作为输入。Join 就是此类运算符的一个最好的例子。在这些情况下，最左边数据源的类型决定了是使用 LINQ 还是 PLINQ。因此，您只需在第一个数据源上调用 AsParallel 便能使查询并行运行。

IEnumerable<T> leftData = ..., rightData = ...;
var q = from x in leftData.AsParallel()
        join y in rightData on x.a == y.b
        select f(x, y);

所有这些讨论都假设您使用扩展方法编写查询。但是，如果您已经选择直接调用 Enumerable 类上的方法，那么将需要多做一点工作才能迁移到 PLINQ。除了调用 AsParallel 外，还必须引用 ParallelEnumerable 类型。例如，假设上述查询是通过直接调用 Enumerable 来编写的：

IEnumerable<T> data = ...;
var q = Enumerable.Select(
            Enumerable.OrderBy(
                Enumerable.Where(data, (x) => p(x)),
            (x) => k(x)),
        (x) => f(x));
foreach (var e in q) a(e);

要使用 PLINQ，必须按如下方式重新编写该查询：

IEnumerable<T> data = ...;
var q = ParallelEnumerable.Select(
            ParallelEnumerable.OrderBy(
                ParallelEnumerable.Where(data.AsParallel(), (x) =>    p(x)),
            (x) => k(x)),
        (x) => f(x));
foreach (var e in q) a(e);

显而易见，使用理解和扩展方法是更为方便的一种查询编写方法，而且还有一个额外的好处，那就是大大简化了迁移到 PLINQ 的过程。

就是这些！ 用 PLINQ 代替“LINQ 到对象”就要求这些变动。由于“LINQ 到 XML”将 XML 文档作为 IEnumerable<T> 数据结构公开，因此上述所有内容也都适用于查询 XML 内容。

处理查询输出

前面已经提到，由于延迟评估，在开始处理查询输出之前不会引入并行性。如果您熟悉 IEnumerable<T>，就该知道这相当于调用 GetEnumerator 方法。处理 PLINQ 查询的基本方法有三种，各自都会产生一种稍有差别的并行性模型。

第一种方法是管道式处理。在这种情况下，负责枚举的线程与专门运行查询的线程是分开的。PLINQ 将使用许多线程以执行查询，但是会将并行度减少一个，以使枚举线程不受干扰。例如，如果有八个处理器可用，其中七个将运行 PLINQ 查询，剩下的一个处理器便可在元素可用时对 PLINQ 查询的输出运行 foreach 循环。这样的好处是允许对输出进行更多增量处理，从而减少存放结果所需的内存要求；但是，如果有许多生产者线程而只有一个使用者线程，这通常会导致工作分配不平衡，结果是处理器效率低下。

第二种模型是准动态 (stop-and-go) 处理。在这种模型下，启动枚举的线程会联接所有其他线程以执行查询。一旦所有线程已经生成一组完整输出，该线程就会继续枚举输出。这样做的好处是所有处理能力都会集中在尽快产生输出上。此外，它的效率也比管道式处理稍有提高，因为实现过程中的增量式同步系统开销减少了：对于这些方面，PLINQ 可以更加智能化，因为它确切地知道输出数据的目的地以及访问它的方式。如果使用者和生产者之间的工作分配不平衡，这通常是一个更恰当的消耗方法。

最后一种方法是反转枚举。该方法会为并行运行的 PLINQ 提供一个 lambda 函数，为输出中的每个元素提供一次。这是最高效的一种机制，因为公开给 PLINQ 的并行性更多，而且该实现也避免了高成本的运算，例如合并多个线程的输出。但是它也存在一些缺陷：您不能简单地使用 foreach 循环，而必须使用特殊的 ForAll API，必须特别注意 lambda 不依赖共享状态。否则，引入并行性将会导致查询出错，并可能导致不可预料的崩溃或数据损坏。但是，如果您可以就此 API 表达您的问题，这始终是一个首选方法。

采用这三种模型中的哪一种取决于您将如何处理查询结果。默认的模型是第一种，即管道式处理。一旦对产生的查询枚举器调用 MoveNext 后，一组额外的工作线程会立即执行查询，并且此次调用以及所有后续 MoveNext 调用的结果（当它们可用时）都会返回。如果调用了 MoveNext，但是查询生产者线程没有任何输出，则调用线程将阻塞，直至某个元素可用。如果只用了 foreach 来处理 PLINQ 查询的输出，您将获得如下所示的结果：

var q = ... some query ...;
foreach (var e in q) {
    a(e); // this runs in parallel with the execution of 'q'
}

IParallelEnumerable<T> 接口实际上提供了 GetEnumerator 的重载，该函数接受一个 bool 参数（称为管道式），并允许您选择准动态处理来代替（true 表示管道式，false 表示准动态）。第一次后续调用 MoveNext 时，将执行整个查询，而且仅在所有输出可用时调用才会返回。后续调用 MoveNext 将只枚举包含以下输出的缓冲区：

var q = ... some query ...;
using (var e = q.GetEnumerator(false)) {
    while (e.MoveNext()) {
        // after the 1st call, the query is finished executing
        // we then just enumerate the results from an in-memory list
        a(e.Current);
    }
}

在几种特殊情况下，默认使用准动态处理：如果您使用 ToArray 或 ToList 方法，这些运算符将在内部强制执行准动态操作。如果在查询中有排序，则将转而使用准动态操作，因为将排序输出管道化纯属浪费。排序表现出极度滞后性（因为它通常需要对整个输入排序后才能生成单一输出元素），因此，PLINQ 更愿意将所有处理能力用于尽快完成排序。

要使用反转枚举，必须使用一个不同的 PLINQ 特定 API：

public static class System.Linq.ParallelEnumerable {
    public static void ForAll<T>(
        this IParallelEnumerable<T> source, Action<T> action);
    ... the other standard query operators ...
}

使用 ForAll API 与使用 foreach 循环非常相似，正如您所看到的：

var q = ... some query ...;
q.ForAll(e => a(e));

并发异常
有趣的情景

在阅读本文时， 您可能已经开始设想在自己的应用程序中如何采用 PLINQ 的一些方法。也许您目前已经在使用 LINQ，并且想要提高多处理器或多核计算机上的应用程序的可伸缩性。PLINQ 无疑可以让现有的程序运行得更快，除此之外，它还可以让您执行更多的计算工作，在相同的时间内操作更多的数据，同时能够以更快的速率处理数据流。在实现这些功能的过程中，新的 PLINQ 技术可能开启了您以前无法尝试的新应用领域。

让我们来看一些情景说明，在这些情景中，多核和 PLINQ 开启了新的领域。假设某个在录音室工作的音乐创作人想对乐器原声应用一系列效果，以制作更具美感、更让人愉悦的商业品质主音轨。为他提供混音软件的公司可以使用 PLINQ 来应用这些效果。这些效果通常都由大量数据流（原始音乐）上的过滤器和投影组成。PLINQ 可以大大加速制作时间并能利用更强大的硬件（在它出现时）。此方法甚至可以实现近乎实时的音乐转换，而不用进行完整的后期制作处理。

再来假设一个类似的情景，一名外汇交易员正在为了获利而寻找套利条件（市场无效率）。此类变化非常细微，并且会随着市场不断达到平衡而消失，所以需要快速交易。利用 PLINQ 提供的并行性来查询股票交易信息即可实现近乎实时的决策制定，大量的数据和复杂的分析与计算可提供可靠信息。

PLINQ 通过在多核硬件上提供加速以带来业务优势的途径还有很多，这些仅仅是一小部分例子而已。其他领域也提供类似的机遇，例如医疗保健、经济、地质建模、科学计算、交通控制和模拟、游戏、人工智能、机器学习、语言分析等等。

虽然上述关于 PLINQ 并行化处理过程是完全透明的说法大部分属实，但是在少数情形中，并行性的使用可能会偏离上文中介绍的简单抽象。这些就是上文中略为提及的一些限制。庆幸的是，大多数限制都很细微，但是不管怎样，您必须了解它们。

任何引发异常的 lambda 或查询运算符都会立即停止顺序 LINQ 查询的执行。那是因为，在仅使用一个处理器来运行查询时，会按顺序逐个处理元素：如果某个运算符在其中一个元素上失败，则会立即引发异常，后续元素甚至不会纳入考虑。同样的情况对 PLINQ 并非如此。

为了说明这个问题，请看下面这个（精心设计的）查询：

object[] data = new object[] { "foo", null, null, null };
var q = data.Select(x => x.ToString());
foreach (var e in q) Console.WriteLine(e);

每次用 LINQ 运行此查询时，它对第一个数组元素运行 ToString 将成功，然后在对第二个元素尝试调用 ToString 时失败，引发 NullReferenceException。第三或第四个元素永远不会得到机会。然而，当使用多处理器时，象使用 PLINQ 那样，存在并行产生多个异常的可能性。根据 PLINQ 决定细分该问题的方法，您可能会看到第 1、第 2 和第 3 个元素同时失败，或者这三个元素的任何组合，可能是第 3 个元素失败，而非第 1 或第 2 个元素。

为了解决这个问题，PLINQ 采用了与 LINQ 稍有不同的异常模型来通报失败。当某个 PLINQ 线程上产生异常时，系统首先会尝试尽快停止所有其他线程的运行。此处理过程是完全透明的。但是此过程是否能及时完成以防止其他异常的并发产生是不可知的，事实上，这些异常在 PLINQ 开始介入之时就可能已经存在。所有线程关闭后，产生的整个异常集都将被聚合到新的 System.Concurrency.MultipleFailuresException 对象中，而且将重新引发这个新的聚合异常对象。产生的每个异常以后都可通过类型 Exception[] 的 InnerExceptions 属性进行访问，包括不受干扰的堆栈跟踪。

实际上，当某个未处理的异常终止了某个查询的执行时，PLINQ 始终会引发一个 MultipleFailuresException，即使实际上只引发一个异常。在上述例子中，这意味着 PLINQ 始终会将 NullReferenceExceptions 封装到 MultipleFailuresException。如果不是的话，您必须编写多个捕获子句才能捕获某个特定类型的异常。显而易见，您通常不会捕获某些类型的异常，但是如果有这种想法，那么就必须编写下列代码并复制大量逻辑：

try
{
    // query...
}
catch (NullReferenceException)
{ ... }
catch (MultipleFailuresException)
{ ... }

这不仅是个笨办法，而且开发人员容易丢三落四，导致仅在某些情况和配置下才会产生的 bug。

很遗憾，这会增加调试难度。如果某个异常得不到处理而您又开始使用调试程序，那么您一开始将中断并进入对 GetEnumerator 的调用（如果对查询结果调用 foreach），而不是最初引发异常的地方。这类似于目前 .NET Framewor 中的异步编程模型 (BeginXX/EndXX) 中发生的情况。令人欣慰的是，PLINQ 保留了原始堆栈跟踪，因此当展开 MultipleFailuresException 对象并查看其 InnerExceptions 属性时，会找到整个异常集，并有完整的原始堆栈跟踪可供使用。

输出结果排序

假设您用 LINQ 编写了下列代码：

int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = (from x in data select x * 2).ToArray();

您能预测 data2 的内容吗？这个问题看起来如此简单，甚至根本都不需要考虑。每个人都会回答：{ 0, 2, 4, 6 }。但是，如果您按如下所示对代码稍作改动，则 data2 的内容可能会大有不同：

int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = (from x in data.AsParallel() select x * 2).ToArray();

在此例中，{ 0, 2, 4, 6 } 是非常可能的，但是 { 6, 0, 2, 4 } 也有可能，或者可能是这四个数字的任何其他排列组合。

之所以如此，是因为 PLINQ 并行运行查询，结果在产生之后即变为可用，不管是使用 foreach 遍历查询还是使用 ToArray 将结果封送到某个数组。LINQ 排序只是其实现按顺序处理输入这一事实的附带结果。与之相反，PLINQ 排序是由并行工作单元的不确定性计划确定的，这种工作单元在两次执行之间有可能发生极大的变化。

这是由 PLINQ 团队制定的一个明确的设计决策。以前，查询并不保证任何与排序相关的问题。举例来说，对于 SQL Server™，除非您在查询文本中用子句指定了一个顺序，否则排序将取决于许多因素：查询中是否使用了索引、磁盘上的记录布局等等。事实上，它也可能是不确定性的，因为 SQL Server 也可以在查询评估中使用并行性！

由于用户经常需要保留排序顺序，并且需要为尝试从 LINQ 迁移的一些用户解决一些小困难，因此，PLINQ 提供了一种明确加入以保留顺序的方法。顺序保留只是确保在没有其他排序操作介入的情况下，输出元素之间的相对顺序与输入元素之间的相对顺序紧密相关。如果要确保上述查询的输出始终为 {0, 2, 4, 6 }，那么可以使用下列查询来代替：

int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = (from x in data.AsParallel(QueryOptions.PreserveOrdering)
               select x * 2).ToArray();

顺序保留并不是没有代价的。事实上，它可能在很大程度上影响查询的性能和扩展能力。这是因为 PLINQ 将从逻辑上在末尾插入一个排序操作，而排序是一个无法随处理器数量的增加而实现完美扩展的运算符。要了解这其中的含义，上述查询在逻辑上等同于下列查询：

int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = data.AsParallel().
                  Select((x, i) => new { x, i }). // remember indices
                  Select((x) => new { x * 2, i }). // (in original)
                  OrderBy((x) => x.i). // ensure order is preserved
                  Select((x) => x.x). // get rid of indices from output
                  ToArray(); // (in original)

PLINQ 为支持顺序保留所引入的步骤是红色的。正如您所看到的，这是一项巨大的工作！ 很显然，与编写此版本的查询相比，PLINQ 实现此功能的方法要高效得多，但是步骤在逻辑上是等效的。

副作用
PLINQ 资源
  • Joe Duffy 有关 PLINQ 的博客文章
  • Channel 9 与 Anders Hejlsberg 关于 LINQ 的视频访谈
  • .NET Framework 开发人员中心的 LINQ 项目
  • LINQ 的演变及其对 C# 设计的影响，作者：Anson Horton（《MSDN 杂志》，2007 年 6 月）
  • MSDN 论坛上对的 LINQ 讨论

PLINQ 依赖于所谓的统计纯度：大多数 LINQ 查询在多数时候都不会改变数据结构或执行不纯的操作。也就是说，大多数 LINQ 查询都是严格意义上的功能性操作；它们接受某些数据作为输入、执行某些计算，并创建完全独立的数据副本，进行修改以作为输出。但是编译器或运行库并不以任何方式强制实施此类最佳实践用法。如果人们已经编写了突破此常见使用模式的查询，那么转移到 PLINQ 可能会变得更加复杂。

比方说，考虑以下的 LINQ 查询：

var q = from x in data where (x.f-- > 0) select x;

请注意，where 子句中的谓词实际上修改了某个对象的一个字段。这意味着若不公开争用状况和并发 bug，并行运行可能是不安全的。在默认情况下，您必须假设此类查询是不安全的，不可以用 PLINQ 运行。但是，它何时才会真正安全呢？只有当谓词在多个针对同一个对象的线程上运行时才会发生争用情况，而这种情况只有在数据包含重复对象时才会发生。因此，如果数据是一个集合，那就完全没有问题。

还有其他一些肯定不安全的情况，例如在使用静态变量的地方：

class C { internal static int s_cCounter; }
// elsewhere...
var q = from x in data where (x.f == C.s_cCounter++) select x;

如果并行运行此查询，您很可能会非常失望。最终的结果可能是含重复 f 字段值的许多对象，这种情况只在出现争用状况时才会发生。注意：为了解决这个问题，您最初的反应和方法可能是用 Interlocked.Increment(ref C.s_cCounter) 代替 C.s_cCounter++。尽管通过此方法会找到正确的解决方案，但是任何类型的同步都将大大降低查询的可伸缩性。作为首要原则，应努力消除查询中所有对变动性和共享状态的依赖性。

Windows® 平台是以线程为中心的，而且许多系统状态最后都可能附加到本身运行某些代码片段的线程上。可以采取的形式包括线程本地存储、诸如模拟的安全信息、COM 单元（特别是单线程单元），以及 Windows 窗体和 Windows Presentation Foundation 之类的 GUI 框架。从 LINQ 迁移至 PLINQ 时，所有这些都可能引来麻烦。

在 LINQ 模型中，查询中的所有代码都在启动该查询的同一个线程上运行，而在 PLINQ 中，查询中的代码分布在许多线程上。遗憾的是，当您已经对线程关联存在依赖关系时，这并不总是很明显，因为 .NET Framework 中的许多服务在内部都依赖这种关联，而且这种依赖是固有和透明的。我能给出的最佳建议是警惕上述并发危险源，并在您的查询中尽量避免它们。

开始执行 PLINQ

PLINQ 使 LINQ 开发人员能够利用并行硬件的优势（包括实现更佳性能和构建本质上更具可伸缩性的软件）而无需理解并发或数据并行性。编程模型很简单，使更多的开发人员能够利用更多的并行硬件。“PLINQ 资源”侧栏中列出一些参考资料，可供了解更多信息。

对于那些已经使用“LINQ 到对象”、“LINQ 到 XML”或 LINQ 将异类数据源结合在一起的用户，让现有查询使用 PLINQ 是一个简单的过程。若要进一步了解此技术的可能用法，请参阅“有趣的情景”侧栏。对于那些尚未开始采用 LINQ 的用户而言，并行加速的益处是您考虑使用它的另一个原因。即使目前 PLINQ 尚未推出，在程序中使用 LINQ 也是对未来的一项投资。

Joe Duffy 是 Microsoft Parallel FX 团队的开发主管，并定期在 www.bluebytesoftware.com/blog 上撰写博客。他目前正在编写一本书，书名是“Concurrent Programming on Windows”（Windows 上的并发编程），将由 Addison-Wesley 出版。

Ed Essey 是 Microsoft 的一名具有五年经验的资深项目经理，从事并行计算模型和基础结构方面的研究。


并行性能: 优化多核计算机的托管代码

并行性能

优化多核计算机的托管代码

Daan Leijen and Judd Hall
本文讨论:
  • 任务并行库
  • Parallel.For 与 ThreadPool 之比较
  • 静态工作分配
  • Future
本文使用了以下技术:
Parallel FX 库
目录
TPL 简介
结构化并行性
过度公开并行性
射线跟踪器示例
动态工作分配
聚合
派生-联结并行性
任务和 Future
可复制任务
任务管理器

由于单处理器速度无法再大幅提高，

多
处理器计逐渐成为标准工具。因此改善性能的关键是在多处理器上并行运行程序。遗憾的是，要编写真正充分利用那些多处理器的算法仍然相当困难。事实上，大部分的应用程序使用的只是单核，因此在多核计算机上运行时也看不出速度有任何改进。我们需要以新的方式来编写程序。

TPL 简介

任务并行库 (TPL) 的设计是为了能更简单地编写可自动使用多处理器的托管代码。使用该库，您可以非常方便地用现有序列代码表达潜在并行性，这样序列代码中公开的并行任务将会在所有可用的处理器上同时运行。通常这会大大提高速度。

TPL 是在 Microsoft® 研究院、Microsoft 公共语言运行库团队 (CLR) 和并行计算平台团队的协作努力下创建的。TPL 是 Parallel FX 库中一个主要的组件，是对 Microsoft .NET Framework 的下一代并发支持。尽管版本尚未达到 1.0，但 MSDN<SUP>®</SUP> 仍会在 07 年秋季发首次布 Parallel FX 的社区技术预览版 (CTP)。有关详细信息，请浏览 http://blogs.msdn.com/somasegar。TPL 对语言扩展没有任何要求，能与 .NET Framework 3.5 以及更高版本配合使用。

完全支持 Visual Studio® 2008，且所有的并行性都通过普通的方法调用表达。例如，假设您有以下求数组元素平方的 for 循环：

for (int i = 0; i < 100; i++) {
  a[i] = a[i]*a[i];
}

因为迭代是相互独立的（也就是说，后续迭代不会读取之前迭代进行的状态更新），因此可用 TPL 表达潜在的并行性，方法是调用并行的 for 方法，如下所示：

Parallel.For(0, 100, delegate(int i) {
  a[i] = a[i]*a[i];
});

请注意，Parallel.For 只是一个带有三个参数的普通静态方法，其中最后一个参数是一个委托表达式。此委托捕获了之前示例中完全相同的循环主体，这使得在程序中尝试引入并发操作变得非常简单。

库中包含用于动态工作分配的复杂算法，并可自动适应工作负荷和特定的计算机。同时，库的原语仅仅表达潜在的并行性，但无法对它提供保证。例如，在单处理器计算机上，并行 for 循环是顺序执行的，与严格序列代码的性能紧密匹配。然而，在双核计算机上，根据工作负荷和配置，库使用两个工作线程来并行执行该循环。这意味着您可马上在代码中引入并行性，您的应用程序将会在条件许可时自动使用多处理器。同时，代码在旧式的单处理器计算机上仍然能够很好地运行。

遗憾的是，库并不能帮助正确实现使用共享内存的并行代码的同步。仍需程序员来负责确保某些代码能够安全地并行运行。其他机制（例如锁）对于保护共享内存的并发修改仍然十分必要。TPL 确实提供了一些有助于同步的抽象（接下来我们将向您展示）。

结构化并行性

并行程序最重要的抽象之一就是并行循环。例如，考虑以下的简单矩阵乘法例程：

void SeqMatrixMult(int size, double[,] m1, double[,] m2, double[,] result)
{
  for (int i = 0; i < size; i++) {
    for (int j = 0; j < size; j++) {
      result[i, j] = 0;
      for (int k = 0; k < size; k++) {
        result[i, j] += m1[i, k] * m2[k, j];
      }
    }
  }
}

在本示例中，外部迭代是各自独立的，并且有可能并行完成。通过 TPL 来发现此潜在的并行性非常简单。首先，我们在编译期间引用 System.Concurrency.dll 程序集。接着使用 using 语句，就能把库导入到代码中：

using System.Concurrency;

一旦命名空间可用，我们即可将矩阵乘法的外部 for 循环替换为调用静态 Parallel.For 方法：

void ParMatrixMult(int size, double[,] m1, double[,] m2, double[,] result)
{
  Parallel.For( 0, size, delegate(int i) {
    for (int j = 0; j < size; j++) {
      result[i, j] = 0;
      for (int k = 0; k < size; k++) {
        result[i, j] += m1[i, k] * m2[k, j];
      }
    }
  });
}

Parallel.For 的构造是一个带有三个参数的普通静态方法。前两个参数指定了迭代的限制（在 0 和具体大小值之间）。最后一个参数是为每个迭代调用的委托功能。此委托将迭代索引当作它的第一个参数，然后执行与之前示例中完全相同的循环主体。因为委托自动捕获循环主体的自由变量（比如 result 和 m1），所以不需要对最初的循环主体进行任何更改。有关委托表达式的详细信息，请参阅 msdn.microsoft.com/msdnmag/issues/06/00/C20。

最后，如果在任何迭代中引发异常，所有的迭代都会被取消，且第一个引发的异常将在调用线程中被重新引发，以确保异常得到正确传播并不会丢失。

如果没有 TPL，要在此循环中表达潜在的并行性就会困难得多。即使在 NET ThreadPool 类的帮助下，我们仍然必须考虑同步和工作划分的成本。图 1 显示了使用线程池达成并行的矩阵乘法例程。

Figure 1 静态工作分配和显式同步 (Close Figure 1)
void ThreadpoolMatrixMult(int size, double[,] m1, double[,] m2,
    double[,] result)
{
  int N = size;
  int P = 2 * Environment.ProcessorCount; // assume twice the procs for
                                          // good work distribution
  int Chunk = N / P;                      // size of a work chunk
  AutoResetEvent signal = new AutoResetEvent(false);
  int counter = P;                        // use a counter to reduce
                                          // kernel transitions
  for (int c = 0; c < P; c++) {           // for each chunk
    ThreadPool.QueueUserWorkItem(delegate(Object o)
    {
      int lc = (int)o;
      for (int i = lc * Chunk;           // iterate through a work chunk
           i < (lc + 1 == P ? N : (lc + 1) * Chunk); // respect upper
                                                     // bound
           i++) {
        // original inner loop body
        for (int j = 0; j < size; j++) {
          result[i, j] = 0;
          for (int k = 0; k < size; k++) {
            result[i, j] += m1[i, k] * m2[k, j];
          }
        }
      }
      if (Interlocked.Decrement(ref counter) == 0) { // use efficient
                                                     // interlocked
                                                     // instructions
        signal.Set();  // and kernel transition only when done
      }
    }, c);
  }
  signal.WaitOne();
}

这个示例已相当先进，它将线程池用作工作项目，并使用计数器和一个等待句柄来最小化内核切换的次数。另外，它根据处理器的数目将循环静态划分成多个块，创建量相当于必需数量的两倍，从而更好地适应动态工作负荷。然而，与 Parallel.For 不同，图 1 中所示的方法没有在循环主体中传播异常，因此无法取消。

显然，此代码比起 Parallel.For 方法要难编写得多，也更容易出错。而且，尽管经过手工调整并使用几乎完美的工作划分，一般来说线程池方法还是比 Parallel.For 方法要逊色。图 2 显示了一些有趣的测试。结果代表了当对具有 750x750 个元素的矩阵乘法的外部循环实现并行时获得的相对加速度，1 代表普通 for 循环的运行时间。这些测试是在一台四套接字、3GB 内存并运行 Windows Vista® Ultimate 的双核计算机上进行的。请注意，在单核计算机上 Parallel.For 版本的实际执行过程与for 正循环相同。

图 2 Parallel.For 与 ThreadPool 性能比较
图 2 Parallel.For 与 ThreadPool 性能比较

过度公开并行性

也许您已经注意到了，通过并行化第二个 for 循环，我们可以公开更多的并行性，如下所示：

Parallel.For( 0, size, delegate(int i) {
  Parallel.For( 0, size, delegate(int j) {
    result[i, j] = 0;
    for (int k = 0; k < size; k++) {
      result[i, j] += m1[i, k] * m2[k, j];
    }
  });
});

即使可以嵌套并行循环，此方法的性能一般会差一些，原因有两个。首先，在此特定的示例中，外部循环已经公开了太多的并行可能性，因为通常比起矩阵的大小，我们拥有的内核要少得多。其次，每个委托表达式都分配了一些内存来存放自由变量。这就是在我们最初的示例中只有一个分配，它的成本通过迭代得到分摊的原因。遗憾的是，在新的代码中，内部 Parallel.For 会在外部循环的每个迭代中执行堆分配。分配在 CLR 中非常有效，但与每个迭代中完成的工作量相比，仍是一个非常显著的成本。

请注意，由于那些迭代不是独立的，因此您不能并行内部循环。特别是，因为每个迭代都添加至了 result[i,j] 位置，所以存在争用现象。如果您并行此循环，两个迭代会同时将当前值读入寄存器，对它执行加法，然后写回结果，结果就是丢失一次加法！ 并行内部循环的唯一方法就是用锁来正确地保护加法。当然，我们并不建议真正这么做：即使您暂时忽略额外的分配，因为每个并发迭代都争用同一个锁，因此性能会遭受严重影响。稍后我们会在讨论聚合操作时回到这个话题。有关争用和锁的更多信息，请参阅 msdn.microsoft.com/msdnmag/issues/05/08/Concurrency/default.aspx.

射线跟踪器示例

射线跟踪器是一种简单但功能强大的方法，可生成图像逼真的呈现。但是该技术要求进行大量的计算。对于我们的库来说，射线跟踪器实际具备非常优秀的应用条件，因为每个射线都能并行计算。我们选择了一个现有的射线跟踪器，由 Luke Hoban 编写（请参阅 blogs.msdn.com/lukeh/archive/2007/04/03/a-ray-tracer-in-c-3-0.aspx），并对其进行了修改，以便用 TPL 并行运行。射线跟踪器会生成如图 3 中所示的图像，并且在 Parallel FX CTP 中用作示例。原来的射线跟踪器的核心循环会遍历最终图像的所有像素：

图 3 并行射线跟踪器
图 3 并行射线跟踪器(单击该图像获得较小视图)
图 3 并行射线跟踪器
图 3 并行射线跟踪器 (单击该图像获得较大视图)

void Render(Scene scene, Color[,] rgb)
{
  for (int y = 0; y < screenHeight; y++)
  {
    for (int x = 0; x < screenWidth; x++) {
      rgb[x,y] = TraceRay(new Ray(scene,x,y));
    }
  }
}

因为每条射线能被独立跟踪，我们只需要改变一行原始代码即可实现并行：

void Render(Scene scene, Color[,] rgb)
{
  Parallel.For(0, screenHeight, delegate(int y)
  {
    for (int x = 0; x < screenWidth; x++) {
      rgb[x,y] = TraceRay(new Ray(scene,x,y));
    }
  });
}

在一台八核计算机上，原来的代码每秒能生成 1.7 帧 350 x 350 像素的图像。比较一下，在同一八处理器计算机上运行的并行版本每秒能生成 12 帧。这在八处理器的计算机上是 7 倍的加速，对于如此小的更改而言是相当好的结果。每秒 12 帧的速度足够制作一个在地板上跳动的球的流畅动画。而且因为这是一个非常简单的射线跟踪器，您可以进一步优化它，以获得更为流畅的动画。

动态工作分配

在使用线程池手动并行化循环时，开发人员通常最终做的就是静态划分工作。例如，在射线跟踪器中，图像通常会被平均分成多个部分，每个部分由单个线程进行处理。一般来说，因为实际工作负荷的分配也许不平均，所以这并不是一个好主意。例如，由于反射的原因，图像下部的计算时间要长两倍，那么处理图像上部的线程大部分时间都是在等待下部线程完成。即使工作是平均分配的，由于页面错误或系统上并发运行的其他进程，这样的问题也仍会发生。

为了很好地扩展到多个处理器，TPL 使用工作窃取技术将工作项目动态地适应和分配到工作线程上。库有一个任务管理器，默认情况下它会对每个处理器使用一个工作线程。这确保了 OS 执行的线程切换次数最少。每个工作线程有其各自等待完成的本地任务队列。每个工作线程通常只是把新任务推入队列中，并在任务完成时弹出工作。当其本地队列为空时，工作线程自己会寻找工作，尝试从其他工作线程的队列中“窃取”工作。

这里的优势在于工作线程之间几乎没有同步，因为工作队列是经过分配的，而且大多数操作对工作线程来说都是局部的，这对可伸缩性而言至关重要。此外，工作窃取具有已证实的良好缓存区域和工作分配属性。例如，如果工作负荷不平均，某个工作线程可能需要很长时间来完成某个特定任务，但是其他工作线程现在将从它的队列中窃取工作，保证所有处理器都处于忙碌状态。动态工作分配在典型的应用程序中至关重要，原因是很难预期某个任务需要多长时间才能完成。对于桌面系统（其中多个不同进程共享多个处理器，而且我们无法预期工作线程将得到的时间片）而言，情况尤其如此。

图 4 演示了正在使用四个线程的动态工作分配。它显示的射线跟踪器图像与图 3 中的相同，但这次每个工作线程使用不同的颜色来呈现其像素。您能看到，库在工作线程之间平均地分配工作，以动态适应工作负荷。

图 4 在工作线程之间分配工作
图 4 在工作线程之间分配工作(单击该图像获得较小视图)
图 4 在工作线程之间分配工作
图 4 在工作线程之间分配工作 (单击该图像获得较大视图)

除了执行动态工作分配，库还能动态调整工作线程数量（如果工作线程被阻塞）。一些阻塞操作的例子是文件读取、等待按键以及检索用户名（因为这需要访问域上的网络）。如果某个任务在原因不明的情况下阻塞，性能会随着并发级别的降低而下降（但是程序仍然正常运行）。为了改善性能，库会自动跟踪以检测工作线程是否被阻塞，并在需要时插入额外的工作线程来保持并发级别。一旦操作解除阻塞，一些工作线程可能会注销，以便减少线程切换的成本。

聚合

一个 for 循环通常用于遍历某个域并将值聚合为单个结果。以下面将小于 100 的质数累加的迭代为例：

int sum = 0;
for(int i = 0; i < 100; i++) {
  if (isPrime(i)) sum += i;
}

遗憾的是，我们无法按原样并行此循环，因为并行此循环会导致数据争用。每次迭代都在没有锁保护的情况下修改共享的 sum 变量. 如果两个并发迭代同时增加 sum，两者都有可能读取寄存器中的同一值、对其执行加法并写回各自的结果，结果就是丢失一次加法。正确的版本会使用锁来保护加法，如下所示：

int sum = 0;
Parallel.For(0, 100, delegate(int i) {
  if (isPrime(i)) {
    lock (this) { sum += i; }
  }
});

然而，因为所有的并行迭代都同时争用同一锁和同一内存区域 (sum)，程序现在又将遭遇性能问题。如果每个工作线程能够维持一个线程的局部 sum，而且仅在循环结束时将其增加到全局 sum，情况会得到改善。此模式由 Paral- lel.Aggregate 操作实现，这样我们将该示例重新编写为：

int sum = Parallel.Aggregate(0, 100, // iteration domain
              0,                     // initial value
              delegate(int i) { return (isPrime(i) ? i : 0) }, // apply
                                                      // on each element
              delegate(int x, int y) { return x+y; }  // combine results
          );

聚合操作有五个参数。前两个参数指定了迭代的域，该域也可以是枚举器。下一个参数是结果的初始值。接下来的两个参数是委托函数。第一个函数应用于每个元素，另一个用于组合元素结果。

库自动使用线程局部变量来计算线程局部结果（无任何锁定），仅在组合最终线程局部结果的时候使用一个锁。请记住，如果聚合是并行完成的，元素就有可能以不同于顺序聚合的次序进行组合。因此，组合委托函数必须是关联的，而且初始值必须是单位元素。

派生-联结并行性

另一种常见的并行模式是派生-联结并行性。作为示例，请考虑下面的顺序快速排序实现：

static void SeqQuickSort<T>(T[] domain, int lo, int hi) where T : IComparable<T>
{
  if (hi - lo <= Threshold) InsertionSort(domain, lo, hi);

  int pivot = Partition(domain, lo, hi);
  SeqQuickSort(domain, lo, pivot - 1);
  SeqQuickSort(domain, pivot + 1, hi);
}

该算法对元素类型 T 是通用的，只需要 T 实例能够比较即可。在一定的阈值下，算法回到插入排序，在元素数量较少时执行效果更佳。否则，我们将输入数组分成两部分，对各部分分别快速排序。因为每个排序都作用于数组的不同部分，因此两个排序可以并行执行。通过 Parallel.Do 方法可以方便地表达这种情形：

static void ParQuickSort<T>(T[] domain, int lo, int hi) where T : IComparable<T>
{
  if (hi - lo <= Threshold) InsertionSort(domain, lo, hi);

  int pivot = Partition(domain, lo, hi);
  Parallel.Do(
    delegate { ParQuickSort(domain, lo, pivot - 1); },
    delegate { ParQuickSort(domain, pivot + 1, hi); }
  );
}

Parallel.Do 方法是一个静态方法，把两个或更多委托当作参数并潜在地并行执行它们。因为快速排序是递归的，而且每次调用引入了许多并行任务，因此大多数并行性都得以公开。再次重申，因为库不能确保并行执行，大部分任务实际上是顺序执行的，这对于确保良好的性能是非常关键的。

任务和 Future

之前的示例都演示了结构化并行性，其中并行代码的范围是由词汇范围决定的。但并不是所有的并行算法都能以此方式来表达。庆幸的是，库对于普通并行任务也提供了支持：

class Task
{
  Task( Action action );

  void Wait();
  void Cancel();
  bool IsCompleted { get; }
  ...
}

在创建任务时，对其提供一个有可能并行执行的关联操作。该操作将在任务创建时至第一次调用 Wait 方法之间执行。关联操作可在另一个线程上并行执行，但是可以保证该操作不会在线程间迁移。这个保证非常有用，因为程序员能使用类似于 Windows 关键部分的线程仿射抽象，而不必担心（比方说）执行 LeaveCriticalSection 的线程与 EnterCriticalSection 有所不同。如果任务已经完成，Wait 会立即返回。

在关联操作中引发的任何异常都会存储在任务中，任何时候调用 Wait 时会再次引发。与之类似，Parallel.For 和 Parallel.Do 函数也累积引发的所有异常，并在所有任务完成时重新引发。这确保异常不会丢失，并能正确传播给相关者。

最后，通过调用 Cancel 可以取消任务以及在其关联的操作中创建的所有任务（子任务）。取消并不是强占式的，运行中的任务必须通过回调库配合退出工作。例如，这可以通过创建新任务或调用 Wait 方法来完成。如果父任务已经取消，这些库调用会引发一个（同步）OperationCanceled 异常来停止操作。

您可以将任务看成一个经过改进的线程池，其中工作项目返回一个可取消或等待的句柄，而且异常可得到传播。还有一种任务的变体，称为 future，其中关联的操作会计算某个结果：

class Task<T> : Task
{
  Task ( Func<T> function );
  T Value { get; }            // does an implicit wait
}

future（也就是计算某个结果的任务）不是通过普通操作构造的，而是通过返回结果的操作构造的。结果是一个 Func<T> 类型的委托，其中 T 是 future 值的类型。

future 的结果可通过 Value 属性进行检索。Value 属性在内部调用 Wait 以确保任务已经完成，而且结果值已经完成计算。由于 Wait 已调用，调用 Value 会引发在计算值的时候引发的异常。看待这个问题的一个方法是把 future 当成有一个值或者异常值（由计算确定）。

Future 是一个旧的概念，在 multi-lisp 中已经实现。请注意，尽管我们对于 future 的概念从某种意义上是不“安全”的，也就是说程序员对正确锁定共享内存负有责任。这与将 future 的操作自动封装进内存事务的方法形成对比。

future 抽象适用于结构化程度比循环更低的符号代码。例如，考虑以下关于二进制树节点和叶的定义：

class Node : Tree {
  int   depth;  // The depth of the tree
  Tree  left;   // The left sub tree
  Tree  right;  // The right sub tree
  ...
}

class Leaf : Tree {
  int value;  // values are stored in the leafs
  ...
}

现在假定我们在 Tree 上定义一个虚拟的 Sum 方法，它累加所有叶的值。一个叶仅仅返回其值。节点将其子树的和相加：

override int Sum() {
   int l = left.Sum();
   int r = right.Sum();
   return (r + l);
}

在此情形中，每个子计算能并行完成，因为它们是独立的。在这里并行性受词汇范围的限制，我们能使用 Parallel.Do，但是为了演示目的，我们使用 future：

override int Sum()
{
   Task<int> l = new Task<int>( left.Sum );
   int r       = right.Sum();
   return (r + l.Value);
}

对于每个左边的子树，我们创建一个类型为 int 的新 future，并传递委托作为构造函数的参数。在本例中，我们传递了左边子树的累加方法 left.Sum，但没有调用它。我们继续计算右边子树的和。通过创建 future，其他处理器便有可能开始并行求左边子树的和。最后，我们使用 Value 属性请求 future 的值。

如果任务已经由另一个工作线程计算完成，此调用会立即返回结果，很好！ 如果任务仍然在另一个线程上运行，我们便会阻止，直到结果出现为止（但是已安排另一个线程维护并发级别）。

还有一个非常普遍的情形就是任务根本没有启动。在这种情况下，调用 Value 会直接在调用线程上执行任务。因为我们只是发出了一个间接的方法调用，所以这是常见的，而且非常有效。相比较而言，当我们等待 OS 提供的信号时，我们不能迫使信号产生，唯一能做的是阻止调用线程，一般来说它会对性能产生负面影响。在我们的情形中，很清楚的一点是如何计算值，并且库直接执行关联的操作而不是阻塞线程。

因为在叶中完成的工作量非常小，所以在一定的树深度下顺序执行累加来增加每个任务的工作量可能会更好。例如，我们假设一个顺序累加方法 SeqSum，我们可以这样编写：

override int Sum()
{
   if (depth < 10) return SeqSum();

   Task<int> l = new Task<int>( left.Sum );
   int r         = right.Sum();
   return (r + l.Value);
}

总的来说，确定正确的阈值界限取决于任务执行过程中完成的工作量相对于分配任务对象的成本。以我们的经验来看，分配的开销很低，阈值的界限通常在 100 个浮点乘法左右。

因为 future 是真正的一级值，因此 future 可以用来在逻辑上互异的程序部分之间引入并行性。例如，future 可以存储在数据结构中，而其他独立的阶段实际上会请求这些 future 的值。对此适用的一个地方是游戏应用程序。例如，一个阶段可以计算所有字符的健康作为 future，而另一个阶段稍后会使用那些健康 future 的值。在一台多核计算机上，通过每个阶段完成的工作，这些 future 可以并行计算。

可复制任务

库仅仅构建在两个原语概念上：任务和可复制任务。所有其他的抽象（例如 future 和并行 for 循环）都是依据这两个原语来表达的。这确保了操作能够以一致的语义按常规方法运行。例如，异常总是被很好地传播，而且所有抽象都可以取消（包括并行 for 循环）。

请注意，可复制任务实际上是为希望扩展 TPL 提供的标准抽象的库写入程序准备的，在普通代码中应该谨慎使用（如果确实需要）。可复制任务从普通任务直接派生，如下所示：

class ReplicableTask : Task
{
  ReplicableTask( Action action );
}

可复制任务代表一种自身可被多个线程同时执行的任务，并且可捕获无所不在的“全部适用”的并发模式，同时又从工作分配的动态中抽象出来。构造函数采用操作委托，它有可能在另一线程上并行执行，并且有可能同时由多个线程执行。如果在任何执行过程中引发异常，只有其中的一个会被存储并由 Wait 再次引发。

如果其他线程有可能参与执行工作，就可以使用可复制任务。所有的 Parallel.For 和 Parallel.ForEach 变体都是使用可复制任务实现的。例如，基本的 Parallel.For 可以简单地实现为：

static void For( int from, int to, Action<int> body )
{
  int index = from;
  ReplicableTask rtask = new ReplicableTask( delegate {
    int i;
    while ((i = Interlocked.Increment(ref index)-1) < to) {
      body(i);
    }
  });
  rtask.Wait();
}

因为所有可复制任务都共享索引变量，所以传递给可复制任务构造函数的实际操作委托可根据需要由任何数量的线程来执行。在实现中，这将允许空闲的处理器参与到工作中。

在此实现中，每个工作线程一次声明一个索引。这符合 OpenMP 的 dynamic(1) 策略，当每个索引的工作负荷可以有显著差异时非常有效。（有关详细信息，请参阅 msdn.microsoft.com/msdnmag/issues/05/10/OpenMP。） 但是此策略会在每个索引的工作负荷很小时导致缓存争用。在这种情况下，最好一次处理一个索引跨距。请看此 Parallel.For 的变体，它对应于 OpenMP 的 dynamic(n) 策略，且把跨距当作一个参数：

static void ForWithStride( int stride, int from, int to, Action<int> body )
{
  int index = from;
  if (stride <= 0) stride = 1;
  ReplicableTask rtask = new ReplicableTask( delegate {
    int i;
    while ((i = Interlocked.Add(ref index,stride)-stride) < to) {
      int end = Math.Min(i+stride, to);
      do {
        body(i);
        i++;
      }
      while (i < end)
    }
  });
  rtask.Wait();
}

可复制任务对于实现不同的并行迭代策略是一个强有力的抽象。然而，以我们的经验来看，标准的 Parallel.For 和 Foreach 实现在很多情形下都非常有效，而且我们不希望在实际操作中对可复制任务的额外表现力有太多的需求。

任务管理器

所有的任务都属于任务管理器，正如其名字显示，任务管理器管理任务并监督工作线程对任务的执行。虽然始终有一个默认的任务管理器可用，应用程序也可以显式创建任务管理器。任务管理器接口定义为：

class TaskManager : IDisposable
{
  TaskManager();
  TaskManager( int maxConcurrentThreads );

  static TaskManager Current     { get; }
  int    MaxConcurrentThreads    { get; }
  ...
}

class Task
{
  Task( TaskManager taskManager, Action action )
  ...
}

任务管理器关联有一个并发级别，该级别由 MaxConcurrentThreads 属性返回。这向管理器指定了在任何给定时间执行任务的理想线程数。这是一个提示，所以如果管理器需要更多的线程来推进进度，它会动态执行。在创建任务管理器时，您可以明确提供此数量，默认情况下它等于处理器的数量。

一般来说，因为默认的管理器始终可用，所以显示创建任务管理器永远都不是必需的。然而，您可能想要使用多个任务管理器，每个都有不同的并发级别，或者每个处理一组不同的任务。在这种情况下，您可以创建新的任务管理器并使用特定的 Task 构造函数，该构造函数把任务管理器当作其第一个参数，执行该任务及其在该任务管理器中的所有子任务。以下列代码为例：

using (TaskManager tm = new TaskManager(2)) {  // only use 2 worker
                                               // threads for tasks
  new Task( tm, delegate {
    // tasks created in this delegate use the tm task manager by default
    ...
    // finally, show some statistics
    Console.WriteLine( "statistics: " + tm );
  }).Wait();
}

任务管理器接口的另一个重要用处是使用单个工作线程顺序运行所有代码。这意味着所有任务和并行 for 循环都顺序执行。这对于调试非常有用，您可以检验顺序执行时代码功能是否正确，然后才在多处理器计算机上并行运行它。

Daan Leijen 是 Microsoft 研究院的一位研究员。他目前的工作重点是声明性和函数式编程、类型推断系统以及并行计算。

Judd Hall 是 Microsoft 的一位项目经理 II。


移动应用程序: 调整铃声音量，避免环境噪音

移动应用程序

调整铃声音量，避免环境噪音

Chris Mitchell
本文讨论:
  • Pocket PC 开发
  • 测量响度
  • 自定义安装程序
  • 使用 P/Invoke
本文使用了以下技术:
.NET Compact Framework
代码下载位置: NoiseDetection2007_10.exe (193KB)
目录
解决方法
参考值
实现
如何处理安装
结束语

在
某个阳光明媚的午后，您坐在当地一家咖啡馆里，心情平和地享受着宁静的时光，这时，您的电话响了。而且它被设置成最大音量，铃声如此之响，差点让您把咖啡打翻。所以您将声音调小了。

那天晚些时候，您在酒吧等一位朋友，但是他迟迟不来。实际上，他是忘记你们要见面的酒吧了，并打电话询问您在什么地方。不幸的是，您没有接到电话，因为您没有听见电话铃声。原来：铃声音量仍然处在之前调得很小的状态，而在嘈杂的酒吧里面根本听不见这么小的铃声。

如果您认为现在的移动电话处理功能可以与我用于多声道音频录音的第一台 PC 相媲美，那么这将是非常可笑的想法。如今的智能移动设备看起来拥有诸多功能，能访问诸多信息，因此这种问题不应该存在。但是，拥有移动电话的人都或多或少遇到过这种问题。

要解决该问题，显而易见的方法是记得更改音量设置。更改铃声音量的一个方案可能是区别对待不同环境中的声音差异，但实际功能并没有做到这一点。这些设备的反应能力应该多结合一点常识。其实很简单：我只要我的移动电话在安静的环境中不要发出太响的铃声，在嘈杂的地方又能够让我听得见铃声。

本文将探讨 Windows Mobile® 应用程序根据其当前环境中的环境噪音自动调整 Pocket PC 的铃声级别的可能性和实用性。此应用程序的功能将防止电话铃声太响或太轻，从而避免我陷入尴尬的境地和错过电话。在本文中，我将从此问题切入，着手介绍使用 Visual Studio® 2005 和 C# 进行 Windows Mobile 开发。

电话铃声太响使您陷入尴尬？

解决方法

办法是利用移动电话的传感器获取对当前环境的更好了解。我想使用麦克风来测量环境噪音，然后相应地调整铃声水平。

我需要解决一系列具体问题才能使此方法行得通，如图 1 所示。我如何从内置于该设备的麦克风中获取音频采样呢？我该使用什么度量来定义响度？我该如何更改移动设备的铃声水平对这一响度作出反应？我将马上讨论此系统的第一部分，即从麦克风中收集环境信息。但是首先，我需要定义响度的度量。

图 1 调整铃声音量的高级步骤
图 1 调整铃声音量的高级步骤

响度是主观性的，通常由等响度图表表示。（有关声音主观感知的详细信息，我建议您查询一些有关心理声学研究的文章。） 这些图的一个轴上是频率，另一个轴上是声压度量，通常以分贝 (dB) 为单位。这些图表显示了频率和人们对等响度的感知之间的关系。

为了确定关系值，科学研究向参与者提供了很多音调，这些音调按不同频率，以 10dB 递增。研究参与者被要求判断何时声音具有与 1KHz 参考信号相比的等响度。然后，这些关系被用来构造表格和绘制等响度图表，它们与从电话麦克风中获取的频率信息一起，可用于创建响度的度量。

另一个方法是将声压水平的度量与参考值进行比较，并在发生重大变化时调整铃声水平。然后可以通过电话的配置文件来定义构成变化的条件。该方法克服了不同电话上不同麦克风和扬声器之间存在差异这个问题。只需更改参考值即可使软件能够针对各种电话型号有效地操作。这还意味着系统不必从麦克风中获取频率信息，从而节省宝贵的电池电量。

系统中的设计复杂性通过频率分解和 dB 测量的冗余得到了简化。但是，我的应用程序必须使用相对响度的度量。在音频信号处理中，可以使用均方根 (RMS) 作为响度的基本度量。（我将在说明实现阶段时详细介绍 RMS。） 尽管频率属性仍然影响着感知到的响度，但是对于准确性需求来说，RMS 值应该已经足够了——鉴于 Pocket PC 只有五个音量设置，那更是如此。RMS 函数可用于获取调整铃声音量所必需的值。

参考值

一旦从麦克风中获取采样并输入到 RMS 函数，输出必须映射到五个音量状态之一。什么是映射函数？这些设置如何与上文讨论的参考值关联起来？参考值对于每个设备都是特定的，它们用于定义何时移动电话铃声音量发生变化。例如，当 RMS 函数指出环境类似于某个咖啡馆或嘈杂的酒吧时，铃声水平应进行相应调整。

如果我打算扩展此应用程序，以支持多个设备的配置文件，我将提供一个界面以设置该应用程序。但是在本文中，我并未打算实现此类界面，让其他人来做吧。

为某个设备配置文件生成参考值的过程并不是很复杂。首先，使用应用程序的 RMS 报告功能获取适合您需要的多种参考情况下的参考值。我使用的是四种常见环境：晚间的住宅区 (45dB)、安静的室内餐厅或办公室 (55dB)、普通的室内餐厅 (65dB) 及约 16 英尺远的繁忙的交通区 (75dB)。这些值将是映射到 Pocket PC 上五个音量状态的参考。（很明显，我们需要一种工具来获取这些地点的各种 RMS 值。）

接着，在您得到所有这些地点的平均值后，您将使用这些值作为应用程序代码中的上限和下限。

实现

由于 Visual C#® 2005 具有集成的移动开发平台，因此我选择它来构建我的解决方案。虽然仍旧没有用于创建桌面安装程序（可通过桌面部署 Pocket PC 应用程序）的内置向导，但这已经大大简化了 Pocket PC 的开发。市面上有一些优秀的免费工具可充当安装程序，也有一些方法可供您创建自定义的安装程序。（我将在本文稍后讨论如何构建自定义安装程序。）

现在，让我们回到应用程序，我会将它命名为 TooLoud。创建这个应用程序须由三个部分组成：

  1. 捕获和处理音频，我将使用 Microsoft® .NET Compact Framework 的 P/Invoke 服务。
  2. 我将编写一个函数来计算 RMS 值。
  3. 如果给定了平均 RMS 值，我将编写一个函数，使用 Pocket PC 上的注册表来访问和设置音量。

您可以使用许多库和 ActiveX® 控件来访问麦克风，以记录 Pocket PC 设备的音频。我的示例构建在 P/Invoke 库（请参阅 msdn2.microsoft.com/aa446550.aspx）之上，该库包含用于访问设备麦克风的 WaveIn 类。此类包含一个结构，能够以设定的速率和位分辨率存储采样结果。P/Invoke 库还提供名为 Wave 的帮助器类，这是到 .wav 音频格式的一个通用接口。上述链接提供了在原始 WaveIn 和 Wave 类中所有已实现类和函数的列表。

在我的解决方案中，P/Invoke 库的版本仅包含 WaveIn、Memory 和 Registry 类。为了简洁起见，所有其他类均已删除。图 2 中所示的库已通过提供额外函数而得到了扩展：一个函数位于 WaveIn (TooLoud)—AutoSetVolume—另一个函数位于 Wave—CalcRMS。

图 2 与 TooLoud 类关系图一同显示的部分 P/Invoke 库
图 2 与 TooLoud 类关系图一同显示的部分 P/Invoke 库(单击该图像获得较小视图)
图 2 与 TooLoud 类关系图一同显示的部分 P/Invoke 库
图 2 与 TooLoud 类关系图一同显示的部分 P/Invoke 库 (单击该图像获得较大视图)

应用程序运行 AutoSetVolume，后者调用 CalcRMS 作为其过程的一部分以设置音量。TestProc 可重复调用，或只调用一次，具体取决于用户界面中选择的模式。TestProc 调用六个函数：

  • AutoSetVolume 用于实现最终目标。
  • NumDevices 用于获取输入设备的数量。
  • GetDeviceName 用于获取指定录音设备的名称。
  • Preload 为给定大小和最大的音频数量（以毫秒计）预加载缓冲区。（Start 用于初始化录音时间，目前设置为一秒，Stop 用于结束当前录音。）
  • AutoSetVolume 用于计算新的音量设置。
  • Dispose 用于清除缓冲区。

Preload 将设置 waveformatex 的实例、用于说明数据的标头、脉冲编码调制 (PCM) 格式标签、8 位采样分辨率、单一声道和 11025 Hz 的采样率。

既然我能获取一段时间的音频数据块，那么我可以使用此数据来计算 RMS 值。均方根的计算见图 3 中的定义。

图 3 响度计算的一部分
图 3 响度计算的一部分(单击该图像获得较小视图)
图 3 响度计算的一部分
图 3 响度计算的一部分 (单击该图像获得较大视图)

图 4 中所示的相应代码现在可用于定义使用从麦克风中获取的采样值来计算 RMS 值的方法。由于采样值以字节格式 (0-255) 存储，因此，减去中间值 127.5 可提供 -127.5 到 +127.5 的规范化音频信号。

Figure 4 RMS 计算 (Close Figure 4)
public double CalcRMS(MainTest.DisplayLineDelegate ShowLine)
{
    byte[] data = new byte[dwBytesRecorded];
    Marshal.Copy(lpData, data, 0, data.Length);
    double temp = 0, sum = 0;
    int j = 0;

    ShowLine(data.Length.ToString());

    for (int i = 0; i < data.Length; i++)
    {
        temp = (double)data[i] - 127.50;
        sum = sum + (temp * temp);
        j++;
    }
    return Math.Sqrt(sum / j);
 }

映射函数取 RMS 值并定义将用于设置 Pocket PC 当前铃声水平的值。虽然完成它需要更多的经验，但是移动电话功能的简单配置文件对演示就足够了。

正如我先前所述，此技术的缺点在于，扬声器和麦克风功能有显著差异的每种电话型号都将可能需要一个配置文件。解决这个问题的办法是通过创建配置界面，让用户设置适合各种地点的参考值，或者可以提供一个网站，在上面提供各种配置文件。在本文中，我将仅介绍输入一组参考值的方法。然而，怀着这个目的，我将向您介绍如何获取映射值，以便可以为任何电话的配置文件复制它们。

RMS 值是通过录制许多与特定 dB 水平关联的常见声音环境而生成的。理论上，这项录制工作须经过很长一段时间才能完成（例如，有人在咖啡馆掉了一个盘子，而此时您正在采样），而且它应包括您认为可能需要接电话的普通环境。然后，可以将每种环境的平均读数用作音量设置的上限和下限。

一旦参考值计算好，就必须即刻创建自适应配置文件。这只是定义了移动电话将如何基于不同的值来自调铃声水平。您可以考虑许多其他策略。例如，如果电话处于一个非常吵闹的环境中，您甚至可以开启振动。对于我的演示，我将仅用参考值将麦克风输入映射到五个音量。

可以使用注册表来设置 Pocket PC 的铃声水平。注册表值位于 ControlPanel\SoundCategories\Ring 下的 HKEY_CURRENT_USER，要更改的值是 InitVol，该值范围是 0 到 5。

图 5 中的代码使用标准注册表写函数来设置铃声音量值。给定了上限和下限后，映射可以确定铃声的适当值，具体实现为若干个条件语句。

Figure 5 设置当前铃声水平 (Close Figure 5)
public Wave.MMSYSERR AutoSetVolume(MainTest.DisplayLineDelegate ShowLine)
{
    double AverageRMS = 0.00;
    if (!m_inited)xreturn Wave.MMSYSERR.ERROR;

    if (m_recording) Stop();

    try
    {
        // Write the data recorded to each buffer.
        // Get RMS value for each block and take the average
        // across all blocks.
        for (int i = 0; i < m_numBlocks; i++)
        {
            if (m_whdr[i] != null)
            {
                AverageRMS = AverageRMS + m_whdr[i].CalcRMS(ShowLine);
            }
        }

        AverageRMS = AverageRMS / m_numBlocks;
        ShowLine("Average RMS: " + AverageRMS);

        // Select volume based on reference values.
        uint Volume = 0;
        if (AverageRMS > 35.00)
        {
            //Range of this value for the volume control is from
            //0x0000 (min) non to 0x0005 (max).
            Volume = 5;
        }
        else if ((AverageRMS <= 35.00) && (AverageRMS > 30.00))
        {
            //Range of this value for the volume control is from
            //0x0000 (min) non to 0x0005 (max).
            Volume = 4;
        }
        else if ((AverageRMS <= 30.00) && (AverageRMS > 20.00))
        {
            //Range of this value for the volume control is from
            //0x0000 (min) non to 0x0005 (max).
            Volume = 3;
        }
        else if ((AverageRMS <= 20.00) && (AverageRMS > 10.00))
        {
            //Range of this value for the volume control is from
            //0x0000 (min) non to 0x0005 (max).
            Volume = 2;
        }
        else
        {
           //Range of this value for the volume control is from
           //0x0000 (min) non to 0x0005 (max).
            Volume = 1;
        }

        ShowLine("Set Volume to: " + Volume.ToString());
        PInvokeLibrary.Registry.CreateValueDWORD(
            "ControlPanel\\SoundCategories\\Ring", "InitVol",
            Volume);

        return Wave.MMSYSERR.NOERROR;
    }
    catch { return Wave.MMSYSERR.ERROR; }
    finally { FreeWaveBuffers(); }
}

如何处理安装

现在我已经创建了 TooLoud 应用程序代码，要为 Pocket PC 创建安装程序，我必须从若干不同选项中作出选择。如果您是急性子，想要立即尝尝鲜（我当初就是这样），那么您可以使用 Windows Vista® Mobile 设备中心来传输此应用程序。但是，这对于最终用户而言并非是理想做法。另外一种方法是创建 CAB 文件，然后创建 Microsoft Installer (MSI) 安装程序，该安装程序可以从 PC 运行，以便在 Pocket PC 上安装此应用程序。一旦 CAB 文件生成后，您便可以使用一个程序快速生成安装文件，并配备最终用户许可证。但是，您可能更愿意构建一个自定义安装程序，以便从桌面处理对移动电话的部署，从而提供更高级的技术。本部分的结尾将探讨如何使用 Visual C# 2005 来创建自定义安装程序、智能设备 CAB 文件以及部署项目。如果您拥有 Visual Studio 2003，则可以在 msdn2.micro- soft.com/ms838273 找到一份关于实现类似功能的指南。

《MSDN® 杂志》网站上可供您下载完整的项目，其中包括四个主要组件：TooLoud 应用程序、SmartDeviceCab 生成器、CustomInstaller 和 Setup 安装程序。

在 Visual Studio 2005 中，创建智能设备 CAB 的过程很简单。要将智能设备 CAB 项目添加到解决方案，只需单击“解决方案资源管理器”中的解决方案，然后依次选择“添加”|“新项目”、“其他项目类型”|“安装和部署”、“智能设备 CAB 项目”模板，然后单击“确定”即可。现在在“解决方案资源管理器”中，选择您刚刚添加的智能设备 Cab 项目，然后添加 TooLoud 应用程序的主项目输出。

您可以为 SmartDeviceCab 项目设置多个属性；图 6 显示了 Visual Studio 2005 中的属性页。虽然有些属性很简单，但有些则并非如此。例如，OSversionMin 属性如果设置为 4.21，则定义应用程序是否支持屏幕方向感知。但是，此值将不允许在基于 Windows Mobile 2003 和早期版本的 Pocket PC 上安装应用程序。对基于 Windows® 2003SE 和早期版本的 Pocket PC 解决方案来说，Compress 属性和 NoUninstall 设备部署属性必须为 false。（请注意，如果您安装了 .NET Compact Framework 2.0，则 NoUninstall 设备部署属性可以设置为 True。）

图 6 SmartDeviceCab 属性
图 6 SmartDeviceCab 属性(单击该图像获得较小视图)
图 6 SmartDeviceCab 属性
图 6 SmartDeviceCab 属性 (单击该图像获得较大视图)

CE Setup DLL 指定了运行安装后操作的文件，这些操作包括配置注册表设置或将应用程序安装为服务等。这后一种情况非常有用，例如，您希望应用程序在后台作为服务运行，能够做出何时运行此应用程序的智能决定。

构建 CAB 文件后，它能够将此应用程序部署到移动电话上，但是关于如何触发复制 CAB 文件并将其安装到移动电话上仍然存在问题。因此，我将创建一个部署项目，该项目会将 CAB 文件安装到系统桌面，然后触发应用程序管理器（参见图 7）。应用程序管理器在 Windows Mobile 设备中心下运行，它是您桌面上的程序，控制安装到移动电话的应用程序。

图 7 Mobile 设备中心和应用程序管理器
图 7 Mobile 设备中心和应用程序管理器(单击该图像获得较小视图)
图 7 Mobile 设备中心和应用程序管理器
图 7 Mobile 设备中心和应用程序管理器 (单击该图像获得较大视图)

我的部署项目与 Visual Studio 模板中所含的桌面部署项目相同。但是，我必须扩展功能，以包括启动应用程序管理器以安装 CAB 文件的任务。我的实现方法是创建一个自定义安装程序类来扩展普通安装程序。该自定义安装程序类会重写两个基类方法 AfterInstall 和 AfterUninstall，然后添加一个方法以启动应用程序管理器。您可以通过访问台式计算机上的注册表来查找应用程序管理器的位置。

图 7 显示了已由我的自定义安装程序启动的应用程序管理器（前台窗口）。我的自定义安装程序的代码如图 8 所示。

Figure 8 自定义安装程序代码 (Close Figure 8)
private void Installer_AfterInstall(
    object sender, System.Configuration.Install.InstallEventArgs e)
{
    // Get fullpath to .ini file.
    string arg = Path.Combine(
        Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
        "Setup.ini");

    // Run WinCE App Manager to install .cab file on device.
    RunAppManager(arg);
}

private void Installer_AfterUninstall(
    object sender, System.Configuration.Install.InstallEventArgs e)
{
    // Run app manager in uninstall mode (without any arguments).
    RunAppManager(null);
}

private void RunAppManager(string arg)
{
    // Get path to the application manager.
    const string RegPath = @"Software\Microsoft\Windows\" +
        @"CurrentVersion\App Paths\CEAppMgr.exe";
    RegistryKey key = Registry.LocalMachine.OpenSubKey(RegPath);
    string appManager = key.GetValue("") as string;

    if (appManager != null)
    {
        // Launch the application manager.
        Process.Start(string.Format("\"{0}\"", appManager),
            (arg == null) ? "" : string.Format("\"{0}\"", arg));
    }
    else
    {
        // Could not locate application manager.
      MessageBox.Show(
          "Could not launch the WinCE Application Manager :-(");
    }
}
图 9 完整解决方案
图 9 完整解决方案

现在，部署项目是按普通方法创建的，即向解决方案添加一个标准桌面安装项目。添加了部署模板之后，我将 custominstaller.dll 和 smartdevicecab.cab 添加到安装项目。为了引用自定义安装程序，右键单击安装项目，然后选择“查看”|“自定义操作”。接下来，我将 custominstaller.dll 添加到部署项目的“安装”和“卸载”自定义操作中。

我的部署项目需要最后一个文件——setup.ini 文件：

[CEAppManager]
Version      = 1.0
Component    = App

[App]
Description  = TooLoud Pocket PC
    application.
CabFiles     = SmartDeviceCab.cab

它告诉应用程序管理器该加载哪个 CAB。图 9 显示了我的解决方案的布局。（生成顺序应设为 TooLoud—SmartDeviceCab—CustomInstall—Setup。） 现在，可以使用装有应用程序管理器的台式计算机构建和部署我的项目了。

结束语

本文概述了我使用 .NET Compact Framework 的 1.x 版本为 Visual Studio 2005 编写的一个特定示例。它说明了设计一个将使用 Windows Mobile 设备上的硬件来监控周围环境并相应自动调整铃声音量的应用程序时，所涉及的基本原则和一般技巧。当然，可以重构此代码以使用最新的技术，如 .NET Compact Framework 2.0 版本。

为了检查这个应用程序的运行状况，我同时进行了基于实验室的测试和环境测试。基于实验室的测试是为了确保电话铃声仅超过环境噪音 2-5dB（6dB 大约是两倍响度）。分贝值是通过分贝计，利用音调作为测试信号测量出来的。环境测试在两个地点进行——洛杉矶的一个咖啡馆和一个机场。我为此测试采用了非常科学的测量方式——我记下了当我的电话响起时回头的人数。

此应用程序提供了一种声音法/可靠的方法（抱歉，sound approach 这一双关语是有意使用的）来监控和自调移动电话的铃声水平。与所有应用程序一样，它也有自己的局限性，如需要为移动电话的每个型号配置参考值。尽管这不是一个不可管理的任务，但是当前用户界面并不支持它。另外一个主要限制是此方法会消耗很多电量。但是，耗电问题非常容易解决，例如，只在电话将要响起时才启用进程。

除了这些问题，此应用程序比目前仅在手动更改设置后铃声水平才能发生变化的方法更具有意义，这在我看来尤其如此。但是，还是有一些需要改进的地方，因此请根据需要自调代码并添加您认为有用的任何功能。我肯定您能想到本文中尚未探讨的很多特性和功能。

在此，我对 Brian Richardson 为本文开头提供的工艺图表示感谢。

Chris Mitchell 是机器学习和音乐/声音信号处理专业的博士，目前是一名 Kauffman/NCGE 研究员。他曾经在英国剑桥安格利亚鲁斯金大学 (Anglia Ruskin University) 担任兼职讲师，并提供一些网络咨询，以此资助自己的学业。您可以通过电子邮件 c.mitchell@anglia.ac.uk 与 Chris 取得联系。对音乐技术的热情促使他着手探究一些音频程序设计基础，他总是非常乐意了解有关这一领域的任何信息。


编者按: 复杂性之复杂性

编者按
复杂性之复杂性
Howard Dierking

作为开发人员， 为了管理复杂性方面出现的新问题，我们似乎不断承受着日益增长的压力。我们需要编写并维护可逐步支持更复杂的业务规则和流程的系统。我们需要通过各种媒体来创建动态、交互式和完整的用户体验。并且，我们一直面临压力，需要了解并有效利用最新与最佳软件设计之间的细微差别。

面对这一不断提升的复杂性，解决方案在何处？如果以史为鉴，那么解决方案便是创建新的隐喻和新的抽象。隐喻可以让我们使用现有的语言和经验，通过一种通用的方式来传达各种专业化主题。同样，通过隐藏会阻碍我们有效分离不同元素的细节，抽象有利于更好的设计。最后，抽象可以让技术专家创建对其他元素依赖性最少的软件元素，而隐喻能够让全方位专家和不同领域的专家智能化地讨论并应用这些元素，从而创建精妙的解决方案。

将大部分事业投入到为各行各业设计和开发软件之后，最近我加入了《MSDN® 杂志》。我确信您可以想象的一点是，在出版杂志的过程中有很多灵活变动的部分——对于一个初来咋到者，很容易被这种复杂性弄得手忙脚乱。

然而，和我在当开发人员时一样，我发现了抽象和隐喻的宝贵价值，可为我了解业务提供一条井然有序的途径。例如，编辑和制作过程中的细节已被有效地抽象出来——编辑不需要了解排版的所有技术细节，制作人员也不必关心如何得到和进一步处理稿件。而诸如“倾注”之类的隐喻描述了将编辑好的文字转移到排版的过程。总之，值得高兴的是，我当开发人员时学会的一些技能在杂志业也有用武之地！

在本期《MSDN 杂志》中，我们将探讨如何通过新工具和隐喻，来解决您在日常软件开发任务中可能面临的复杂性。为了支持复杂性日益增加的业务需求，Bill Horst 讨论了类型推断如何提高代码的抽象级别，即从 CLR 类型系统提高至更接近于业务领域。在对动态语言运行时 (DLR) 的讨论中，Bill Chiles 进一步阐明了编程中的这一意图概念。为实现更丰富的用户体验，Dino Esposito 在他 ASP.NET AJAX 专栏的第 2 部分将服务隐喻扩展到 Web 表示模式。Daan Leijen 和 Judd Hall 说明了 Parallel FX 库如何对特定的底层细节（关于如何利用新的软件创新，如多处理器和多核处理器）进行抽象化。Joe Duffy 和 Ed Essey 然后说明了如何扩展现有的语言隐喻，以便在 Parallel LINQ 中支持这些抽象。

最终，成功创建新的隐喻和抽象无疑又会带来新一轮的复杂性，因此需要有新的隐喻和抽象。但是，对于每一个新抽象层，我们能够通过软件来解决的问题数量也相应增加。如此说来，我们应为《MSDN 杂志》的前景创建何种隐喻？以下是几个想法：摇滚乐音乐会、铁锤、镜子、七彩霓虹灯。

您有什么想法呢？请把您的想法和创意通过电子邮件发送给我们——谁知道会发生什么呢？您的创意可能会非常有助于规划杂志未来几年的前景！ —Howard

Thanks to 衷心感谢下列 Microsoft 技术专家为本期杂志提供的大力协助：Mahesh Prakriya、Kit George、Umachandar Jayachandran、Rob Earhart、Eric Li、Sandeep Ranade、Rob Shewan、Judd Hall、Ed Essey、Vance Morrison、JJ Johnston、Daniel Moth、Mike Hall、Jordan Parker、David Aiken、Joe Duffy 和 Stephen Toub。


工具箱: 管理数据库、更便捷的 FTP，以及群集缓存

工具箱
管理数据库、更便捷的 FTP，以及群集缓存
Scott Mitchell

目录
有效管理数据库项目
FTP 轻松实现
将缓存提升到新的高度

有效管理数据库项目

有许多工具可以用来管理数据库、浏览和修改架构，以及编写和执行查询。在这些工具中，一个值得研究的现成工具是 Altova 开发的 DatabaseSpy 2007。DatabaseSpy 可以连接到各种不同的数据存储——Microsoft® Access®、Microsoft SQL Server™、Oracle、MySQL、Sybase 等——而且提供统一的用户体验（无论数据库为何）。

当第一次通过 DatabaseSpy 连接到数据库时，会自动创建新的项目。这些项目充当存储连接详细信息、凭据、常用查询等数据的存储库。您可以快速搜索项目的内容，而且可将经常使用的元素存储在“收藏夹”文件夹中。此外，还可以自定义和配置项目管理器的菜单和工具栏以启动外部工具。

DatabaseSpy 的 SQL 编辑器还提供最新的查询编辑器。对于初学者来说，Autocomplete 功能可为编辑器提供类似 IntelliSense® 的行为。当输入查询时，可用的 SQL 关键字、功能和表名称的下拉列表会出现。当执行查询时，结果会显示在网格中。不过与 SQL Server Management Studio 中的结果网格不同，通过单击列标题可以对 DatabaseSpy 的网格进行排序。SQL 编辑器还包括一些数据导出工具，可将数据导出为包括 XML、CSV、HTML 和 Excel® 在内的各种格式。

此外，DatabaseSpy 提供了一个极具特色的图形化 Design Editor，开发人员通过一些点击即可创建和修改表。在创建或修改表的过程中，DatabaseSpy 会维护一个数据库结构更改脚本并将其显示在屏幕上。此信息可便于您轻松验证已做出了哪些具体的更改，而且可用于将相同的更改应用到另一个数据库。

如果您正在寻找一款管理数据库和执行查询的顶级工具，请花些时间研究一下第三方替代方案，例如 DatabaseSpy 2007。

价格： $129

www.altova.com/DatabaseSpy

DatabaseSpy 的 Design Editor 可帮助您创建和修改表
DatabaseSpy 的 Design Editor 可帮助您创建和修改表 (单击该图像获得较小视图)
DatabaseSpy 的 Design Editor 可帮助您创建和修改表
DatabaseSpy 的 Design Editor 可帮助您创建和修改表 (单击该图像获得较大视图)

FTP 轻松实现

文件传输协议 (FTP) 是一个众所周知的开放式协议，用于通过网络在服务器和客户端之间交换文件。Web 浏览器是目前最常见、使用最广泛的 FTP 客户端应用程序，但是 FTP 功能和用户体验（尽管简单易懂）通常缺乏大多数独立式 FTP 客户端应用程序所具备的更先进的功能。

其中一种独立式 FTP 客户端应用程序就是 FileZilla（版本 2.2），它是 Tim Kosse 发起的开源项目。启动 FileZilla 后会显示存储了 FTP 服务器帐户信息的站点管理器。在这里，您可以添加和分类您经常访问的 FTP 服务器，可存储身份验证凭据、连接选项和其他配置信息。FileZilla 支持本地 FTP 实现（因为它以纯文本的形式通过网络发送凭证，所以不安全）和两种标准化的安全实现：FTP over SSL 和 SFTP。

连接到 FTP 站点之后，FileZilla 用户界面会包含四个窗格。第一个窗格列出了已发送到 FTP 服务器的 FTP 命令和相应的响应。该底层信息对于开发人员和其他熟练用户进行故障排除非常有帮助。第二个窗格列出了本地计算机上的文件和文件夹，第三个窗格则列出 FTP 服务器上的文件和文件夹。要将文件或文件夹从一个位置上传到另一个位置，只需从一个窗格中选择项目，然后将它们拖放到另一个窗格中即可。当前活动和等待处理的操作列表列在窗格底部的传输队列中。

除了客户端应用程序之外，FileZilla 项目还提供免费的开源 FTP 服务器实现。

价格： 免费；提供源代码 (Visual C++)

sourceforge.net/projects/filezilla

作为一款诊断工具，FileZilla 列出了 FTP 命令和响应
作为一款诊断工具，FileZilla 列出了 FTP 命令和响应 (单击该图像获得较小视图)
作为一款诊断工具，FileZilla 列出了 FTP 命令和响应
作为一款诊断工具，FileZilla 列出了 FTP 命令和响应 (单击该图像获得较大视图)

将缓存提升到新的高度

缓存是用来提高应用程序性能的常见技术，其实现方式是将常用数据从慢数据源复制到更快的数据源。对于数据驱动的应用程序来说，该技术通常需要将从数据库或 Web 服务检索到的数据缓存到本地计算机的内存中。

当缓存特定于每个应用程序时最容易实现缓存技术，但是如果多个应用程序需要使用一个公共缓存，那么问题将变得更具挑战性。例如，大型网站通常使用服务器场，其中包含多个提供相同内容的计算机。当每个请求到达时，它会被分配给场中的其中一台计算机。然而，如果信息缓存到场中的一台计算机内存中，其他计算机中的缓存就无法访问它，因此降低了缓存的效率。该问题的一种解决方案是将缓存移到集中式的数据存储（如数据库），或将场中的其中一台服务器指定为缓存服务器，只用来存储缓存的唯一副本。使用集中式缓存存储存在一个问题，那就是它们引入了单点故障，且会成为瓶颈。

要克服单一、集中式缓存存储的局限性，请考虑使用 Alachisoft 开发的 NCache 3.1。NCache 是一个群集缓存框架，能够无缝管理分布在多台计算机上的缓存。管理员首先通过 NCacheManager 工具指定群集拓扑和缓存策略。管理员可以指定多种设置，包括是将缓存存储在内存还是磁盘中、逐出策略和最大缓存大小等；这些设置和拓扑均可在运行时修改。此外，NCacheManager 还提供一系列统计数据，它们不但可用于监控缓存的运行状况，还可作为微调缓存设置以获得最优性能的指标。

在定义缓存群集时，您可以指定是应复制群集内的缓存还是应对其进行分区。复制的缓存群集所拥有的缓存内容与群集中每台机器上的内容均相同。因为没有单点故障，所以复制的缓存可提高可靠性；但是对群集的任何更新都必须复制到所有的群集。因此，对以读取为主的缓存或小群集来说，复制的群集是理想选择。另一方面，分区缓存在群集内的各个计算机之间划分缓存内容，并为更新和大型群集实现了更好的性能。

在评估像 NCache 这样的框架时，我对学习曲线和集成简化始终充满兴趣。毫无疑问，掌握 NCacheManager 工具并充分理解各种配置设置需要花费时间，但是将 NCache API 插入您的应用程序却是一个轻松的过程。NCache API 中的类几乎与 Microsoft .NET Framework 中的缓存 API 类相匹配，并包括了 HTTP 模块，它使得升级 ASP.NET 应用程序就如同更新其配置文件那样简单。NCache 还提供了集成到 Microsoft 缓存应用程序块 (CAB) 的提供程序。

简言之，增强您应用程序的缓存功能是一个相对简单易懂的过程：现有代码只需极少更改或无需更改即可升级为能使用 NCache。

价格： 每 CPU 995 美元，标准版。

www.alachisoft.com

使用 NCacheManager 指定缓存策略
使用 NCacheManager 指定缓存策略 (单击该图像获得较小视图)
使用 NCacheManager 指定缓存策略
使用 NCacheManager 指定缓存策略 (单击该图像获得较大视图)

请将您想要向 Scott 询问的问题和提出的意见发送至 toolsmm@microsoft.com.

Scott Mitchell是多部书的作者，也是 4GuysFromRolla.com 网站的创立者，自 1998 年起他就一直从事 Microsoft Web 技术方面的工作，是这方面的 MVP。Scott 是一位独立技术顾问、培训专家兼撰稿人。您可以通过电子邮件 Mitchell@4guysfromrolla.com 或他的博客 ScottOnWriting.NET 与他联系。

所有价格都在截稿时经过确认，并有可能发生变动。本专栏所述观点仅代表作者个人之见，并不一定反映 Microsoft 的观点。

MSDN Magazine does not make any representation or warranty, express or implied with respect to any code or other information herein. MSDN Magazine disclaims any liability whatsoever for any use of such code or other information.


CLR 完全介绍: IronPython 和动态语言运行时

CLR 完全介绍
IronPython 和动态语言运行时
Bill Chiles

目录
IronPython Hello World
启动 Ipy.exe
AST 简介
查看 IronPython AST
查看 DLR AST
查看生成的代码
理解动态点
DynamicSite 对象工作原理
重要的回报

M
icrosoft® .NET Framework 旨在于单个运行时——公共语言运行时 (CLR)——上支持各种各样的编程语言。CLR 向这些语言提供共享服务，包括垃圾收集、实时 (JIT) 编译、沙箱安全模型以及对工具集成的支持。对语言实现者来说，共享这些功能有两大好处。实现一种语言更容易了，因为许多底层的设计工作已经完成。另一个好处是，运行于 CLR 上的多种语言可以无缝地集成。通过共享库和框架，对 CLR 陌生的语言可以构建在其他语言的工作之上。

CLR 还提供对动态语言的支持，IronPython 1.0 即是明证 (www.codeplex.com/ironpython)。程序员对动态编程语言的感受是功能强大、生产高效，James Schementi 在 2006 年 10 月期的“CLR 完全介绍”(msdn.microsoft.com/msdnmag/issues/06/10/CLRInsideOut) 中对此有具体介绍。现在，Microsoft 正在构建动态语言运行时 (DLR)，向 CLR 中添加了一组专门用于满足动态语言需要的服务。DLR 添加的功能包括：一个共享的动态类型系统，一个标准的承载模型，以及对简化生成快速动态代码和快速符号表的支持。凭借这些附加功能，可以十分轻松地为 .NET 构建高质量的动态语言实现。此外，这些功能使得构建于 DLR 之上的动态语言可以共享用其他动态语言或基于 CLR 的静态语言编写的库。

DLR 可让您获得使用自己最喜爱的动态语言的最佳体验。这就是说，用您自己的语言编写代码，这种愉悦的感受一如既往。您还能从出色的工具、优良的性能和共享库中受益。因为使用 DLR 即意味着可以共享标准功能，所以您有更多时间来关注语言本身所具有的独特功能。例如，您完全不必再去实现垃圾收集、代码生成和方法缓存功能。

DLR 使用自适应的方法缓存来产生快速动态程序，DLR 上的所有语言实现均从这种共享的工作中受益。由于避免了重复搜索类优先顺序列表，所以，代码运行得更快了。它还避免了每次调用对象时解析 .NET 方法上的重载（比如，调用某个对象 o 上的方法 foo）。

IronPython 和 DLR 的源代码可遵照 Berkeley Software Distribution (BSD) 形式的 Microsoft Permissive License 从 Codeplex 上获得（参见 2.0 Alpha 版）。这是一个很不成熟的版本，还有大量的工作要做。底层机制尚需设计，性能增强尚需实现，文档资料尚需编写。当您阅读本专栏时，有多个 Codeplex 项目尚在实施过程中，包括 DLR 代码本身、IronRuby 以及 IronPython。虽然我们正在进行 DLR JScript® 和 VBX 实现，却也只能提供二进制的形式。

IronPython Hello World

我将详细讲解一个简单的 Python 程序（稍加变化的 Hello World），并讨论它以 ipy.exe 执行时发生了什么。我要讲解的程序是 msdnmag.py：

def yo(yourname):
   text = "hello, "
   return text + yourname

print yo("bill")

我在传统的“print hello”功能之上添加了一点东西，这样我们就可以探讨 IronPython 实现中更加有趣的一些方面。例如，我将能够演示标识符引用的名称绑定和在 yo 函数中隐藏“+”的含义。

我使用的是 IronPython 2.0 alpha，所以，现在就花几分钟下载，然后您就可以和我一起往下进行。

首先，在 Visual Studio® 2005 中打开 ironpython.sln 文件，用 Debug 解决方案配置构建解决方案。然后右键单击 IronPythonConsole 项目，在左侧导航栏中选择“调试”选项卡。将此项目设为启动项目（“启动操作”下方的第一组单选按钮）。接下来，在“启动选项”下方输入 msdnmag.py 作为命令行参数。最后，将上面列出的 msdnmag.py Python 代码复制到 \bin\debug\ 目录下名为 msdnmag.py 的文件中，该文件是您构建解决方案时创建的。

启动 Ipy.exe

现在，让我们看一下 ipy.exe 是如何启动的。DLR 提供了一些默认的控制台类，从而使语言实现者可以轻松地运行解释器（实现者仍需要为他的语言编写分析器和运行时实现）。控制台类处理输入和输出、命令行开关和 DLR 实现的某些共享的诊断开关。这样就减轻了语言实现者在 DLR 上运行一些初始代码的工作，而且它还提供了一个测试工具。

首先，打开 <installdir>\Src\IronPython\Compiler\Generation\PythonScriptCompiler.cs。然后在 ParseFile 函数的“using (Parser parser = ....”行上设置一个断点

按 F5（“调试”|“启动调试”）运行程序。当解析对 Python 执行引擎进行初始化的代码段时，您将首先遇到该断点。该代码段加载 Python site.py file 文件，以便进行计算机特定的初始化。那么，您是如何到达此处的呢？如果看一下调用堆栈，就会发现 ipy.exe 在 PythonConsoleHost 的 Main 函数中启动，Main 函数调用共享的 DLR 便利代码。执行过程回到 IronPython 特定代码，以处理命令行，这就导致了 msdnmag.py 文件的运行。

PythonConsoleHost 类很小。它有一个非常简短的 Initialize 方法，该方法将 LanguageProvider（就此成为执行引擎）设置为 IronPython 实现。PythonConsoleHost 的 Main 方法只是调用默认的 ConsoleHost 类，该类由 DLR 提供。

ConsoleHost 是一个帮助器类，语言实现者可用它迅速构建一个 cmd.exe 样式的交互式外壳程序。ConsoleHost 类从 LanguageProvider 获得 CommandLine 帮助器对象。ConsoleHost 运行 CommandLine 对象，调用 CommandLine 对象的 Initialize 方法，此时，代码开始在 PythonCommandLine 对象中执行。

PythonCommandLine 对象在其 Initialize 方法中设置用于加载模块的 Python 路径，设置某些内置模块，并导入 IronPython 的 site.py 文件。这些全都是标准的 Python 行为。PythonCommandLine 通过在代码段的“import site.py”字符串上调用 IScriptEngine.Execute 来导入 site.py 文件。

您在调用 PythonScriptCompiler.ParseFile 的过程中首先将遇到断点，这是由于解析“import site.py”而引发的，不过，让我们继续往下执行，因为 msdnmag.py 还没有运行。

AST 简介

当 ipy.exe 执行 msdnmag.py 时，IronPython 实现与 DLR 协作，编译该文件中的代码。在基础层面上，编译器是以分析器为起点的管道，分析器产生一种表示代码的数据结构。此数据结构按照编译器的说法是一种抽象语法树 (abstract syntax tree)，或者叫做 AST。编译器在代码分析阶段对 AST 进行转换，或者创建新的数据结构。然后，编译器根据由此得到的结构生成机器码或中间语言 (IL) 代码，后者用于虚拟机，如 CLR。

IronPython 代码首先生成一种特定于 IronPython 的 AST，然后将此树映射到 DLR AST。某些语言有它们自己的中间树，从而它们可以执行分析，或支持用以处理其 AST 的工具（代码编辑器）。这些工具需要一个与用户在编辑器中键入的源代码接近的树。许多语言可能都具有与 DLR AST 类似的 AST，不过，DLR AST 将具有更多关于显式表达的语义的信息。您可以将 DLR AST 看作众多转换形式中的一种，编译器在后期阶段将需要这些转换。

有趣的是，DLR 只是在代码运行时才懒洋洋地编译部分 AST。这与 CLR 的编译方法类似，CLR 仅在代码运行时才使用 JIT 编译。代码第一次运行时会牺牲一些性能，但后续执行速度很快。稍后您会看到，AST 片段直到执行特定的代码行时才会进行编译。

查看 IronPython AST

让我们回过来再运行程序，查看一下 IronPython AST。首先，按 F5（“测试”|“继续”），再次在此断点处停止，此时，IronPython 引擎开始解析 site.py 的内容，以便执行该文件。如果您查看 Visual Studio“局部变量”窗口，可以依次展开“cc”，“SourceUnit”，然后查看 Name 或 DisplayName，看看 ParseFile 正在处理什么内容。

再次按 F5。我们最后一次在此断点处停止。如果您现在查看“局部变量”，就会看到 cc.SourceUnit.DisplayName 是 msdnmag.py。这是我向 ipy.exe 中添加的作为命令行参数的文件。在“局部变量”窗口中，cc.SourceUnit.Name 是“__main__”，因为 Python 就是用这种方式命名主执行模块的。

现在，按 F10（“调试”|“单步跳过”）三次，跳过下面一行

ast = parser.ParseFileInput()

现在，您可以在调试器中查看 IronPython AST，如图 1 所示。

图 1 调试器中的 IronPython AST
图 1 调试器中的 IronPython AST

您可以看到，AST 根节点是一个代表一系列语句的 SuiteStatement，并有一个 Statements 成员。展开第一个语句（索引零），您会看到它有一个 FunctionDefinition 节点。其中的 Body 成员是一个表示函数定义语句的 SuiteStatement，其中的 Name 成员是将要绑定到由此产生的函数对象的名称。

用省略某些细节的图形表示，整个 msdnmag.py 文件的 IronPython AST 看起来与图 2 类似。

图 2 msdnmag.py 的 IronPython 抽象语法树
图 2 msdnmag.py 的 IronPython 抽象语法树(单击该图像获得较小视图)
图 2 msdnmag.py 的 IronPython 抽象语法树
图 2 msdnmag.py 的 IronPython 抽象语法树 (单击该图像获得较大视图)

在树中混合使用了可视化的表现形式。在绝大多数情况下，如果一个 AST 节点有一个指向另一个 AST 节点的成员，图中就显示一个指向子节点的箭头。通常，子节点以父节点中成员的类型命名。例如，FunctionDefinition 节点有一个 SuiteStatement 类型的成员，那么，下一个节点就是一个 SuiteStatement。有时，某个节点包含一个对子节点的描述，描述采用的是缩进和花括号的形式。我选择用该方式显示这些节点是为了简化图表。

查看 DLR AST

IronPython 现在已做好构建 DLR AST 的准备，以便编译的最后阶段可以运行。在 ParseFile 中的断点下面，您可以看到对 BindAndTransform 的调用，其功能是将 IronPython AST 转换为 DLR 树。在该过程中，IronPython 解析所有的标识符，从而使它们在 DLR 树中的表示方式指向它们所代表的变量的声明/分配信息。当然，对于某些标识符来说，它们需要编译为一个代表在运行时对值进行搜索的 AST 节点；例如，标识符可以解析为一个运行时添加的模块成员，或者解析为一个由主机通过模块目录提供的后期绑定的值。

在此部分中有许多细节，也许只有您想将一种语言移植到 DLR 时才有必要了解它们。如果您只是查看 DLR 树的图示，请注意其中的 MethodCallExpression 节点和 ActionExpression 节点。MethodCallExpression 节点编译为对执行一个操作的 IronPython 运行时函数的调用。每当调用此函数时，它都采用传递给它的参数完成相同的工作。

ActionExpression 节点特别有趣，因为它代表一个抽象的操作或动作。例如，抽象动作包括提取成员、索引对象、调用对象、在对象上执行添加等。DLR 将 ActionExpression 节点编译为动态调用点。动态点 (Dynamic sites) 是一种运行时机制，它缓存适合特定动作和参数类型的组合的方法。这种机制使动态代码运行得更快，在编译时只有很少的静态类型信息或完全没有静态类型信息。稍后我会详细解释动态点。

让我们回过来一步步地执行程序。按 Shift+F11（“调试”|“跳出”）两次，停在 SourceUnit.Compile 中，然后按 F10（“单步跳过”），执行对变量“block”的赋值，该变量将持有整个文件的 DLR AST 的根。

现在我们可以查看 DLR AST。在 Visual Studio“局部变量”窗口中，展开名为 block 的节点，它是一个 CodeBlock AST 节点。这是 Python 代码文件的根节点。将某一语言的特定语法树转化为 DLR AST 的一部分工作就是明确地指明了声明变量（显式或隐式）的位置和标识符所指向的变量。如果您展开 block 的变量成员，就会看到两个在模块作用域内声明的变量，即“__name__”和“yo”。（展开元素 0 和元素 1，查看 Variable 对象的名称成员。） 第一个变量是 Python 需要的，并为模块创建的绑定名称。第二个变量是持有我们的函数定义的模块变量。

在深入探讨主体之前，快速查看一下图 3 中对 DLR AST 的部分扩展，该图反映了与您早先看到的 IronPython AST 类似的表现方式：

图 3 DLR AST
图 3 DLR AST(单击该图像获得较小视图)
图 3 DLR AST
图 3 DLR AST (单击该图像获得较大视图)

展开 block 下方的 Body 属性。鉴于 Visual Studio 调试器的显示特性，您需要展开 body 节点下的第一个节点才能真正看到 BlockStatement 的内容。此 DLR BlockStatement AST 节点对应于 IronPython AST 的 SuiteStatement 节点，各自都代表一系列语句。展开语句属性。

第一个语句（索引 0）是一个 EmptyStatement，它是 IronPython 转化到 DLR 树的产物。如果存在一个模块文档字符串，IronPython 就会分配两个语句，这种情况下会导致创建对 __doc__ 的赋值。此示例中，由于不存在任何文档字符串，第一个语句留作占位符 EmptyStatement。

第二个语句（索引 1）是我们的兴趣所在，因为它是文件（即我编写的代码）其余部分的 BlockStatement。此 BlockStatement 包含一个 ExpressionStatement，其后跟随一个 BlockStatement（稍后讨论）。ExpressionStatement 节点封装了一个 BoundAssignment 节点，从而使赋值的结果值从堆栈中弹出；Python 赋值不返回值。BoundAssignment 具有名称和值成员。它们表示将局部变量 yo（局部是相对于 Python 代码执行所在的模块而言）设置为一个函数调用的结果。函数调用由 MethodCallExpression 表示，它调用创建 IronPython 函数对象的运行时帮助器函数。MethodCallExpression 有一些成员表明了调用对象是名为 MakeFunction 的方法以及调用所采用的参数。一个参数是用于该函数主体的 CodeBlockExpression AST 节点，另一个参数是一个参数数组，其中包含所创建的函数将要采用的参数。

展开 MethodCallExpression AST 节点，它是 BoundAssignment 的值成员。看一下 Arguments 属性的第三个元素（索引 2），您会看到 yo 的主体的 CodeBlockExpression。它有一个 Block 属性，该属性是一个 CodeBlock。CodeBlockExpression 使它所封装的 CodeBlock 转变成可调用的值，该值可以分配给模块变量。在我们具体讨论 yo 函数的 AST 的主体之前，先看一下图 4，这是 msdnmag.py 的完整 DLR AST 图示。

图 4 msdnmag.py 的完整 DLR AST
图 4 msdnmag.py 的完整 DLR AST(单击该图像获得较小视图)
图 4 msdnmag.py 的完整 DLR AST
图 4 msdnmag.py 的完整 DLR AST (单击该图像获得较大视图)

图中有几个地方需要注意。与前面的图相似，在树中混合使用了可视化的表现形式。在绝大多数情况下，如果一个 AST 节点有一个指向另一个 AST 节点的成员，图中就显示一个指向子节点的箭头。有时，某个节点包含对子节点的描述，描述采用的是缩进和花括号的形式，而不是更多的箭头和方框。在图的左侧，Visual Studio 中“局部变量”窗口的快照显示了 AST，往下一直到 MethodCallExpression 节点。第一个箭头是虚线的，因为该图略去了带有 EmptyExpression 的 BlockStatement。您可以在“局部变量”窗口中进一步展开数据结构，查看图中所示的树的完整结构。

代表 yo 函数主体的 CodeBlock 节点具有变量成员、参数成员和主体 AST 成员。这类似于代表整个 msdnmag.py 文件的 CodeBlock。稍后您会看到，该文件的 CodeBlock 将转变为该模块的一个 Initialize 方法。如果您展开变量成员，展开元素 0，然后展开 Variable 对象，就会看到，yo 函数有一个局部变量 text。通过类似的展开操作，您还会看到，yo 函数有一个参数 yourname。

CodeBlock 的主体是 BlockStatement，它有两个语句：一个赋值语句和一个返回语句。就像您在上面看到的对 yo 的赋值，BlockStatement 首先有一个持有 BoundAssignment 的 ExpressionStatement。此赋值表示将 text 设置为“hello,”。BoundAssignment 对象有一个 ConstantExpression 值成员和一个 Variable 成员。Variable 对象将 text 描述为一个局部变量，而且有一个指回 CodeBlock（该局部变量作用域）的 block 成员。

BlockStatement 中的第二个语句是 ReturnStatement，其表达式成员是 ActionExpression。就像我上文解释的那样，此 AST 节点特别有趣。此 ActionExpression 节点有一个 DoOperationAction 动作成员，在本示例中它代表“添加”操作。ActionExpression 有一个参数成员，带有的两个元素都是 BoundExpression 对象。第一个 BoundExpression 有一个变量成员，代表局部变量 text，其值设为“hello,”。第二个 BoundExpression 有一个变量成员，代表参数 yourname。

现在，让我们转回到树的上方，看看包含两个 ExpressionStatments 的 BlockStatement。一个用于将 yo 的 BoundAssignment 绑定到一个函数对象。还存在第二个 ExpressionStatement，以便编译器生成代码，将其表达式的值弹出堆栈。ExpressionStatement 有一个 MethodCallExpression 表达式成员。

MethodCallExpression 代表对实现 print 语句的 IronPython 运行时函数的调用。它有一个方法成员，是描述打印的 .NET RuntimeMethodInfo。MethodCallExpression 有一个 ActionExpression 参数成员。正如上面讨论的那样，这编译为用于快速调用的动态点。ActionExpression 有一个动作成员，是调用 yo 的 CallAction。此节点还有一个带有两个元素的参数成员。一个是 BoundExpression，它有一个变量成员，表示该表达值是局部变量 yo 的值（yo 对于定义它所在的模块而言为局部变量）。另一个参数是 ConstantExpression，它有一个持有字符串“bill”的值成员。

我们对 DLR AST 的详细探讨就此结束。您可以看到 IronPython 树如何直接代表您所看到的 Python 代码。DLR AST 中有更多明确信息。它包含标识符绑定信息和表征作用域的显式代码块。它包含对运行时方法调用（调用 Print）的显式表述，而不是编译为动态点（“text + yourname”的节点）的动作。DLR AST 还包含促使表达式结果从堆栈中弹出的节点，从而使代码遵循 Python 语义。

查看生成的代码

既然您已了解进程如何启动和执行分析的过程，让我们来看一下生成的代码。最简便的方法是使用 ipy.exe 的开关和 Lutz Roeder 的 Reflector 程序。这些开关可致使 ipy.exe 生成静态代码（与轻量级代码生成器或动态方法相对）并保存到磁盘。从 aisto.com/roeder/dotnet/ 下载 Reflector，然后在您构建 ironpython.sln 所在的 bin debug 目录中调用下列命令行：

ipy.exe -D -X:SaveAssemblies -X:StaticMethods msdnmag.py

随后您会在该目录中发现两个文件：snippets1.dll 和 msdnmag.exe。现在，使用下列命令行对这些文件启动 Reflector（根据需要填写绝对路径）：

C:\where\you\put\reflector.exe snippets1.dll, msdnmag.py

您可以向下翻阅 DLL 和 EXE 列表，查找 msdn 和 snippets1。依次展开 msdnmag、msdnmag.exe、“{} -”，最后到“__main__$mod_2”。现在双击 Initialize 方法。这是 msdnmag.py 文件的主体。您应能看到从 DLR 生成的 IL 中分解出来的源代码。如果您以前用过 Reflector，就会知道它是一个很棒的工具，但它也有一些缺陷。例如，它会额外生成一些临时变量，所以您需要对所看到的 C# 代码持保留态度。

我不会详述 Initialize 函数，但您可以看到，yo 被设置为调用 MakeFunction 的结果，这与您在 AST 中看到的相同。您可以看到，Initialize 随后针对调用 yo 产生的结果，调用运行时帮助器 PythonOps.Print。请注意片段“Call-Simple-1.Invoke”。Call-Simple-1 对象是方法缓存机制的一部分。我会通过它在 yo 函数中的具体表现进一步说明方法缓存机制。

我们来看一下 yo 函数。双击左窗格中的 yo$1。您可以看到它在哪里将 text 设为“hello”，以及它在哪里返回“DoOperation-Add-0.Invoke(text, yourname)”的结果。那里就是我用 Python 编写的代码的本质。我马上会重点说明 DoOperation-Add-0，它就是缓存方法搜寻结果的对象。在深入探讨 DoOperation-Add-0 的过程中，我们还将查看 snippets1 中的一些运行时生成的函数。

理解动态点

动态点可使动态语言代码快速运行。它们管理对特定类型的对象执行特定操作的方法缓存。DLR 使用的动态点机制建立在对经历时间验证的动态语言实现的研究和试验的基础之上。您可以看到生成的代码中的对象，如 Call-Simple-1 和 DoOperation-Add-0，它们是从 DynamicSite<Targ1, Targ2, Tresult> 中派生的类型的实例。我在这里将简要介绍缓存理论，不过，您可以从网上搜索“dynamic language method caching”或“polymorphic inline method caching”，以获得详细信息。

动态语言的性能障碍在于每个调用点上发生的额外检查和搜索。您每次执行一行特定的代码时，直接的实现必须重复搜索成员的类优先顺序列表，并有可能对方法参数类型的重载进行解析。在 o.m(x, y) 或 x + y 之类的表达式中，动态语言需要检查对象 o 的确切类型，o 上所绑定的 m 是什么，x 是什么类型，y 是什么类型，对于 x 和 y 的运行时实际类型，“+”的含义是什么。在静态类型语言中（或代码中具有足够的类型暗示和类型推断），您可以确切地发出每个调用点上适用的指令或运行时函数调用。您可以做到这一点，因为从静态类型中可以得知编译时需要什么。

动态语言因其动态功能，提供了极大的生产效能增强和功能强大的简练表达式。不过，在实际工作中代码倾向于每次都在同一类型的对象上执行。这意味着您可以在一段代码首次执行时记住方法搜索的结果，从而提高性能。例如，就 x + y 而言，如果表达式首次执行时 x 和 y 都是整数，就可以记住一段代码序列或执行两个整数相加功能的确切的运行时函数。此后，每当该表达式执行时，就不会再涉及搜索。代码只是再次检查 x 和 y 是否为整数，并分派给正确的代码，而无需任何搜索。结果实际上可缩减为含有两次类型检查和一个加法指令的内嵌代码生成，这取决于操作的语义和所采用的方法缓存机制。

DynamicSite 对象工作原理

静态语言编译器可以在编译时选择方法或生成特定代码，以执行相加、成员提取和编制索引等操作。由于 DLR 在编译时无法即时得知要生成什么代码，它就生成在 DynamicSite 对象上调用 Invoke 方法的代码。此对象捕获该操作和一个包含缓存逻辑的委托（通过 Invoke 调用）。在运行时，每当该委托发现一个新的参数类型组合时，此缓存逻辑就会得到更新。（注意，这些委托实际上封装了指向函数的指针，不过为简便起见，我使用委托来充当函数。） 这只是简单叙述；接下来是更详细的说明。

委托首先在其内部发起一个对 UpdateBindingAndInvoke 的调用。调用点首次执行时（让我们只考虑 x + y），UpdateBindingAndInvoke 查询一个“加法”操作实现的参数，以获得 x 的类型和 y 的类型。如果它获得了参数的类型，就会生成一个新委托，该委托会编制一个检查 x 和 y 的代码，检查它们是否具有您刚才所见的相同类型。在此后的调用中，如果 x 和 y 具有相同的类型，则新委托的代码就会调用我们所得到的实现。如果 x 或 y 在后期调用中具有不同的类型，该委托就不能实现所有的缓存检查，并再次调用 UpdateBindingAndInvoke，生成一个新委托。最新委托的代码将捕获第一次对 x 和 y 的测试以及新测试，调用与类型匹配的目标实现。

现在我们来看一下 DoOperation-Add-0 委托，它是 DLR 从运行 msdnmag.py 中的代码生成的。转回到 Reflector，这一次在左窗格中展开 snippets1、snippets.dll、“{} -”和“Type$_stub_$4”节点。单击 Handle 方法，在右窗格中您会看到图 5 中的代码。

Figure 5 分解的 "+" 缓存 (Close Figure 5)
public static object Handle(object[],
    FastDynamicSite<object, object, object> site1,
    object obj1, object obj2)
{
    if (((obj1 != null) && (obj1.GetType() == typeof(string)))
        && ((obj2 != null) && (obj2.GetType() == typeof(string))))
    {
        string text;
        string text1 = text =
            StringOps.Add(Converter.ConvertToString(obj1),
                          Converter.ConvertToString(obj2));
        return text;
     }
     return site1.UpdateBindingAndInvoke(obj1, obj2);
 }

这是从中间语言中分解出来的代码，该中间语言是 DLR 在执行“text + yourname”后为 DoOperation-Add-0 委托生成的。如果再次调用 yo 函数，此委托就会执行。您可能认识到，此代码有一些性能问题，但我们稍后再讨论该问题。您可以看到，代码会测试两个参数是否为字符串，并调用 IronPython 运行时实现，执行两个给定字符串相加功能。

如果其中一个参数不是字符串，那么委托就会调用 UpdateBindingAndInvoke，以生成新的委托。新委托将取代当前委托（即上述委托），因此，下次 DoOperation-Add-0.Invoke 运行时，就会调用更新后的委托。我更改了 msdnmag.py 代码，从而使第二次调用 yo 时“text + yourname”在两个整数上执行。随后我发现了 DLR 在第二次调用后生成的第二个委托。它看起来与图 6 类似。

Figure 6 分解的 "+" 缓存（二） (Close Figure 6)
      public static object Handle(object[]
    FastDynamicSite<object, object, object> site1,
    object obj1, object obj2)
{
    if (((obj1 != null) && (obj1.GetType() == typeof(int)))
        && ((obj2 != null) && (obj2.GetType() == typeof(int))))
    {
        return Int32Ops.Add(Converter.ConvertToInt32(obj1),
                            Converter.ConvertToInt32(obj3));
    }
    if (((obj1 != null) && (obj1.GetType() == typeof(string)))
        && ((obj2 != null) && (obj2.GetType() == typeof(string))))
    {
        return = StringOps.Add(Converter.ConvertToString(obj1),
                               Converter.ConvertToString(obj2));
    }
    return site1.UpdateBindingAndInvoke(obj1, obj3);
  }

我清除了 Reflector 插入的临时假变量。如果您做我完成的这个额外实验，您会得到稍微不同的代码。现在您会看到，该代码会测试参数是否为整数。如果它们是整数，则调用实现整数“加法”的运行时帮助器函数。我以前编写的相同代码跟随在整数测试和目标代码之后。我选择的测试顺序是先测试最新类型。通过简要介绍程序的运行，您可以想像按最佳顺序生成所有适当的参数测试。当然，如果参数有一种前所未见的新类型组合，委托就会转而求助于调用 UpdateBindingAndInvoke。

您可能注意到了代码的性能问题。DLR 如今可能生成更好的代码，不过，与任何 alpha 版本一样，DLR 对功能和代码生成有所取舍。您可能注意到，代码对一种类型（字符串）进行测试，然后将参数转换为字符串。这就存在两个问题。第一个问题是，.NET Framework 2.0 JIT 不会将“if”测试中的类型信息传播到后继的代码块中。由于“加法”实现是一种静态类型方法，那么，它的参数需要显现为字符串。第二个问题是，DLR 生成的代码执行一个对运行时转换函数的缓慢调用。DLR 在此处可能已发出了一个转换操作指令。

需要说明的最后一个性能问题是，DLR 如今并不为避免重复相同的测试而对测试进行构造。更新后的委托知道如何快速执行两个整数或两个字符串的相加，现在我用空值对参数进行测试。当我不一定要全面进行各种测试时，我多次进行这种空值测试。当 UpdateBindingAndInvoke 搜索操作实现时，它实际上询问对象如何执行操作。它请求对象提供一个如何执行由 DynamicSite 代表的操作的规则。该规则包含一个用于执行测试的 AST 和一个用于测试为真的情况下执行目标代码的 AST。DLR 可以在测试过程中执行树转换，以更进一步地优化委托的代码。

最后一点，因为我使用了动态点提取对象的成员，我必须处理对实例和动态类型的更改。DLR 无法对 o.m() 做出假定：o 如果指向同一个对象，它便确切地知道该调用哪一个 m。某些语言允许类型在运行时发生更改，并且它们可以替换成员，实际上在一个实例中覆盖一个成员。DLR 使用一个全局版本计数器，对新类型而言增加数字，对原有类型而言可更改数字。导致类型版本发生变化有多方面的原因，比如更改成员。对于任何指向对象的 DynamicSite 缓存，如果对象的类型版本发生变化，则缓存就会失败。此时，DynamicSite 就会重新搜索并更新委托。

重要的回报

生成的 DynamicSite 类型是通用类型。在 msdnmag.py 的 Python 示例代码中，使用通用类型没有多大用处。不过，在诸如“if x < 1:”这样的表达式中，您可以生成一个 DynamicSite<Object, Int, Bool>。该点的 Invoke 方法将采用静态类型，采用一个整数参数并返回一个布尔值。动态点内部的委托也以这种方式采用静态类型，从而使产生的代码速度更快。该委托只须在其缓存中测试一个参数，当堆栈弹出时，JIT 便知道一个布尔值被释放到正确的位置。这就消除了与 if 测试有关的任何转换或检查。

某些动态语言具有可选的显式类型定义。即使它们没有显式类型定义，实现也可以推断出类型，推断的依据包括对 .NET 静态方法的调用、文字常量等。它们可以通过变量的生存期、条件语句的分支等传播此类型信息。当语言具有类型信息时，它们可以生成 DLR AST，进而编译为具有完整类型信息的 DynamicSite 类型。这样就可导致在缓存中生成 Invoke 方法调用和目标代码调用的内嵌代码。

如今，DLR 生成的通用 DynamicSite 类型具有六个或更少的通用类型参数。第一个 N-1 参数代表参数的类型，而 Nth 代表操作结果的类型。在六个类型之后，必须生成仅执行对运行时帮助器函数的方法调用的 AST。这些函数必须延迟工作，以便在运行时完成全部计算。将来，DLR 可让您使用通用聚合，以提供大量的采用静态类型的参数。当我们得出绝大多数程序的最佳结合点后，我们也可能将该参数增加到六个以上。

对于如何查看特定于语言的 AST 和 DLR AST，您现在应该有了相当明确的概念。凭借简单的树上的一些基本信息，您可以检查某些语言在 DLR 上构建的方式。如果您试图在 DLR 上实现自己的语言，则可以使用此类技巧。即便您并不想实现自己的语言，只是查看一下深层次的东西也是一件有趣的事。

您还看到了 DLR 向语言实现者提供的一些共享的基础结构。从 CLR 和 DLR 身上可以利用许多东西——垃圾收集、JIT 编译、动态点、公共动态类型模型，等等。在 .NET 运行时上实现语言现在可产生相当好的结果，而且只会变得更好。第 1 版的 DLR 将会相当棒。用不了多久，在 .NET 上实现动态语言将会非常轻松，产生的结果将是出类拔萃的。

请将您想要询问的问题和提出的意见发送至 clrinout@microsoft.com.

Bill Chiles是 Microsoft 的一名高级项目经理，在他的职业生涯中，主要研究动态语言项目（CMU Common Lisp、Dylan）和工具。Bill 在 Visual Studio 核心技术团队工作了九年时间，目前负责研究 IronPython 和动态语言运行时。


基本技术: Visual Basic 2008 中的类型推断

基本技术
Visual Basic 2008 中的类型推断
Bill Horst

目录
为什么类型推断是必需的？
类型推断和 Lambda 表达式
其他含义
转到类型定义
启用和禁用类型推断

为
了支持语言集成查询 (LINQ)，即将发布且包含类型推断的 Visual Basic® 2008 中添加了一套新的技术。在 Visual Basic 较早的版本中，除非在代码中显式声明了不同的类型，否则成员类型都假定为 Object，如图 1 所示。以这种方式声明变量的缺点就是编译器只能假定变量值将为类型 Object。对开发人员有用的经验是 IntelliSense® 只能显示所有 Object 通用的成员的信息，尽管运行时的实例上可能会有其他成员。凭借后期绑定，可在运行时绑定其他成员，这意味着在执行代码行之前是不会以任何方式对它们进行验证的。因此，在代码中可能引用不存在的成员（如图 1 中的 cust.Name），而编译器不会发现此问题。这很容易会引发 MissingMemberException，而且通常需要您自行跟踪更多的信息。

Figure 1 Visual Basic 2005 缺少类型推断 (Close Figure 1)
Class Form1
    Sub Main()
        Dim a = 3                   ' Type: Object    Value: 3 {Integer}
        Dim b = DateTime.Now        ' Type: Object    Value: Current Time

        Dim cust = GetCustomer(someID)
        MsgBox(cust.Name) ' Customers have FirstName and LastName fields.
                          ' This code generates an exception at Run-Time.

        Dim procs = Process.GetProcesses
        For Each g In procs   ' Compile Error: 'g' is not declared
            Console.WriteLine(g.Id)
        Next
    End Sub
End Class

然而，使用 Visual Basic 2008 中的类型推断，可基于声明语句中任何赋值的右边来推断方法级别成员的类型。您可以在图 2 中找到几个示例。这里，编译器将会捕捉到对图 1 所示的并不存在的成员 cust.Name 的引用，而且将提供所有可用成员的 IntelliSense 信息，就象类型是显式指定的一样。在图 2 所示的 For 循环中，变量 g 被当作一个声明，类型为“procs”表达式。

Figure 2 Visual Basic 2008 中的类型推断 (Close Figure 2)
Class Form1
    Sub Main()
        Dim a = 3                   ' Type: Integer   Value: 3
        Dim b = DateTime.Now        ' Type: Date      Value: Current Time

        Dim cust = GetCustomer(someID)
        MsgBox(cust.Name) ' Here we get a compiler error, catching the
                          ' problem quickly

        Dim procs = Process.GetProcesses
        For Each g In procs         ' Type: Process
            Console.WriteLine(g.Id) ' No compile errors
        Next
    End Sub
End Class

然而，有些时候类型推断并不会发生。对于局部 Dim 语句，仅当声明行上有赋值时才会发生类型推断。所以，变量声明之外所进行的赋值将会导致编译器假定类型为 Object。此外，Object 仍被推断为类级别成员的类型，所以类型推断不适用于函数、子例程、属性、类/结构字段等。图 3 中显示了一些特定示例。

Figure 3 无类型推断的情况 (Close Figure 3)
Class Form1
    Sub Main()
        Dim d         ' Assignment not on the declaration line
        d = 3         ' Type: Object    Value: 3 {Integer}
    End Sub
                      ' Class-level
    Dim e             ' Type: Object    Value: Nothing
    Dim f = 3         ' Type: Object    Value: 3 {Integer}

    Function Func1()  ' Type: Object    Value: 3 {Integer}
        Return 3
    End Function
End Class

为什么类型推断是必需的？

由于新的语言结构允许创建并未在任何地方显式定义的类型，所以在 Visual Basic 2008 中类型推断是必需的。为了将变量分配给具有非显式定义类型的表达式，必须推断类型，因为无法在代码中指定它。

这些非显式定义的类型称为匿名类型。在之前未定义的情况下，可创建对象和初始化某些成员。这使 LINQ 语句能够在无需提前为每个可能的变量定义对象的情况下指定不同数量的字段（且以不同的次序）。因为对象不必匹配已定义的类型，所以编译器可隐式生成类型（因为此类型的名称不重要，而且不会公开给开发人员供其在代码中使用，所以它称为匿名类型）。使用类型推断功能，编译器能够同处理显式定义类型的对象一样处理此匿名类型对象。对于用 Visual Basic 编写 SQL 样式查询的 LINQ 用户来说，匿名类型最有用。

以下是匿名类型如何帮助您进行 LINQ 开发的示例。假设您有一个包含贵公司订单记录的数据库，而且您想要查看已向华盛顿州发货的所有订单的某些信息。以下 SQL 代码假定了一个名为 Orders 的表，表中的记录包含了各种字段，其中包括 ID、Date、Address、City、State、Zip 和 Shipped。查询将会返回表中 Shipped 字段值为 True 且 State 字段值为 Washington 的所有记录：

SELECT Orders.ID, Orders.Date AS OrderDate, Orders.Address,
       Orders.City, Orders.State, Orders.Zip
FROM Orders
WHERE Shipped = 'True' AND State = 'Washington'

随着 LINQ 的发展，Visual Basic 中可执行类似的操作。以下代码对 Orders 执行相同的查询，并将结果分配给变量 OrdersShippedToWA。Orders 可能是数据库表、内存中的数组或集合，也可能是实现 IEnumerable 接口的某个类型：

Dim OrdersShippedToWA = From Ord In Orders _
                        Where Ord.Shipped _
                            And Ord.State = "Washington" _
                                Select Ord.ID, OrderDate = Ord.Date, _
                                Ord.Address, Ord.City, _
                                Ord.State, Ord.Zip

将为 OrdersShippedToWA 推断的类型是 IEnumerable(Of <anonymous type>)。那么，IEnumerable 中的每个元素都将会拥有查询中选择的成员，即 ID、OrderDate、Address、City、State 和 Zip。应该可以想象，如果对要在查询表达式的 Select 部分中使用的每个成员组合都无需定义类型，那么 LINQ 会更容易使用且更灵活。因此，Visual Basic 编译器生成包含相应成员的匿名类型，并在代码后面引用某元素时推断该类型。在以下的 For 循环中，变量 Ord 的类型推断为我为 OrdersShippedToWA 讨论过的同一匿名类型。

For Each Ord In OrdersShippedToWA
    Dim City As String = Ord.City
Next

在此示例中，变量 Ord 具有一个未显式声明的类型，但其成员名为已知。后台编译器能够推断该类型，而且您可获得您已习惯的相同 IntelliSense 支持。这就是类型推断已添加到 Visual Basic 2008 中的主要原因。

请设想另一个情景，您想要处理整个 Order 对象。如果查询只选择了一个表达式，则会推断特定的类型。如果为集合中使用的类型定义了方法或其他成员，那么也可通过类型推断调用它们：

Dim SortedWAOrders = From Ord In Orders _
                     Select Ord _
                     Order By Ord.ID Ascending

Dim FirstShipTime = SortedWAOrders(0).CalculateShipTime()

实际上，正是因为有了类型推断，您才可以在查询本身中使用自己的方法。请考虑以下示例，其中结果按方法计算所得的发货时间次序返回：

Dim SortedShipTimes = From Ord In Orders _
                      Select Ord _
                      Order By Ord.CalculateShipTime() Ascending

有些时候，在代码中初始化匿名类型会比较方便，因此 Visual Basic 也同样给予支持。在图 4 中，A 部分显示了创建的 NewCustomer 变量，且无显式类型。请注意，定义中成员的类型也被加以推断，因为未在任何地方定义它们。B 部分显示了另一个赋值，其中编译器识别了匿名类型的某个成员。当在 NewCustomerCity 赋值中引用了变量时，则会对该变量提供 IntelliSense 功能支持。在 C 部分中，则会初始化一个称为 SeattleCustomers 的查询。该查询选择能匹配匿名类型成员的成员。在此例中，编译器识别出已存在匿名类型，且该匿名类型与此语句所需的匿名类型相匹配。为 SeattleCustomers 推断的类型与为 NewCustomer 推断的类型相同，因而无需创建新的匿名类型。在 E 部分中，分配给 NewCustomer 的对象可添加到来自 SeattleCustomers 查询的数据，因为识别同一匿名类型的类型推断可在这两处使用。

Figure 4 从查询和变量匹配的匿名类型 (Close Figure 4)
'[Part A] Anonymous Type variable declared
Dim NewCustomer = New With {.Name = "Bill Horst", _
                            .Age = 25, _
                            .City = "Seattle"}

'[Part B] Member recognized by Compiler/IntelliSense
Dim NewCustomerCity = NewCustomer.City

'[Part C] Name, Age and City selected from records in
'         Customers database table
Dim SeattleCustomers = From cust In db.Customers _
                       Where cust.City = "Seattle" _
                       Select cust.Name, cust.Age, cust.City

'[Part D] Seattle Customers stored in a variable with
'         type List(Of <anonymous type>)
Dim SCList = SeattleCustomers.ToList()

'[Part E] Variable Type and Query Result Type match
SCList.Add(NewCustomer)

类型推断和 Lambda 表达式

类型推断还有助于容纳 Lambda 表达式。Lambda 表达式是一种新的 Visual Basic 语言功能，能够以内联方式使用简单函数的委托。这在将简单的委托当作参数传递时特别有用。在甚至无需命名函数的情况下，便可以传递 Lambda 表达式，声明要返回的参数和表达式。在以下示例中声明了一个函数，该函数接受 Customer 和 String 并返回一个布尔值，表示 Customer 的城市是否与 String 匹配。然后该函数将作为委托传递给 Filter 方法，可能是要根据此布尔值进行筛选：

Orders.Filter( _
    Function(cust As Customer, city As String) cust.City = city)

因为这实际上是一个没有返回类型的函数定义，所以在确定表达式的类型以声明正确的委托并验证该类型是否有效方面，类型推断将如何起到作用就变得显而易见了。

其他含义

当 Option Explicit 关闭时，可以在代码中使用局部变量，而无需显式声明。与上述例子一样，变量假定为类型 Object 并且所有调用都是后期绑定的。值得注意的是，隐式定义的变量不会发生类型推断，因此行为完全不会改变。以下代码显示一个示例：

Sub SwapTwoInts(ByVal x As Integer, ByVal y As Integer)
    z = x
    x = y
    y = z
End Sub

在使用 Visual Basic 2005 时，可以在即时窗口中隐式声明临时变量，同时可通过将一些表达式分配给在当前范围内未定义的标识符进行调试。结果是创建了类型为 Object 的变量，并为其分配了指定值，基本就如同 Option Explicit 关闭时在代码中进行此赋值一样。在 Visual Basic 2008 中，我介绍的即时窗口行为和 Option Explicit 关闭时的行为与它们在 Visual Basic 2005 中是一样的，因此不会发生类型推断。如果我将以下两个命令输入到即时窗口，则会显示以下结果，表明 Object 变量得到了一个整数值 12：

intVar = 12
?intVar
12 {Integer}

转到类型定义

当类型在变量定义中显式给定时，可轻松从类型标识符的上下文菜单调用 Visual Studio®“转到定义”操作，以确定类型在何处定义。在以下示例中，用户可以单击 IEnumerable，结果将是显示在对象浏览器中的 IEnumerable(Of T)：

Dim customers As IEnumerable(Of Customer) = GetCustomers()

基于本文所讨论的新语言功能，我预期更频繁出现的情况是，一下子就想辨别类型是非常困难的。类型出于类型推断的原因而无需成为显式，而且由于新的语言结构支持 LINQ，所以它可以变得非常模糊。以下变量的类型也是 IEnumerable(Of T)，但是在这里它要不明显得多，而且也没有地方调用“转到定义”以导航到其类型：

Dim customers = From cust In _
                    (From c In db.Customers Select c) _
                Where cust.FirstName = "Danae" _
                Select cust.LastName, cust.Address

For Each c In customers
...

此问题就是促使 Visual Studio 2008 引入“转到类型定义”命令的原因。您还可以从上下文菜单调用该命令，所以即使在代码中仅存在某变量标识符时，仍可以找到该变量类型的定义。您可以对变量或引用的定义执行“转到类型定义”命令。在之前的代码段中，您只需对定义或 For 循环中的客户变量调用“转到类型定义”即可，随之您将进入对象浏览器，其中的 IEnumerable(Of T) 已经选中（参见图 5）。相反，如果在代码中显式定义了该类型，您将会导航到该位置。

图 5 For 循环中的“转到类型定义”命令
图 5 For 循环中的“转到类型定义”命令

启用和禁用类型推断

类型推断支持的主要影响之一就是在启用了类型推断的项目中无法编译用 Visual Basic 早期版本编译的一些代码。这就是为什么升级后的项目会在默认情况下关闭类型推断。可能发生这些生成错误的主要区域是使用后期绑定的代码部分。

使用 Visual Basic 的早期版本时，当 Option Strict 处于关闭状态时会发生后期绑定，而且没有显式类型的变量会初始化为一个表达式。该变量的给定类型是 Object，对该变量的任何成员或方法调用都将在运行时被绑定：

' Late Binding with Visual Basic 2005
Dim x = 5
x = New List(Of Integer)
x.Sort()
x = New Queue(Of Integer)
x.Enqueue(6)

使用类型推断后，此类调用将会导致编译器错误。编译器会给予变量一个与将该变量初始化的表达式相匹配的类型，而不是给予变量类型 Object。启用类型推断后仍可使用后期绑定，但必须显式给予 Object 作为类型：

' Late Binding with Visual Basic 2008
Dim x As Object = 5
x = New List(Of Integer)
x.Sort()
x = New Queue(Of Integer)
x.Enqueue(6)

有些时候您也可能希望关闭类型推断。庆幸的是，有一个与 Option Strict 或 Option Explicit 不同的编译器选项，称为 Option Infer，它可让您打开或关闭类型推断。与 Visual Basic 中所有的编译器选项一样，它位于代码文件的开头：

Option Infer On    ' This enables type inference for the file

Option Infer Off   ' This disables type inference, so declarations
                   ' with no type specified will use Object

如果没有为特定的代码文件指定 Option Infer，则会使用项目级设置。在“项目设计器”的“编译”选项卡上可以设置 Option Infer 的项目级默认设置。从“项目”菜单或“解决方案资源管理器”的“我的项目”节点可以访问“项目设计器”。

在新的 Visual Basic 2008 项目中，Option Infer 默认为打开状态，对升级项目则默认为关闭状态，以避免破坏以前存在的代码。文件级设置可为特定文件覆盖此选项。

使用类型推断有许多优势，您将会看到，当您开始使用 LINQ 和受益于它的其他技术时，您可以指定何时调用它、何时不调用它，从而为您提供了最大的灵活性。

请将您想要向 Bill 询问的问题和提出的意见发送至 instinct@microsoft.com.

Bill Horst 是 Microsoft Visual Basic 团队从事测试的软件设计工程师。他完成过很多 LINQ 查询的测试工作，而且还测试了 Visual Basic 程序的调试问题。


数据点: 通用表表达式

数据点
通用表表达式
John Papa

代码下载位置: DataPoints2007_10.exe (150KB)
目录
视图、派生表和 CTE
CTE 的结构
理解 CTE
递归规则
递归偏移
结束语

开
发人员正在研发的许多项目都涉及编写由基本的 SELECT/FROM/WHERE 类型的语句派生而来的复杂 SQL 语句。其中一种情形是需要编写在 FROM 子句内使用派生表（也称为内联视图）的 Transact-SQL (T-SQL) 查询。这一常规使开发人员能获取一个行集，并立即将该行集加入到 SELECT 语句中的其他表、视图和用户定义函数中。另一种方案是使用视图而不是派生表。这两种方案都有其各自的优势和劣势。

当使用 SQL Server™ 2005 时，我更倾向于第三种方案，就是使用通用表表达式 (CTE)。CTE 能改善代码的可读性（以及可维护性），且不会有损其性能。此外，与早期版本的 SQL Server 相比，它们使得用 T-SQL 编写递归代码简单了许多。

在本月的专栏中，我将着重探讨如何使用 CTE 来应对常见的开发情况。首先，我将介绍 CTE 的工作原理以及可用它们来应对的情况。接着我将讨论使用 CTE 相对于使用传统的 T-SQL 构造的优势，如派生表、视图和自定义过程。在本期专栏中，我将给出示例并解释它们的使用方法和适用情况。我还将演示 CTE 是如何处理递归逻辑并定义递归 CTE 的运行方式的。请注意，我在本专栏中讨论的所有代码都可从《MSDN® 杂志》网站下载获得，而且它们还使用 SQL Server 2005 附带的 Northwind 和 AdventureWorks 数据库。

视图、派生表和 CTE

如果查询需要在一组数据中进行选取，而这些数据在数据库中并不是以表的形式存在，则 CTE 可能非常有用。例如，您可能想要编写一个针对一组聚合数据的查询，该聚合数据基于客户及其订单来计算值。这些聚合数据可能会将 Customers、Orders 和 Order Details 表联接在一起，以计算订单的总和以及平均值。此外，您可能想要查询聚合的行集。一个方法是创建一个视图，首先收集聚合数据，然后针对该视图编写一个查询。另一个方法是使用派生表针对聚合数据编写一个查询 通过将 SQL 语句移到 FROM 子句中并对其进行查询，可实现这一点。

视图通常用来分解大型的查询，以便用更易读的方式来查询它们。例如，一个视图可以表示一个 SELECT 语句，该语句会将 10 个表联接起来，选择许多列，然后根据涉及的一组逻辑来过滤行。接着，可以通过其他 SELECT 语句在整个数据库中查询该视图。此抽象使由该视图表征的行集更容易访问，而且无需在临时表中复制或存储数据。

假定权限许可，这个视图还能在整个数据库中被重复使用。例如，在图 1 中，已经创建了一个视图，并为另一个 T-SQL 语句所使用。然而，当您想要收集数据并且只使用一次的时候，视图未必是最佳解决方案。由于视图是存在于数据库中、适用于所有批处理的数据库对象，那么创建仅用于单个 T-SQL 批处理的视图就有些多余。

Figure 1 被查询的视图 (Close Figure 1)
CREATE VIEW vwMyView AS
SELECT
    EmployeeID, COUNT(*) AS NumOrders,  MAX(OrderDate) AS MaxDate
FROM Orders
GROUP BY EmployeeID
GO

SELECT
    e.EmployeeID, oe.NumOrders, oe.MaxDate, e.ReportsTo AS ManagerID,
    om.NumOrders, om.MaxDate
FROM
    Employees AS e
    INNER JOIN vwMyView AS oe ON e.EmployeeID = oe.EmployeeID
    INNER JOIN vwMyView AS om ON e.ReportsTo = om.EmployeeID

另一种方法是创建派生表（也称为内联视图）。要创建派生表，在由括号包围的 FROM 子句中移动 SELECT 语句即可。接着就能像表或视图一样查询或者联接它。图 2 中的代码解决的查询与图 1 所解决的相同，但使用的是派生表而不是视图。尽管只能在派生表所在的语句中访问它们，但是，表通常使查询变得更难以阅读和维护。如果想要在同一个批处理中多次使用派生表，此问题会变得更加严重，因为随后必须复制和粘贴派生表才能重复使用它。

Figure 2 使用派生表的查询 (Close Figure 2)
SELECT
    e.EmployeeID, oe.NumOrders, oe.MaxDate,  e.ReportsTo AS ManagerID,
    om.NumOrders, om.MaxDate
FROM
    Employees AS e
    INNER JOIN
        (SELECT EmployeeID, COUNT(*), MAX(OrderDate)
         FROM Orders
         GROUP BY EmployeeID) AS oe(EmployeeID, NumOrders, MaxDate)
        ON e.EmployeeID = oe.EmployeeID
    LEFT JOIN
        (SELECT EmployeeID, COUNT(*), MAX(OrderDate)
         FROM Orders
         GROUP BY EmployeeID) AS om(EmployeeID, NumOrders, MaxDate)
        ON e.ReportsTo = om.EmployeeID

CTE 非常适用于此类情形，因为它提升了 T-SQL 的可读性（就像视图一样），而且能在同一个批处理后紧跟的查询中多次使用。当然，超出该范围它就不适用了。另外，CTE 是语言级别的构造，也就是说 SQL Server 不会在内部创建临时表或虚拟表。每次在紧随其后的查询中引用 CTE 的底层查询时都会调用它。

因此，同样的情形也能用 CTE 来编写，如图 3 所示。EmpOrdersCTE 收集聚合数据，然后在紧随 CTE 之后的查询中使用该数据。使用 CTE 之后，图 3 中的代码令查询变得非常易读（就像视图一样），而且并没有创建系统对象来存储元数据。

Figure 3 使用 CTE 查询 (Close Figure 3)
;WITH EmpOrdersCTE (EmployeeID, NumOrders, MaxDate) AS
(
  SELECT EmployeeID, COUNT(*), MAX(OrderDate)
  FROM Orders
  GROUP BY EmployeeID
)

SELECT
    e.EmployeeID,  oe.NumOrders, oe.MaxDate,
    e.ReportsTo AS ManagerID,  om.NumOrders, om.MaxDate
FROM
    Employees AS e
    INNER JOIN EmpOrdersCTE oe ON e.EmployeeID = oe.EmployeeID
    LEFT JOIN EmpOrdersCTE om ON e.ReportsTo = om.EmployeeID

CTE 的结构

现在我将用一个简单的 CTE 为例来演示如何构造 CTE。CTE 以 WITH 关键字开始。然而，如果 CTE 不是批处理中的第一个语句，则必须在 WITH 关键字前添加一个分号。作为最佳做法，我倾向于在所有的 CTE 之前都加上一个分号作为前缀，我发现这种一致的方式比起必须牢记是否需要添加分号来，要容易得多。

WITH 关键字后面是 CTE 的名称，接着是一个列别名的可选列表。列别名对应于 CTE 内的 SELECT 语句返回的列。可选列别名的后面是 AS 关键字，这是必需的。AS 关键字后面是用括号括起来、定义 CTE 的查询表达式。

请看这个示例：

;WITH myCTE (CustID, Co) AS
(
  SELECT CustomerID, CompanyName FROM Customers
)
SELECT CustID, Co FROM myCTE

CustomerID 和 CompanyName 列的别名为 CustID 和 Co。接着跟随 CTE 其后的是通过列别名引用 CTE 的 SELECT 语句。

理解 CTE

在设计 CTE 之前，必须理解它的工作原理和遵循的规则。本章节描述了 CTE 的适用情况，以及在 CTE 内什么是可以使用的，什么是不可以使用的。对于初学者来说，可以在 T-SQL 批处理、用户自定义函数、存储过程、触发器或视图中创建并使用 CTE。

CTE 仅能被紧随其后的语句所引用。这意味着如果要使用 CTE，则必须紧随 T-SQL 批处理中的 CTE 之后编写引用 CTE 的查询。例如，以下批处理会产生错误：

;WITH myCTE (CustID, Co) AS
(
  SELECT CustomerID, CompanyName FROM Customers
)
SELECT CompanyName FROM Customers WHERE CustomerID = 'ALFKI'
SELECT CustID, Co FROM myCTE

在本段代码中，myCTE 仅供紧随其后的第一个查询使用。当第二个查询引用 myCTE 时，CTE 不在范围之内，并且引发异常（对象名“myCTE”无效）。

另请注意，因为 CTE 预期由另一个可能随之肯定要重新处理数据的查询引用，所以 CTE 的查询不能含有 ORDER 和 COMPUTE 之类的语句。然而，诸如 FOR XML 的复杂语句仍可用来定义和运行 CTE。例如，您可以使用 FOR XML 子句来查询 CTE 并返回其结果，如下所示。

;WITH myCTE AS
(
  SELECT c.CustomerID, c.CompanyName, o.OrderID, o.OrderDate
  FROM Customers c
  INNER JOIN Orders o ON c.CustomerID = o.CustomerID
)

SELECT CustomerID, CompanyName, OrderID, OrderDate
FROM myCTE FOR XML AUTO

一旦定义了 CTE，跟随其后的首个查询便能多次引用它。这一功能在某个查询需要多次引用 CTE 时尤为有用。图 3 中的代码示例演示了查询如何引用 EmpOrdersCTE 两次，以便能获取员工和主管的信息。当需要多次引用同一行集时，这非常有用；引用 CTE 两次比复制该查询要简单得多。

CTE 并不一定由 SELECT 语句使用；任何引用 CTE 所生成行集的语句都可使用它。这意味着 CTE 后面可以跟随使用 CTE 的 SELECT、INSERT、UPDATE 或 DELETE 语句。您也可以在使用 CTE 的查询中使用只进和快照光标。

此外，CTE 后面也可以跟随另一个 CTE。在想要把中间结果聚集到行集时，可使用这种技术从其他 CTE 构建 CTE。当创建从其他 CTE 构建的 CTE 时，请用逗号分隔 CTE 的定义。

图 4 所示的示例定义了 EmpOrdersCTE，它收集了一个员工列表和每个员工的订单总数。第二个 CTE 的名称是 MinMaxOrdersCTE，它查询第一个 EmpOrdersCTE 并在行集上执行聚合函数来确定员工订单的平均数、最小数和最大数。

Figure 4 引用另一个 CTE 的 CTE (Close Figure 4)
;WITH
EmpOrdersCTE (EmployeeID, NumOrders)
AS
(
  SELECT EmployeeID, COUNT(*)
  FROM Orders
  GROUP BY EmployeeID
),
MinMaxOrdersCTE (Mn, Mx, Diff)
AS
(
  SELECT MIN(NumOrders), MAX(NumOrders), AVG(NumOrders)
  FROM EmpOrdersCTE
)
SELECT Mn, Mx,    Diff
FROM MinMaxOrdersCTE

通过用逗号来分隔，在 WITH 关键字后面可以定义多个 CTE。每个 CTE 都可以被紧随其后的 CTE 所引用，形成层接的构建关系。CTE 定义后面的数据操作语言 (DML) 语句也可引用 WITH 子句中定义的任何 CTE。

递归规则

CTE 还可用于实现递归算法。在需要编写调用其本身的算法时，递归逻辑很有用——这通常用来遍历一组嵌套的数据。编写递归逻辑可能很复杂，特别是使用 T-SQL 之类的语言的时候。然而，这正是 CTE 旨在解决的特别问题之一。创建递归 CTE 的基本公式如下所示：

  1. 创建一个返回顶层（这是定位点成员）的查询。
  2. 编写一个递归查询（这是递归成员）。
  3. 通过 UNION 将第一个查询与递归查询结合起来。
  4. 确保存在没有行会被返回的情况（这是终止检查）。

您的递归 CTE 如下所示：

;WITH myRecursiveCTE(col1, col2, ... coln) AS
(
  -- Anchor Member Query
  UNION ALL
  -- Recursive Member Query that references myRecursiveCTE
)

当编写不涉及 CTE 的自定义递归过程时，必须包含一个显式终止子句。这个终止子句负责确保递归算法最后将终止，并弹出了递归调用堆栈。若无此子句，您的代码最终将无限循环下去。

CTE 可从两个方面帮助处理终止子句。首先是一个隐式终止子句，当递归成员返回零记录时出现。此时，递归成员查询不会递归调用 CTE，取而代之的弹出了递归调用堆栈。其次是能显式设置 MAXRECURSION 层。

MAXRECURSION 层可以在含有 CTE 的批处理中或通过服务器端设置（服务器范围的设置默认值为 100，除非您更改它）显式设置。这个设置限制了 CTE 可递归调用其本身的次数。如果达到限制次数，则将引发异常。设置 MAXRECURSION 层的语法是在 SELECT 语句中的 CTE 后面使用 OPTION 子句，如下所示：

-- DEFINE YOUR CTE HERE
SELECT * FROM EmpCTE
OPTION (MAXRECURSION 7)

在设计递归 CTE 时，还有一些其他规则也得铭记在心。递归 CTE 必须包含定位点成员和递归成员。这两种成员必须拥有相同数量的列，而且同属于这两种成员的列必须具有匹配的数据类型。递归成员只能引用 CTE 一次，并且成员不能使用下列子句或关键字：

  • SELECT DISTINCT
  • GROUP BY
  • HAVING
  • TOP
  • LEFT/RIGHT OUTER JOIN

递归偏移

在数据和行集方面，递归用于解决您需要在不同情况下针对同一组数据重复执行同一逻辑时所遇到的问题。例如，假设您需要找出所有销售人员以及他们的上级，然后以分层顺序返回数据。图 5 演示了一个使用 CTE 的解决方案，该方案通过递归来收集销售副总裁下属的员工的列表。

Figure 5 递归收集销售人员 (Close Figure 5)
;WITH EmpCTE(EmployeeID, EmployeeFirstName,
 EmployeeLastName, MgrID, SalesLevel)
AS
(
  -- Anchor Member
  SELECT EmployeeID, FirstName, LastName, ReportsTo, 0
  FROM Employees
  WHERE EmployeeID = 2 -- Start with the VP of Sales

  UNION ALL

  -- Recursive Member
  SELECT
    e.EmployeeID, e.FirstName, e.LastName, e.ReportsTo, m.SalesLevel+1
  FROM
    Employees AS e
    INNER JOIN EmpCTE m ON e.ReportsTo = m.EmployeeID
)

 -- Using the CTE
SELECT EmployeeID, EmployeeFirstName, EmployeeLastName,
       MgrID, SalesLevel
FROM EmpCTE

除了少数其他方面，图 5 中所示的递归 CTE 看起来很像标准 CTE。其中标准 CTE 包含一个定义行集的查询，递归 CTE 定义两个查询定义。第一个查询定义（即定位点成员）定义了将在调用 CTE 时执行的查询。第二个查询定义，即递归成员，定义了一个返回与定位点成员相同的列和数据类型的查询。递归成员还检索接下来将被用于递归回调到 CTE 的值。查询的结果通过 UNION 语句结合在一起。

图 5 中的 EmpCTE 显示了收集销售副总裁的员工记录的定位点成员 (EmployeeID = 2)。定位点成员查询的最后一列返回 0 值，这表示分层顺序的第 0 层，也就是最顶层。递归成员查询收集向其各自上级汇报的员工的列表。这通过联接 Employees 表和 EmpCTE 来实现。

从递归成员中可以检索相同的列，但是 SalesLevel 列的计算方式是：取当前员工的主管，收集主管的 SalesLevel，然后在其基础上增加 1。表达式 m.SalesLevel+1 为所有直接向销售副总裁汇报的员工（从定位点成员中检索到）的 SalesLevel 赋值 1。然后，所有向那些员工汇报的员工的 SalesLevel 值变为 2。对随后销售机构层次结构中的每一后续层次，其 SalesLevel 都以此方式递增。

结束语

比起那些在查询中使用复杂的派生表或引用那些在 T-SQL 批处理外部定义的视图的方案，CTE 使得编写 T-SQL 更具可读性。此外，CTE 还为解决使用递归算法的过程中遇到的难题提供了一个更先进的工具。不管您使用的是非递归 CTE 还是递归 CTE，您都会发现 CTE 可以帮您应对许多常见开发情况，并且可在不损害性能的情况下提升可读性。

请将您想要向 John 询问的问题和提出的意见发送至 mmdata@microsoft.com.

John Papa 是 ASPSOFT (aspsoft.com) 的一名高级 .NET 顾问，也是一名棒球迷，在夏季的夜晚，他的大多数时光都是和家人及其忠实的狗 Kadi 一起为洋基队加油助威度过的。John 是 C# 领域的一位 MVP，撰写过多本有关 ADO、XML 和 SQL Server 方面的书籍。他经常在行业会议（如 VSLive）上发表演讲，或者在 codebetter.com/blogs/john.papa 上撰写博客文章。


领先技术: AJAX 应用程序体系结构，第 2 部分

领先技术
AJAX 应用程序体系结构，第 2 部分
Dino Esposito

代码下载位置: CuttingEdge2007_10.exe (151KB)
目录
通过 AJAX 登录
服务的风格
AJAX 服务返回什么？
JSON 与 XML 之比较
安全性考虑
ASP.NET AJAX 中的服务
ASP.NET AJAX 服务和 SOAP
调用 AJAX 服务
构建用户界面
这一切意味着什么

使
用 ASP.NET AJAX Extensions 将您的站点过渡到 AJAX 体验时，有两个主要的编程模型可供选择：部分呈现和脚本服务。在上个月的专栏中，我主要从体系结构的角度论述了部分呈现。简而言之，使用部分呈现，您无需更改 ASP.NET 应用程序的底层体系结构——它是实现 AJAX 某些最佳元素（如站点页面的无闪烁更新）的便捷途径。实现此类改进行为只需添加一些新的服务器控件（特别是 ScriptManager 和 UpdatePanel），并让它们悄悄地施展一些技巧，通过 XMLHttpRequest 对象运行的异步请求来转换传统的回发。此方法很容易实现，因为它只是将 AJAX 功能应用于现有的 Web 开发模型。

如果您准备对构建 AJAX 应用程序实行全面的模式转换，那么就应该了解一下脚本服务的方法。总的来说，典型的 AJAX 体系结构相当容易理解。图 1 展示了其工作原理的高层视图。其中有一个由应用程序特定服务组成的后端，通常只是可调用 AJAX 脚本的外层，其下方是业务逻辑所在和发挥作用的系统中间层。服务与前端通过 HTTP 交换数据，使用多种格式传递参数和返回值。前端由运行于客户端上的 JavaScript 代码组成，在接收和处理完数据后，它面临着使用 HTML 和 JavaScript 构建图形用户界面的重大任务。对 JavaScript 的依赖是由于受浏览器结构的限制，只有当浏览器可以支持功能更加强大的编程功能时，这种情况才会改变。

图 1 典型的 AJAX 体系结构
图 1 典型的 AJAX 体系结构

通过 AJAX 登录

抛弃传统的 ASP.NET 模型会引起许多实际反响。考虑一下登录过程，看看在纯 AJAX 解决方案中会发生怎样的变化。

当前对 ASP.NET 来说，启用登录过程包括：用 Login 控件配置登录页面，用 LoginView 控件配置受保护的页面，设置 ASP.NET 成员身份提供程序。受保护的页面将使用登录模板或匿名模板以图形方式反映身份验证过程的结果。在 ASP.NET 2.0 中，您无需编写任何代码即可完成大部分此类工作。要成功地进行登录，需要有用户到达受保护页面时发生的重定向 (HTTP 302)、从登录页面到身份验证凭据的回发，然后是回到原先请求页面的另一次重定向。

对于 AJAX 页面来说则并不一定如此。如果用户从地址栏请求一个受保护的页面，除了重复 ASP.NET 的历程之外，您并不能做什么。不过，如果您在页面中有指向受保护页面的链接，则可以将某些脚本添加到该链接的 onclick 事件上，以检查用户是否已通过身份验证。如果没有，则可以弹出一个警告框以提醒用户，如下所示：

function checkFirst()
{
    var loggedIn = Sys.Services.AuthenticationService.get_isLoggedIn();
    if (!loggedIn)
    {
        alert("You must be logged in to follow this link");
        return false;
    }
    return true;
}

如您所见，除了其他方面之外，此方法使用一个客户端框架来帮助您检查当前用户是否已登录。凭借 ASP.NET AJAX Extensions 即可通过 Sys.Services.AuthenticationService 类实现该功能。

同样的服务可用于在用户指定有效凭据后对其进行身份验证。在这种情况下，不发生任何重定向和回发。一切都发生在同一页面的环境中，如下面的代码段所示：

function OnLogin()
{
   Sys.Services.AuthenticationService.login(
      $get("UserName").value,
      $get("Password").value,
      false,
      null,
      null,
      OnLoginCompleted,
      OnLoginFailed);
}

检查凭据成为使用脚本服务进行身份验证的标准脚本操作。这完全要靠开发人员来更新客户端页面的 UI，以反映身份验证成功后用户已处于登录状态。除非您使用的框架提供了一个将 HTML 包装在控件中的对象模型，否则，编写文档对象模型 (DOM) 脚本的过程要长得多。注意，ASP.NET AJAX Extensions 目前尚不提供用控件代表标记块的对象模型。

服务的风格

在 AJAX 中，服务表示驻留在应用程序域并向客户端脚本代码公开功能的一段代码。AJAX 中使用的服务需要进行某种设计，以便实现对实际应用程序后端和中间层的保护，避免其与最终用户直接交互。此类服务是 WS-* Web 服务吗？它可以是面向服务的体系结构 (SOA) 服务吗？

最适合 AJAX 应用程序的服务主要涉及向 Web 客户端公开数据和资源。它可以通过 HTTP 获得，并要求客户端使用 URL（也可以是 HTTP 头）访问数据和命令操作。客户端与服务进行交互使用的是 HTTP 动词，如 GET、POST、PUT 和 DELETE。换句话说，URL 代表一个资源，而 HTTP 动词描述了您想对资源采取的操作。在这些交互中交换的数据以简单格式表示，如 JSON 和纯 XML，也可以整合格式表示，如 RSS 和 ATOM。

具有这些特征的服务是 Representational State Transfer (REST) 服务。有关 REST 定义的详细信息，请阅读描述 REST 前景的原作（在 ics.uci.edu/~fielding/pubs/dissertation/top.htm 中提供）。

最后，AJAX 应用程序所使用的服务并不倾向于使用 SOAP 进行通信，而且不一定是 SOA 意义上的自治服务。相反，它们与承载自身的平台和域相绑定。基于这一点，它们几乎不能称为 WS-* Web 服务或 SOA 服务。

此外，这些服务是不使用公共文档资料或发现架构的范例，这一点与 WS-* Web 服务的 Web 服务描述语言 (WSDL) 这类事物不同。这会减少依附于服务的依赖关系的数量，并使服务代码更加迅速地演变。总而言之，为 AJAX 服务推荐的模式不像 Web 和 SOA 服务背后的模式那样雄心勃勃，但对于将要使用它的环境来说，它依然十分有效。

注意，在本专栏的其余部分，我将使用“AJAX 服务”的说法表示通过脚本服务方法实现 AJAX 应用程序后端的服务。

AJAX 服务返回什么？

既然公开 AJAX 服务的唯一方式是通过 HTTP，那么就几乎可以使用任何文本格式来包装请求和响应的主体。JavaScript Object Notation (JSON) 是最常用的格式，但也可使用其他格式，如纯 XML 和原始文本。

JSON 是基于文本的格式，用于跨应用程序的各层移动对象的状态。JSON 字符串通过常见的 eval 函数便可方便地赋给 JavaScript 对象。JSON 格式描述了对象，如下所示：

{"ID":"ALFKI", "Company":"Alfred Futterkiste"}

该字符串表示一个对象有两个属性，即 ID 和 Company，以及它们各自的文本序列化值。如果对某个属性赋予非基本类型的值（比如自定义对象），那么该值将递归地序列化为 JSON，如下所示：

 "ID":"ALFKI",
 "Company":"Alfreds Futterkiste",
 "Location":
      "{"City":"Berlin", "Country":"Germany"}",
}

使用 eval 函数进行处理时，JSON 字符串将变成一个关联性数组（即一种名称/值的集合），其中每个条目都有一个名称和值。如果 JSON 字符串用于代表一个自定义对象（比如 Customer）的状态，那么，您必须负责确保客户端具有相应类的定义。换句话说，JavaScript 的 eval 函数只是将 JSON 字符串中的信息提取到一个通用容器。如果您需要将此信息公开为一个自定义对象（比如 Customer 对象），那么提供类定义并将数据载入到其中的任务就完全依靠您或您使用的框架来完成。有关 JSON 语法和用途的更多信息，请参阅 www.json.org。

JSON 与 XML 之比较

多年以来，XML 已被推崇为 Web 通用语言。我们知道，现在 AJAX 正在改变着 Web，就数据表示而言，JSON 更受欢迎，而 XML 正在被推向角落。

JSON 稍微简单些，更适合与 JavaScript 语言配合使用。您可能会争辩哪一个更容易为人们所理解，不过，Web 浏览器处理 JSON 肯定比处理 XML 更容易。使用 JSON 时，您不需要 XML 分析器之类的任何东西。为分析文本所需的一切都已经完全构建在 JavaScript 语言中了。因为 JSON 不像 XML 那样雄心勃勃，它也不那么冗长繁杂。这并不是说 JSON 就很完美；JSON 需要大量的逗号和引号，这使它的格式显得十分怪异。

凭借 JSON，您还能以相对较低的成本在体系结构方面嬴得关键优势。您按照对象无处不在的思路进行推理。在服务器上可定义一些实体，并用您最喜爱的托管语言将它们实现为类。当某个服务方法需要返回任何类的一个实例时，该对象的状态被序列化为 JSON 并通过线路传送。客户端接收并处理 JSON 字符串，并将其内容载入一个数组或一种与服务器类有相同接口的镜像 JavaScript 对象。类的接口是从 JSON 流推断出来的。这样，服务和客户端页面的代码便使用一个实体的同一逻辑定义。

单纯从技术角度来说，AJAX 服务并不严格要求 JSON 实现为数据表示格式。使用 XML 也可实现同样的结果，但随后就需要可从 JavaScript 使用的 XML 分析器。在 JavaScript 中分析某些简单的 XML 可能不成问题，但使用一个完备的分析器就是另外一种情形了。性能和功能相关的问题将可能导致存在大量表面上相似而实际上共同点很少的组件。随之而来的问题就是 JavaScript XML 分析器是否支持命名空间、架构、空格、注释、处理指导等。

依我看，出于兼容性的考虑，最终的产物将是 XML 的一个子集，仅限于节点和属性。那样的话，这就只是在尖括号的 XML 和花括号的 JSON 之间作出选择的问题了。

安全性考虑

AJAX 提供动态性更强、交互性更强的浏览体验。不过，这增加了跨站点脚本 (XSS) 和跨站点请求伪造 (CSRF) 等常见类型攻击的可能性。这些类型的攻击是由攻击者向 Web 页面注入脚本代码而引发的，一般是通过 URL 实施，从而使攻击者能够控制 Web 浏览器，执行某些操作，如窃取用户名和密码，或者在用户不知情的情况下执行 HTTP 请求。

例如，攻击者可以使用动态创建的 <script> 标记将恶意脚本注入客户端，从而允许将数据导入到攻击者的网站。在实施 CSRF 攻击时，攻击者可以将一段脚本注入客户端，通过使用客户端上保存的身份验证信息（比如 cookie），在另一个网站上执行未经授权的服务方法。

JSON 是一种包装数据并将其传送给客户端的有效方式。不过，由于 JSON 被认为是 JavaScript 语言的一个安全子集（它不包括赋值和调用操作），因此许多 AJAX 应用程序只是简单地将 JSON 字符串直接传递给 JavaScript 的 eval 函数来创建 JavaScript 对象。因此，非正常的 JSON 字符串为攻击者提供了在客户端执行未授权脚本的新手段。

这些攻击类型中的大多数都依赖于利用 HTTP 请求上的 GET 动词。庆幸的是，ASP.NET AJAX 服务在默认情况下对远程服务禁用了 GET 动词。另外，您可以要求在请求中设置一种特殊的内容类型：其他任何情况下，ASP.NET AJAX 服务将拒绝该调用。通过设计，任何从 <script> 标记发出的对 ASP.NET AJAX 服务的直接调用都具有错误的内容类型，因而不会成功。

ASP.NET AJAX 中的服务

通过 ASP.NET AJAX Extensions 实现脚本服务有两种方式——使用特殊类型的 ASP.NET Web 服务和通过页面方法。前一种情况下，您只需设计和构建一个链接到 ASMX 资源的类：

<%@ WebService Language="C#"
    CodeBehind="~/App_Code/TimeService.cs"
    Class="IntroAjax.WebServices.TimeService" %>

该类可以选择从 WebService 类继承，并且必须用新的 ScriptService 属性加以修饰：

[ScriptService]
public class TimeService : System.Web.Services.WebService
{
   ...
}

每个可调用脚本的方法均声明为公共方法，并标记有通常的 WebMethod 属性。

页面方法只不过是在单个 ASP.NET 页面上下文中定义的公共、静态方法，每个方法都标记为 WebMethod。只能从宿主页面中调用它们。除了存储不同之外，对 Web 服务或页面方式的调用均由 ASP.NET AJAX 环境以相同方式进行处理。

您必须清楚，AJAX 服务代表后端的一部分。从 WS-* Web 服务意义上讲，它们并不是公共 Web 服务，Web 服务都是通过 WSDL 脚本完整记录并可通过携带 SOAP 数据的 POST 命令进行访问。AJAX 服务是真正的本地服务，通常在调用它们的同一个应用程序中进行定义。然而，它们也可以在不同的 Web 应用程序甚至不同的网站上，前提是它们位于同一个域。

通过启用 ASP.NET AJAX 运行时以接受对服务的调用，ScriptService 属性扮演了一个关键角色。如果没有 ScriptService 属性，当您试图进行调用时，服务器上就会引发异常。图 2 显示当一个 AJAX 页面链接到未标记该属性的服务时所返回的消息。注意，图中显示的页面永远不会显示给任何用户。当连接到声明使用此类服务的 ASP.NET AJAX 页面时，您确实会收到全部该标记。该标记返回 HTTP 500 错误代码，因为 ASP.NET 内部机制拒绝处理缺少该属性的 ASP.NET Web 服务进行的脚本调用。

图 2 引用不可脚本化服务的页面
图 2 引用不可脚本化服务的页面(单击该图像获得较小视图)
图 2 引用不可脚本化服务的页面
图 2 引用不可脚本化服务的页面 (单击该图像获得较大视图)

默认情况下，AJAX 服务方法只能使用 POST 动词调用并返回 JSON 格式的数据。然而，通过使用各服务方法中可选择的 ScriptMethod 属性，可以更改这些设置。图 3 详细说明了 ScriptMethod 属性所支持的参数。返回到客户端的数据可以更改为 XML，甚至可以添加对 GET 请求的支持。但我已说过，这种表面看起来无恶意的更改可能会为攻击者提供新的机会，且增加了跨站点调用该方法的可能性。下列代码段显示了一个 Web 服务方法的定义：

Figure 3 ScriptMethod 属性的参数 (Close Figure 3)

参数 | 说明
ResponseFormat | 指定是否将响应序列化为 JSON 或者 XML。默认为 JSON，但是，当方法的返回值是 XmlDocument 时，XML 格式会比较方便。
UseHttpGet | 表明是否可以使用 HTTP GET 动词调用 Web 服务方法。由于安全性原因，此项的默认设置为 false。
XmlSerializeString | 表明包括字符串在内的所有返回类型是否都序列化为 XML。默认为 false。当响应格式设置为 JSON 时，将忽略该属性的值。

[WebMethod]
[ScriptMethod]
public DateTime GetTime()
{
   ...
}

如果您不准备更改任何默认设置（多数情况下我建议这样做），则可以忽略 ScriptMethod 属性。不过，WebMethod 属性是必选项。

ASP.NET AJAX 服务和 SOAP

一旦创建了 AJAX Web 服务，它就发布为 ASMX 资源。默认情况下，它是公共 URL，可以由 AJAX 客户端使用，也可以由 SOAP 客户端及工具发现和使用。不过，您可以选择同时禁用 SOAP 客户端及工具。只需在承载服务的 ASP.NET 应用程序的 web.config 文件中输入下列配置设置即可：

<webServices>
   <protocols>
      <clear />
   </protocols>
</webServices>

这个简单的设置禁用了为 ASP.NET 2.0 Web 服务定义的任何协议（特别是 SOAP），并让服务仅回复 JSON 请求。注意，当这些设置开启时，您无法再通过浏览器的地址栏调用 Web 服务来进行快速测试。同样，您也不能请求 WSDL 向 URL 中添加 ?wsdl 后缀。

为了在 Web 应用程序中启用 ASP.NET AJAX，必须在 web.config 文件中包含下列设置：

<httpHandlers>
  <remove verb="*" path="*.asmx"/>
  <add verb="*" path="*.asmx" validate="false"
       type="System.Web.Script.ScriptHandlerFactory, System.Web.Extensions" />
</httpHandlers>

<remove> 节点会丢弃 ASMX 资源的默认 HTTP 处理程序，即通过 SOAP 处理请求的处理程序。<add> 节点会添加一个新的 HTTP 处理程序，该处理程序主要检查每个传入的 ASMX 请求的内容类型，如果内容类型头设置为 application/json，则通过 JSON 处理该请求。否则，HTTP 处理程序假定该请求是基于 SOAP 的，并将其转发给标准的 ASP.NET 2.0 Web 服务处理程序。如果禁用了 SOAP 协议，则会拒绝该请求。

最后，不管表面如何，没有任何必要让 SOAP 介入 ASP.NET AJAX 服务。但对 SOAP 客户端的支持是得到保证的，除非在 web.config 文件中明确禁用。

要使 ASP.NET AJAX 服务按预期正常工作，传入请求的内容类型 HTTP 头必须设置为 application/json。对于通过 <script> 标记实施的跨站点攻击来说，这还是极好的补救措施。

调用 AJAX 服务

要调用 AJAX 服务，AJAX 客户端会遵从在 Windows 和传统的 ASP.NET 应用程序中引用 Web 服务的相同模式。一个代理类在本地提供与远程服务相同的接口。在 ASP.NET AJAX 应用程序中，此代理是一个 JavaScript 类，当页面下载后，由运行库生成。

JavaScript 代理类具有与脚本服务相同的名称和许多附加属性。它的特征是有一组相同的方法，只不过采用了稍有扩展的签名。一般而言，您没有必要探究代理类的源代码。不过，如果您想看一下它的结构，则可从浏览器的地址栏中尝试调用下列 URL：

http://.../service.asmx/js

浏览器将下载一个 JavaScript 文件，您可以将其保存到本地磁盘，供以后详阅。

JavaScript 代理类从一个名为 Sys.Net.WebServiceProxy 的基类继承而来。它提供了进行 JSON 调用的基本功能。本专栏的代码下载提供一个 Web 服务的代理类，它具有下列接口：

interface ITimeService
{
    DateTime GetTime();
    string GetTimeFormat(string format);
}

JavaScript 代理类的特征是具有图 4 中列出的属性。除了常规的一组参数之外，每个镜像的方法还有三个参数。这三个参数分别是：方法成功时所要调用的回调函数、方法失败或超时情况下所要调用的回调函数、传递给两个回调的上下文对象。通过图 4 中显示的三个与默认相关的属性，您可以对多次调用重复使用同一个函数（比如处理错误的唯一 JavaScript 函数）。下面是从 ASP.NET AJAX 页面调用一个远程 AJAX 服务的一些示例代码：

Figure 4 JavaScript 代理类的属性 (Close Figure 4)

属性 | 说明
Path | 指出底层 Web 服务的 URL。
Timeout | 指出在调用超时之前允许方法运行的毫秒数。
defaultSucceededCallback | 指出调用成功时所调用的默认 JavaScript 回调函数。
defaultFailedCallback | 指出调用失败或超时情况下所调用的默认 JavaScript 回调函数（如果有）。
defaultUserContext | 指出要传递给成功回调或失败回调的默认 JavaScript 对象（如果有）。

function getTime()
{
    IntroAjax.WebServices.TimeService.GetTimeFormat(
        "ddd, dd MMMM yyyy [hh:mm:ss]", onMethodComplete);
}

function onMethodComplete(results)
{
    $get("Label1").innerHTML = results;
}

在方法调用结尾（无论结果如何）调用的回调具有下列原型：

function method(results, context, methodName)

context 参数代表调用时指定的上下文对象。methodName 参数是一个设为服务方法名称的字符串。最后，对于成功调用时所调用的回调，results 参数是包含 JavaScript 版本的方法返回值的对象。对于失败的回调，此参数则代表 Sys.Net.WebServiceError 对象。

构建用户界面

AJAX 完全是关于最广泛意义上的用户体验——连贯的感受、无闪烁更新、界面设施、资源聚合、实时数据等等。但您只能利用浏览器和它的一套可编程性功能，主要是浏览器的对象模型、DOM 实现、对 DHTML 扩展的支持、CSS、JavaScript 和插件。

JavaScript 是构建和操纵 UI 的主要工具。用户界面任务的典型模式要求客户端使用 JavaScript 调用远程服务、接收 JSON 数据或可能是 XML 数据，然后重新整理页面以显示更改。

如此简单的模型在应用到实际应用程序的规模和复杂性时未必有效。随着 UI 的结构变得日益复杂化，重新整理页面来合并远程调用后新来的数据所带来的问题不可小觑。关键是，一个复杂的 UI 成为问题的临界点在哪里？

从根本上说，每个实际应用程序（尤其是业务线应用程序）必须依赖三个基本的用户界面功能：布局、数据绑定和样式。除了通过 CSS 支持某些样式外，JavaScript 环境不支持任何这些用户界面功能。此外，在大量的编程常因浏览器的细微错误和粗劣的编程而导致内存问题之类的情况下，JavaScript 还是一种解释性语言。

考虑一个比较常见的简单情形——对数据网格分页。网格归根到底就是十分复杂的表，浏览器必须为每个请求分析和呈现它。第一次处理页面时，用户很难衡量此呈现操作的成本，因为它已合并到整体页面下载过程中。但是，一旦对新网格页面提出 AJAX 回发请求，更新浏览器窗口的成本就会立即显现。浏览器收到包含对象集合的 JSON 字符串，而且必须将该集合转换成新的表。在客户端必须构建庞大的 HTML 字符串，在一些内存缓冲区中组织文本。如果没有差错，相同的字符串随后还必须以图形方式呈现。

当完成如此密集的操作后，您会发现响应时间并非如此理想。对于类似上述的情形，要找出有效的解决方案，需要具备中等以上的 JavaScript 和 DHTML 技能。

一个替代方法是在服务器上预先生成一些标记。这样，远程服务不仅返回数据，而且还合并标记信息。在服务器上，标记是通过已编译的代码来构建的，因此可以更容易地调试和测试它，而且您可以用更强大的编程工具来添加可访问性功能。另一方面，线路上将传输更多的数据（但是请记住，数据量仍远少于常规 ASP.NET 回发）。

底线就是，无论是从性能还是从开发角度考虑，为了有效地创建切实可行的用户界面，客户端需要更强大的工具。完备的部件和控件库对任何开发人员来说都十分必要。但是，即使经过最完美优化的库对于打破解释性语言（比如 JavaScript）的固有限制也无能为力。也许 Silverlight 最终会向 Web 开发人员提供他们所希望的用于下一代 Web 和 AJAX 应用程序的客户端环境；Silverlight 是一个 Microsoft 跨平台浏览器插件，它结合了 Windows® Presentation Foundation (WPF) 框架的一个子集。但是 Silverlight 是一个外部插件，需要单独下载，而且尚未成熟。1.0 版刚发布不久，若要用它构建真正实用的 Web 应用程序的表示层，还需要一些更高级的功能。

这一切意味着什么

部分呈现是实现 AJAX 最简便的方法。它非常适合于将 AJAX 功能添加到旧式应用程序中，您不必花时间、预算来重新设计这些应用程序，也不会有重新进行设计的愿望。从体系结构的角度上说，部分呈现是对当今 ASP.NET 的智能化扩展，而且保留了相同的应用程序模型和底层引擎。

纯 AJAX 体系结构是基于客户端与服务器的松散耦合，即实质上彼此独立的两个世界通过 HTTP 线路就 JSON 交换消息连接了起来。在纯 AJAX 体系结构中有一个基于服务的后端和一个 JavaScript 驱动的前端。构建有效的 HTML UI 完全取决于您或您选择的控件库。不过，这种分离机制使 Web 开发人员能够继续采用 Silverlight 等新兴技术创建交互性更强的用户界面，而不受服务器平台的限制。

请将您想要向 Dino 询问的问题和提出的意见发送至 cutting@microsoft.com.

Dino Esposito是 Solid Quality Learning 的一位导师，也是即将出版的“Programming ASP.NET 3.5 Core Reference”（ASP.NET 3.5 技术内幕）(Microsoft Press, 2007) 一书的作者。Dino 定居于意大利，并经常在世界各地的业内活动中发表演讲。您可访问他的博客，网址为 weblogs.asp.net/despos。


测试运行: Windows XP Embedded 的测试自动化

测试运行
Windows XP Embedded 的测试自动化
James McCaffrey 博士 and Mike Hall

代码下载位置: TestRun2007_10.exe (1402KB)
目录
待测试的应用程序
测试自动化
将 UI 测试自动化移植到 Windows XP Embedded 系统中
创建 Component
操作系统 Component
生成映像
部署映像
简单概括

您
接触过 Windows® XP Embedded 操作系统吗？它本质上是一个由开发人员自定义的、Windows XP 操作系统的简化版本，用于 PC 之类的设备，如游戏控制台、信息网亭、网络设备以及类似的设备等等。随着对移动和嵌入式设备使用的快速增长，这个平台正变得越来越重要。

我发现软件测试工程师和从事基于 Windows XP Embedded 软件系统开发工作的开发人员之间存在一种知沟 (knowledge gap)。为了帮助弥补这个知沟，在本月的专栏中，Mike Hall，Microsoft® 移动和嵌入式设备小组的高级产品经理（同时也是 Windows XP Embedded 方面的专家）会与我合作。我们将会一起来说明如何为运行在 Windows XP Embedded 上的软件系统实现轻型而强大的 UI 测试自动化。

为了向您展示将要讲述的内容，最佳方法就是向您提供一个屏幕快照。图 1 展示了一些正在工作的轻型 UI 测试自动化。但是有一点从屏幕快照中不太容易看出来，那就是自动化是在运行 Windows XP Embedded 的设备上执行的。

图 1 Windows XP Embedded 上的 UI 自动化
图 1 Windows XP Embedded 上的 UI 自动化(单击该图像获得较小视图)
图 1 Windows XP Embedded 上的 UI 自动化
图 1 Windows XP Embedded 上的 UI 自动化 (单击该图像获得较大视图)

如果您仔细观察图 1，就会发现 UI 测试自动化实行的是简单但又极具代表性的基于 Windows 窗体的应用程序，在运行 Windows XP Embedded 的网亭设备上可以找到类似的应用程序。测试自动化是一个控制台应用程序，它可以：

  • 启动待测试的基于窗体的应用程序，并操作该应用程序
  • 模拟对产品名称中包含文本“idg”的产品的搜索
  • 检查最终应用程序状态中是否有“111”
  • 确定测试方案结果

在后台，测试自动化正通过使用 Microsoft .NET Framework P/Invoke 机制调用 Win32® API 函数（例如 FindWindow、SendMessage 和 GetMenu）来操作待测试的应用程序。

在本月专栏中，我们将简要讨论待测试的应用程序，然后看一下测试自动化（它产生了图 1 所示的结果）。接着，我们将详细说明如何使用 Windows Embedded Studio 中的工具，以相对简单的方式创建包含应用程序和测试自动化的 Windows XP Embedded 系统。我们还将探讨一项更为复杂的技术，它可以将测试自动化创建为自定义的 Windows XP Embedded 外壳。最后我们将就如何修改和延伸此处提出的观点以满足您的自身需求进行简要讨论。

如果您对 Windows XP Embedded 不熟悉，您会发现本专栏是对一项有趣技术的出色介绍。如果您对使用 .NET Framework 接口、基于 Win32 的 UI 自动化也不熟悉，那么您会发现本专栏也是对这一技术的不错的介绍。

待测试的应用程序

让我们看一下待测试的基于 Windows 窗体的应用程序的关键代码，它是典型的 Windows XP Embedded 软件系统的核心。应用程序和测试自动化的所有代码都包括在本文附带的代码下载中。如图 1 所示，此虚拟应用程序具有基本的用户控件，包括 TextBox 控件、ComboBox 控件、Button 控件及 ListBox 控件。在实际情形中，Windows XP Embedded 搜索应用程序很可能会从数据库服务器或 Web 服务中检索搜索结果。在这里，应用程序会通过一个简单的 Product 类来维护本地数据存储：

public class Product
{
    public string id, name, price;

    public Product(string id, string name, string price)
    {
        this.id = id; this.name = name; this.price = price;
    }
}

Product 实例的数据存储在 ArrayList 集合中，并在主 Form 对象加载时提供：

private ArrayList al = new ArrayList();
private void Form1_Load(object sender, System.EventArgs e)
{
    Product p1 = new Product("111", "Widget", "$11.11");
    Product p2 = new Product("222", "Gadget", "$22.22");
    Product p3 = new Product("333", "Thingy", "$33.33");
    al.Add(p1); al.Add(p2); al.Add(p3);
}

由于我们是在执行 UI 自动化，因此搜索应用程序从何处以及如何检索其数据对我们来说实际上并不是很重要——不论结果数据来自何处，它都会在该应用程序的 UI 中反映出来，可通过对其进行检查来确定测试方案的结果是通过还是失败。所有应用程序逻辑都发生在搜索 Button 控件的 click 方法中。该应用程序开始时会进行粗略的检查，以确定用户是否指定了搜索条件——按产品 ID 或按产品说明。如果没有，该应用程序会触发 MessageBox 窗口，显示一条错误消息：

if (comboBox1.Text == "" || textBox1.Text == "") {
    MessageBox.Show("You must enter search criteria and term",
                    "Error");
}
listBox1.Items.Clear();

清除了包含搜索结果的 ListBox 控件之后，该应用程序就可以检索搜索条件/目标字符串，并遍历 ArrayList 数据存储，查找匹配项：

string filter = textBox1.Text;
if (comboBox1.Text == "Product ID") {
    foreach (Product p in al) {
        if (p.id.IndexOf(filter) >= 0) {
            listBox1.Items.Add(p.id + " " + p.name + " " + p.price);
        }
    }
}
else if (comboBox1.Text == "Product Name") {
    // search by Name similarly
}

需要强调的是，为了让代码简短而易懂，也为了在开发初期能模拟应用程序相对不那么精确的特性，我们故意未使用良好的编码风格或做法。实际上，示例代码中存在多个您可能会乐于探究的重大逻辑缺陷。

如果您刚接触 Windows XP Embedded，您可能会问自己，“这个应用程序与 Windows XP Embedded 有什么关系？它看起来与普通的基于 Windows 窗体的应用程序并无两样。” 您的观察是正确的——并且这一点非常重要。由于 Windows XP Embedded 实质上是 Windows XP 的组件化版本，因此您的开发技巧可以直接照搬。这使得 Windows XP Embedded 与其他嵌入式设备的开发平台相比，有很大的优势。您不必去了解一个全新的平台和工具集，您可以访问大型支持社区，您可以使用 .NET Framework 库的功能，并且如本专栏所说明的那样，您还可以利用强大的测试自动化技术。

测试自动化

创建 UI 测试自动化的方法有多种。我们此处使用的技术是编写 C# 控制台应用程序测试工具，它可以通过使用 P/Invoke 机制调用本机 Win32 API 函数，进而操作待测试的基于窗体的应用程序。2005 年 9 月期“测试运行”专栏中的“低级别 UI 测试自动化”一文（位于 msdn.microsoft.com/msdnmag/issues/05/09/testrun）详细讨论了这一主题。

我们通过使用 System.Diagnostics 命名空间中 Process 类的静态 Start 方法来启动待测试的应用程序，并由此开始测试自动化：

Console.WriteLine("\nLaunching Windows application");
Process p = Process.Start(".\\TheAppToTest.exe");

请注意，示例代码假设待测试的可执行应用程序与我们的可执行测试工具位于同一目录下。正如稍后您将看到的那样，此设计对于 Windows XP Embedded 系统上的测试自动化特别方便。启动待测试的应用程序后，我们使用 Win32 FindWindow 函数来获得待测试应用程序 Form 对象的句柄，如图 2 所示。

Figure 2 获取句柄 (Close Figure 2)
Console.WriteLine("\nLooking for Form1");
IntPtr ptrToForm;
bool formFound = false;
int numTries = 0;
while (!formFound && numTries < 100) {
    ++numTries;
    ptrToForm = FindWindow(null, "Form1");
    if (ptrToForm == IntPtr.Zero) {
        Console.WriteLine("Form1 not found yet. . . ");
        Thread.Sleep(100);
    }
    else {
        formFound = true;
        Console.WriteLine("Form1 found with ptr = " +
            ptrToForm.ToString());
    }
}
if (numTries >= 100) throw new Exception("Form was not found");

P/Invoke 技术的核心是导入 Win32 FindWindow 函数。我们通过在测试工具中放置一个 DllImport 属性来实现这一点：

[DllImport("user32.dll", EntryPoint = "FindWindow",
           CharSet = CharSet.Auto)]
static extern IntPtr FindWindow(
    string lpClassName, string lpWindowName);

您可以大概地将此属性理解为：“从 user32.dll 库导入一个 Win32 函数。该 Win32 函数名为 FindWindow（尽管系统应该也会检查是否存在以“A”或“W”结尾的相同名称）。它会自动处理 ASCII 和 Unicode 字符编码问题。C# 别名是一种可返回 .NET IntPtr 类型的静态方法，它表示一个窗口的句柄。C# 别名被命名为 FindWindow，它接受两个字符串参数——窗口控件类名称和控件友好名称。”

图 2 中的代码试图查找“窗体”窗口；如果找不到“窗体”窗口（通常是因为 Form 对象尚未初始化），该代码会等待 100 毫秒后再试。如果在尝试 100 次以后还是找不到 Form 对象，测试自动化就会引发异常。在自动化工具启动待测试的应用程序后，该工具会通过缩放和移动窗体来操作 Form 对象。例如：

Console.WriteLine("\nMoving Form1 around");
SetWindowPos(ptrToForm, IntPtr.Zero, 100, 200, 336, 404, 0);

此处使用 Win32 SetWindowPos 函数将 Form 对象左上角置于屏幕坐标 (100,200) 处，宽度为 336 像素，高度为 404 像素。

与 C# 别名相关的 DllImport 属性是：

[DllImport("user32.dll", EntryPoint = "SetWindowPos")]
static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
    int x, int y, int cx, int cy, uint uFlags);

对 Form 对象进行操作之后，我们将获取窗体上子窗口控件的句柄。例如，用来获取 button1 控件句柄的测试工具代码是：

Console.WriteLine("Looking for button1");
IntPtr btn = FindWindowEx(ptrToForm, IntPtr.Zero, null, "Search");
if (btn == IntPtr.Zero)
    throw new Exception("Could not find search button");
else
    Console.WriteLine("button1 found with ptr = " + butt.ToString());

此处使用 Win32 FindWindowEx 函数来获取 Name 属性为“Search”的子控件的句柄。

用来获取 TextBox 控件句柄的代码更加复杂：

Console.WriteLine("\nLooking for textBox1");
IntPtr tb = FindWindowByIndex(ptrToForm, 3);
if (tb == IntPtr.Zero)
    throw new Exception("Could not find textBox");
else
    Console.WriteLine("textBox1 found with ptr = " + tb.ToString());

此处我们使用名为 FindWindowByIndex 的程序定义的方法，它极为方便。

尽管有些窗口控件具有 Name 属性（例如 Form1），但很多并没有。为了获取未命名的窗口控件的句柄，您可以使用 FindWindowByIndex，定义如下：

static IntPtr FindWindowByIndex(IntPtr hwndParent, int index)
{
    int ct = 0;
    IntPtr result = IntPtr.Zero;
    do {
        result = FindWindowEx(hwndParent, result, null, null);
        if (result != IntPtr.Zero) ++ct;
    } while (ct < index && result != IntPtr.Zero);
    return result;
}

其理念是：即使有些窗口控件没有 Name 属性，但所有窗口控件都具有隐含的索引号。您可以使用 Win32 函数 FindWindowEx 和控件隐含的索引号来获取该控件的句柄。窗口控件隐含的索引号和选项卡控件顺序不一样。隐含的索引号由窗口控件被添加到父窗口的顺序决定。因此，在我们的示例中，我们指定了 FindWindowByIndex(ptrToForm, 3)，因为待测试代码的应用程序包含：

this.label1 = new System.Windows.Forms.Label();       // index 0
this.label2 = new System.Windows.Forms.Label();       // 1
this.comboBox1 = new System.Windows.Forms.ComboBox(); // 2
this.textBox1 = new System.Windows.Forms.TextBox();   // 3 <-
this.button1 = new System.Windows.Forms.Button();
// etc.

测试工具中剩余的代码将对待测试的应用程序进行操作，包括向 TextBox 控件发送字符、单击 Button 控件、等待错误 MessageBox 出现、单击其 OK 按钮等等。测试工具在最后将检查 ListBox 控件的内容以确定方案结果是通过还是失败。

将 UI 测试自动化移植到 Windows XP Embedded 系统中

Windows XP Embedded 系统的关键概念是操作系统外壳的概念，而不是运行在操作系统上的外壳应用程序。每个基于 Windows 的操作系统都必须有一个且只能有一个主外壳界面。例如，在标准的 Windows XP 操作系统上，资源管理器外壳通常就是主外壳，虽然也可以有多个应用程序外壳，例如 cmd.exe 命令外壳、Windows PowerShell® 外壳等。由于 Windows XP Embedded 是可自定义的 Windows XP 组件化版本，所以并没有限制您必须要将资源管理器外壳作为主外壳来使用——您可以从多种外壳中任选一种作为主外壳，甚至可以创建自定义的主外壳。

在“作为自定义外壳的测试自动化”侧栏中，我们阐明了当您决定使用非自定义的主外壳时如何将 UI 测试自动化移植到 Windows XP Embedded 系统中。在下一部分中，我们将介绍一种更为复杂的方法，即如何在使用自定义主外壳时移植测试自动化。

在文档术语中，“设备”这个词既可以指将运行 Windows XP Embedded 的一组设备（例如信息网亭），也可以指单个的硬件项（例如鼠标、键盘或显示适配器）。在本示例中，目标设备是一台便携式计算机。在更现实的情形中，目标设备可能会是一些与 PC 不大相同的东西——可能是带有简化键盘或小型视频显示器的设备。但是 Windows XP Embedded 的开发原则却是完全相同的。

我们示例中的目标设备具有一个分为 C: 分区和 D: 分区的物理 硬盘 驱动器。我们的目标是将 Windows XP Embedded 系统复制到 D: 分区。为了创建 Windows XP Embedded 系统，您可以使用 Windows Embedded Studio，它提供了一个工具集和一个软件组件的后端数据库。通常，您不会将 Windows XP Embedded 工具安装到目标设备上；您会将它们安装到开发计算机（通常运行 Windows XP）上，为开发计算机上的自定义 Windows XP Embedded 操作系统和应用程序创建系统映像，然后再将该映像部署到目标设备中。

在开始的时候，创建 Windows XP Embedded 映像可能会让人迷惑不解，但是重复几次以后，就会变得容易理解。在向您介绍特定示例的细节之前，先看一下这里提供的过程高层概述。映像创建过程的第一步是使用名为 Target Analyzer Probe 的实用工具（包含在 Windows XP Embedded 工具集中）来分析您目标设备上的单个硬件设备。第二步是使用名为 Component Designer 的工具（稍后会详细介绍）创建 Windows XP Embedded 组件。第三步是将这些组件结合起来，并内置到 Windows XP Embedded 映像中。第四步是将该映像部署到目标设备中。正如您看到的那样，整个过程从原理上讲并不复杂。

现在我们将向您介绍创建 Windows XP Embedded UI 测试自动化的细节。首先，使用 Target Analyzer Probe（tap.exe；还有一个适用于 16 位系统的 ta.exe 程序）分析目标设备上的硬件项。只要将 tap.exe 实用程序复制到目标设备上即可。如何执行此复制操作取决于目标设备有哪些可用的 I/O 设备。（注意：tap.exe 实用工具要求使用 Windows 2000 或 Windows XP；或者也可以使用 Windows XP Embedded 安装盘，为此目的，它是可启动的。）

tap.exe 被复制到目标设备上以后，只需执行它即可。结果会生成一个名为 devices.pmq 的文件（通常被业内人士称为“南瓜文件”），它与 tap.exe 存储在同一个目录中。此文件包含在目标设备上找到的所有硬件项的列表。由于该文件采用的是 XML 格式，所以您可以使用任何文本编辑器来读取它，以验证是否已找到所有硬件设备。另外，如有必要，还可以手动创建或编辑该文件。创建好 devices.pmq 以后，将它从目标设备复制到开发计算机上。

创建 Component

接下来的几个步骤涉及到使用基于 GUI 的工具（称为 Component Designer）来创建 Component。您可以将 Windows XP Embedded 组件看作相关软件的程序包。注意：在本文中，当提及 Windows XP Embedded 程序包时我们使用 Component，而提及一般项目集合时，则使用“组件”。

Windows Embedded 工具与 Component 而不是与单个文件配合使用，这是因为Windows XP Embedded 映像涉及大量文件，如果针对单个文件进行管理会非常不方便。您需要创建两个自定义的 Windows XP Embedded Component——一个封装基于窗体的应用程序及其基于控制台的测试自动化，另一个封装相应的硬件设备驱动器。

首先要做好准备，即在开发计算机上创建 C:\Automation 目录，然后将 TheAppToTest.exe（应用程序）和 Demo.exe（自动化）放到此目录中。此操作并非绝对必要，但是正如我们稍后要向您说明的那样，它在创建测试自动化 Component 时是一个非常方便的方法。

首先，我们会逐步引导您创建测试自动化组件，然后向您介绍创建设备驱动器组件的过程。首先，从桌面的“开始”菜单启动 Component Designer 工具，然后从主菜单中选择“文件”|“新建”。您可以将配置另存为（使用“文件”|“保存”）AutoTestAutomationComponent.sld（通常称为幻灯片文件），保存目录随意。但是，如果将 .sld 配置文件保存到 Component 资源文件所在的目录，则整个 Component 都将是可移植的，因为所有的文件都位于同一个目录。

在 Component Designer 左侧导航窗格中，展开 Windows XP Embedded Client (x86) 项。现在为测试自动化 Component 创建 Repository 对象。您可以将 Repository 对象看作构成 Component 的文件的物理存储区。右键单击“存储库”项，然后选择“添加存储库”即可。在右侧的数据窗格中，将存储库命名为 AutoTestAutomationRepository，然后将 Source Path 属性修改为 C:\Automation，以指明自动化文件在开发计算机上的位置（相对于在目标设备上的最终位置）。此时还可以设置很多其他 Repository 属性，但是为简便起见，我们就不在这里一一介绍了。

现在返回 Component Designer 的左侧导航窗格，右键单击“组件”，然后选择“添加组件”。此时，Component Designer 的左侧窗格看起来应该像图 3 所示的屏幕，而右侧窗格屏幕看起来应该像图 4 所示的屏幕。在右侧窗格中的“组件属性”区域中，您将看到该组件的 Name 属性已被设置成 AutoTestAutomationComponent。

图 3 Component Designer 左侧窗格
图 3 Component Designer 左侧窗格
图 4 Component Designer 右侧窗格
图 4 Component Designer 右侧窗格(单击该图像获得较小视图)
图 4 Component Designer 右侧窗格
图 4 Component Designer 右侧窗格 (单击该图像获得较大视图)

接下来，要将刚才创建的“存储库”与 Component 关联起来，请单击“存储库...” 按钮，从“选择存储库”对话框中选择 AutoTestAutomationRepository 项，然后单击“确定”。您会注意到大多数 Windows XP Embedded 对象（包括 Repository）都有自动附加到对象名称的修订号——这对保持对象的有序状态有很大的帮助。现在在左侧窗格中，通过右键单击“文件”项，然后选择“添加”|“多个文件”来将引用添加到物理文件中。在“添加组件文件资源”对话框中，导航到开发计算机上的应用程序和自动化文件（在本示例中为 C:\Automation），用 <Ctrl> 键选择 TheAppToTest.exe 和 Demo.exe 文件，然后单击“打开”。

现在将添加“组件依赖关系”。由于本示例中基于窗体的应用程序和基于控制台的测试自动化都建立在 .NET Framework 2.0 之上，所以最终的 Windows XP Embedded 映像必须包括 .NET Framework 2.0 组件。通过显式指定此依赖关系，您可以隐式指示 Target Designer 工具（正如您所记得的那样，它将所有的 Component 组合成一个可正常运行的最终 Windows XP Embedded 映像）包含 .NET Framework 2.0 组件。右键单击“组件”或“组依赖关系”项，然后选择“添加”|“组件依赖关系”。（我们将在本专栏的下一部分中解释什么是“组依赖关系”。） 此时，Component Designer 会连接 Component 的后端数据库，它是作为 Windows XP Embedded Studio 工具的一部分而安装的。

在“添加组件依赖关系”对话框中，导航到“软件”|“系统”|“系统服务”|“其他”，选择 .NET Framework 2.0 组件（如图 5 所示），然后单击“确定”。根据您正在使用的 Windows XP Embedded 工具集的版本不同，可能必须单独安装 .NET Framework 2.0 组件。Component 的列表是动态的，并且在不断增加——Windows XP Embedded 有一个很大的技术支持社区，其中包括第三方工程师和公司，以及 Microsoft 本身。

图 5 添加 Component 依赖关系
图 5 添加 Component 依赖关系(单击该图像获得较小视图)
图 5 添加 Component 依赖关系
图 5 添加 Component 依赖关系 (单击该图像获得较大视图)

至此测试自动化 Component 即创建完成，您可保存.sld 配置并关闭 Component Designer 工具。现在需要通过“组件数据库管理器”工具将自定义的组件转移到后端数据库，您可以从桌面上的“开始”菜单中启动该工具。在该工具连接到后端数据库以后，单击主“数据库”选项卡上的“导入”按钮。在“导入 SLD”对话框中，浏览到 .sld 文件（在本示例中是 AutoTestAutomationComponent.sld），然后单击“导入”。这个过程非常迅速，在查看完日志消息后，只需单击“关闭”和“关闭”即可。测试自动化 Component 现在已可用于 Target Designer 工具。

现在您需要创建第二个 Component，它封装您所有设备驱动器和支持目标设备上单个硬件项目所需要的其他软件。因为有了 devices.pmq 文件（它列出了所有的目标设备硬件），所以这是一个非常简单的过程。再次启动 Component Designer 工具，但是这次是单击“文件”|“导入”。在“选择要导入的文件”对话框中，导航到 devices.pmq 文件，然后单击“打开”。这将打开“导入文件”对话框——只要单击“开始”按钮即可。导入过程需要几分钟的时间，因为 Component Designer 不只是要导入 devices.pmq 中的 XML 数据；实际上它还要分析文件，确定需要哪些软件文件来支持硬件设备，并且还要根据这些信息创建 Component。换句话说，当为硬件组件自动创建一次性测试自动化 Component 时，大多数的操作您都必须手动执行。您应该会看到一条“导入会话成功完成”的消息，然后即可关闭“导入文件”对话框。

如果展开“组件”项目，然后选择设备项目，就可以将 Component 重命名为 AutoHardwareComponent。现在将配置另存为 AutoHardwareComponent.sld，然后关闭 Component Designer 工具。之后就可以使用“组件数据库管理器”工具将硬件组件导入后端数据库，就象处理测试自动化 Component 一样。

操作系统 Component

现在测试自动化和硬件 Component 都已创建，接下来便可以将它们与操作系统 Component 结合起来，创建 Windows XP Embedded 映像。此时便可以使用 Target Designer 工具。

从“开始”菜单启动 Target Designer，然后单击“文件”|“新建”。在生成的“新建配置”对话框中，命名配置（例如在本示例中为 AutoTestAutomation），然后单击“确定”。Target Designer UI 有三个主窗格。左侧窗格列出了可供添加到 Windows XP Embedded 映像的 Component。中间窗格列出了当前在映像中的 Component。右侧窗格显示了在左侧窗格或中间窗格中选中的任何项目的信息。

左侧窗格会自动读取后端数据库，并列出您已创建的任何 Component——本示例中是 AutoHardwareComponent 和 AutoTestAutomationComponent。通过右键单击 AutoHardwareComponent，然后选择“添加”来添加硬件 Component，然后按同样的方法添加 AutoTestAutomationComponent（添加的顺序不同并无任何影响）。

添加完两个自定义的 Component 以后，您需要添加核心操作系统 Component。尽管可以研究成千上万个可用的 Component（通常超过 10,000 个），但有一个更简单的方法，那就是使用预构建的元 Component。在左侧的 Target Designer 窗格中，展开“设计模板”项目，显示一些选项。在本示例中，选择 Windows-based Terminal Professional 模板，然后右键单击，并选择“添加”。这样便添加了大量通常在 PC 之类设备中所使用的单个软件 Component。有多个模板可供使用，包括 Retail Point of Sale Terminal、Home Gateway、Network Attached Storage 等。使用 Component 模板的缺点是您很可能会得到实际上并不需要的 Component。有一个好办法，那就是只将模板作为起点来使用，然后将所有不需要的 Component 全部删除。

在中间窗格中，选择“设置”，然后在右侧窗格中单击“目标设备设置”|“显示”。这些条目指的是目标设备，而不是开发计算机。因此，在本示例中，您应该将启动驱动器保留为 C:， 而将 Windows 文件夹、Program Files 文件夹以及 Documents and Settings 文件夹这些项目中的 C: 更改为 D:。这是因为（如前所述）该计划是要将 Windows XP Embedded 映像复制到该设备的单个硬盘驱动器的 D: 分区。

将 Boot ARC 路径项从 multi(0)disk(0)rdisk(0)partition(1) 编辑成 multi(0)disk(0)rdisk(0)partition(2)。rdisk（物理驱动器）项目是基于 0 的，而分区项则是基于 1 的。将启动分区的大小改为 5000 (MB)。这对于 5GB 或更大的启动分区来说都是一个不可思议的值。如果您指定了一个值，例如 700 MB，那么就有可能创建可启动的 CD。在本示例中，只需要将“分区”群集大小保留为 4096 字节。这是保存该配置的一个好机会，另存为 AutoTestAutomation.slx。

生成映像
作为自定义外壳的测试自动化

在某些情况下， 使用 Windows XP Embedded 的开发人员想实现自定义的主外壳。例如，设想一下充当整个 UI 的基于窗体的应用程序，如图 1 所示。此方法严格地将用户活动强制在此类应用程序上进行，并且基本杜绝了与 Windows Explorer 和 Internet Explorer® 之类的程序进行交互。在自定义外壳方案中，普通的测试自动化技术一般不能发挥作用，因为您不能像启动应用程序那样启动操作系统外壳。

在 Windows XP Embedded 自定义外壳方案中有一个测试自动化解决方案，那就是使用 Windows Embedded Studio 工具创建一个测试自动化 Component，然后将其配置成主外壳。以下是该过程的简单概述。（有关创建自定义 Windows XP Embedded 外壳的详细说明，请查看 Mike Hall 的文章“创建 Windows XP Embedded 外壳”，位于 msdn2.microsoft.com/ms838335。）

要创建自定义测试自动化主外壳，您首先要使用我在本专栏介绍过的 Component Designer 工具创建一个测试自动化 Component。之后，在 Component Designer 中，必须配置四个设置，将该 Component 转变成特殊的主外壳 Component。

  1. 将 Component Prototype 属性设置成“软件”|“系统”|“用户界面”|“外壳”|“Windows 外壳”|“外壳原型组件”。
  2. 使用下列设置添加“高级属性”：名称 = cmiShellPath（区分大小写），格式 = String，值 = \Automation\Demo.exe（例如，测试自动化的路径）。
  3. 将组成员身份设置成“组成员身份”|“添加组成员身份”|“依赖关系”|“外壳”。
  4. 将第二个组成员身份设置成“组成员身份”|“添加组成员身份”|“软件”|“系统”|“用户界面”|“外壳”。

配置好这四个设置以创建特殊主外壳 Component 之后，该 Component 会作为 UI 外壳被列入 Target Designer 工具中，另外还有资源管理器外壳、命令外壳等。接下来，如果您将自定义自动化外壳 Component 添加到 Windows XP Embedded 映像，您会发现结果操作系统将立即启动进入测试自动化，并执行相关的基于窗体的应用程序。

您差不多可以将 Component 结合到映像中去了。但是在试图生成之前，您应该查看一下 Component 依赖关系，以确定是否不小心遗漏了尚未配置的必需 Component。这一步非常重要。操作系统是一个非常复杂的软件，它没有类似 Target Designer 的工具，因此手动确定所有的组件依赖关系会十分困难。

在主菜单栏中，单击“配置”|“检查依赖关系”。对话框打开，显示进度消息。依赖关系检查会将任何需要的 Component 自动导入到该配置中。依赖关系检查完毕后，您可以关闭该对话框。在大多数情况下，会有一些错误显示在 Target Designer 底部窗格的任务列表中。如果您右键单击一条错误，会获得一个“操作”选项，它提供了修复建议。例如，本示例中有一个错误是“区域和语言选项要求至少有一个其他的已启用组件不在配置中”。如果右键单击并选择“操作”，将会看到一个“解决依赖关系”对话框，从其中的语言列表中可以选择“英语”，然后单击“添加”。对本示例来说，还应该添加 NT Loader Component、NTFS Format Component 和资源管理器外壳 Component 才能解决其他错误。

现在您可以单击“配置”|“生成目标映像”。在执行此操作时，您会看到一个带有配置名称 (AutoTestAutomation.slx) 的生成对话框。接下来，必须单击“生成”按钮，然后 Target Designer 会发出警告：告知您已经更改了配置——因为您添加了四个新的 Component 来解决那些依赖关系找到的错误——然后询问您是否要重新运行依赖关系检查。单击“是”便开始运行第二次依赖关系检查，但是这次的速度要快得多。第二次依赖关系检查结束后，假定没有什么错误，您可以关闭“依赖关系检查”对话框，生成过程将开始。

在 Target Designer 中，选择“工具”|“选项”|“生成”可让您配置 Windows XP Embedded 映像在系统上的默认存储位置。还可以在“生成”对话框中指定特定生成的位置。值得注意的是，如果 Windows XP Embedded 映像已存在，则会出现警告消息，提醒您现有的映像将被覆盖，这通常没问题。

生成完成后，如果您看到一些警告，不要感到惊讶。您可以阅读这些警告，确保它们只是一些可以放心忽略的小问题（例如您正在使用评估软件的警告）。

部署映像

现在便可以将 Windows XP Embedded 映像部署到目标设备中了。新的映像由四个文件和一个包含四个子目录的目录组成。其中的三个文件——boot.ini、ntdetect.com 和 ntldr——是启动基于 Windows XP 的操作系统的关键文件。第四个文件 weruntime.ini 本质上是一个特殊的 Windows XP Embedded 密钥，它可以启用 Windows XP Embedded。

将这四个文件从开发计算机复制到目标设备的启动驱动器上。如何复制这些文件将取决于目标设备具有何种类型的 I/O 设备。在本示例中，由于目标设备是一台在 C: 驱动器上安装了常规 Windows XP 的便携式计算机， 所以只要启动目标设备，然后使用 CD/DVD 驱动器来复制这些文件即可。主 Windows XP Embedded 映像位于名为 DriveD 的目录中（因为在目标设备上的目标位置是 D: 驱动器）。该目录包含三个子目录：Documents and Settings、Program Files 和 Windows。此外，还有一个 Automation 目录，其中包含基于窗体的应用程序 (TheAppToTest.exe) 和基于控制台的自动化 (Demo.exe)。将这四个目录复制到目标设备上的 D: 分区。

在试图启动目标设备之前，将需要进行一个小小的改动，使设备可以进行多次启动。只要编辑 Windows XP Embedded 映像的 boot.ini 文件，将下面一行文字添加到 [operating systems] 部分中即可：

multi(0)disk(0)rdisk(0)partition(1)\WINDOWS="Microsoft Windows XP Professional" /fastdetect

如果 D: 分区中的 Windows XP Embedded 映像出现错误， 这能让您轻松地启动目标设备，进入位于 C: 驱动器中的常规 Windows XP 会话。如果目标设备具有软盘驱动器，那么还有另外一个方法，那就是创建用于恢复的可启动软盘。或者可以将原始的 Windows XP 安装 CD 放在便利的地方，这样需要时可用它来启动进入恢复模式。

接下来到关键时刻了。启动目标设备，如果您的 Windows XP Embedded 映像正确，目标设备会启动进入自定义的系统。统称为 First Boot Agent (FBA) 的特殊程序集会在第一次启动时初始化该系统（FBA 不会在后续启动中运行）。

此时便可以执行测试自动化。因为此示例使用的是资源管理器外壳组件，因此该系统具有 GUI 界面。您可以使用 Windows-E 键盘组合来启动 Windows 资源管理器，导航到 D:\Automation，然后双击文件 Demo.exe 以启动测试自动化。结果会类似图 1 中显示的屏幕。另一个方法是将 CMD - Windows 命令处理器 Component 纳入 Windows XP Embedded 映像。这会为您提供一个 cmd.exe 命令外壳，以用于启动测试自动化。

简单概括

让我们概括一下目前为止我们所了解的情况，然后介绍一下此技术的优点。因为 Windows XP Embedded 本质上是 Windows XP 操作系统的另一个版本，所以您可以使用标准的编程技术为其创建一个强大的 UI 测试自动化。

创建用于 Windows XP Embedded 系统的 UI 自动化的一种方法是创建一个 Component，它具有包含在 Windows XP Embedded 映像中的主应用程序及其相关的测试自动化。另一个方法是创建一个没有测试自动化 Component 的普通映像，然后将该自动化单独移植到目标设备中。

测试自动化 Component 方法有三个明显的优点。首先，创建测试自动化 Component 可以使测试工作模块化，从而使其变得更加容易维护和管理。其次，由于后端 Windows XP Embedded Component 数据库可以位于远程服务器，所以测试自动化 Component 可以在开发人员和测试人员团队中轻松共享。第三，创建测试自动化 Component 允许 Target Designer 工具自动检查 Component 的可能较为复杂的软件依赖关系。

请将您想要询问的问题和提出的意见发送至 testrun@microsoft.com.

James McCaffrey 博士供职于 Volt Information Sciences, Inc.，负责管理 Microsoft 向在华盛顿州 Redmond 园区工作的软件工程师提供的技术培训。他参与过多项 Microsoft 产品的工作，其中包括 Internet Explorer 和 MSN Search。James 也是“.NET Test Automation Recipes”（.NET 测试自动化菜单）(Apress, 2006) 一书的作者。您可以通过电子邮件 jmccaffrey@volt.com 或 v-jammc@microsoft.com 与他联系。

Mike Hall是 Microsoft 移动和嵌入式设备团队的高级技术产品经理。Mike 已在 Microsoft 工作了 10 多年，现从事 Windows CE、Windows XP Embedded 以及 Windows Embedded for Point of Service 方面的工作。Mike 出席过很多 Microsoft 和第三方举办的活动，并在撰写讨论嵌入式技术的博客。


基础: WPF 中的三维文字

基础
WPF 中的三维文字
Charles Petzold

代码下载位置: Foundations2007_10.exe (171KB)
目录
FormattedText 和 BuildGeometry
轮廓和网格
Text3D 层次结构
RibbonText 和 SliverText
SolidText 突破
改进工作

诸
如 TrueType 之类的矢量字技术主要供我们灵活准确排版之用，但它们也可以充当图形处理的对象。程序员可以访问定义每个文本字符的实际轮廓，并将它们视为矢量图形对象。这些轮廓可以进行笔划书写、填充、用于剪辑或进行转换。Microsoft® Word 中的常见“艺术字”功能便是以此概念为基础。

认识到这些字符轮廓的特性和局限性非常重要：它们是完全几何性的，缺少操作系统通常在屏幕上呈现字体时所用的“提示”。通过这些提示可以依据可用的像素网格智能地对字符进行光栅化处理。因此，这些无提示的字符轮廓在大字号状态或高分辨率设备上看起来效果最佳。它们通常无法满足在屏幕上呈现普通字号文字的要求。（不过，随着打印机分辨率的提高以及对屏幕图形反失真使用的增多，提示的价值已不像原来那么重要。）

每当遇到新的 Windows® API 时，我都会特意去寻找可访问这些字符轮廓的来源。在 Windows 窗体中，它是 GraphicsPath 类的一部分。AddString 方法的四种重载可让您将字符轮廓添加到一个路径。我所著书籍“Programming Microsoft Windows with C#”（Microsoft Windows 编程——C# 篇）(Microsoft Press, 2002) 和“Programming Microsoft Windows with Microsoft Visual Basic® .NET”（Microsoft Windows 编程——Microsoft Visual Basic® .NET 篇）(Microsoft Press, 2003) 的第 19 章对此过程进行过说明。

在 Windows Presentation Foundation (WPF) 中，提供字符轮廓访问权的类和方法隐藏得更好，但它们确实存在。来自 System.Windows.Media 命名空间的 FormattedText 和 GlyphRun 类均具有名为 BuildGeometry 的方法，这些方法可为特定字体和文本字符串返回 Geometry 对象。在本文中，我将完全使用 FormattedText，因为它是两个类中较容易的一种。我所著书籍“Applications = Code + Markup”（应用程序 = 代码 + 标记）(Microsoft Press, 2006) 的第 28 章和第 30 章中提供了一些 FormattedText 和 BuildGeometry 与二维图形结合使用的示例。

刚开始研究 WPF 中的三维文字时，我很自然地考虑过将这些字符轮廓转为三维文本块的可能性，就像在印刷媒体上或者电视上的飞行徽标效果中看到的那样（请参见图 1 中提供的示例）。我知道这个工作会涉及二维轮廓到三维三角形网格的转换，而除此之外，我只确定一件事：其中涉及的一些编程不会那么简单。

图 1 实心三维文字
图 1 实心三维文字

FormattedText 和 BuildGeometry

我想大多数 WPF 程序员对 FormattedText 类都没有太多接触。正如文档中所述，此类用于“对绘制文本进行低级别控制”。

FormattedText 构造函数需要一个 TypeFace 对象，该对象定义字体系列、样式（如斜体）、可能的加粗，以及与字体相关的任何拉伸或压缩。此外，FormattedText 构造函数还需要 em 尺寸（字体高度）、用于为字体字符着色的刷子，以及文本字符串本身。

创建 FormattedText 对象后，您可以调用各种方法对文本字符串的子集设置不同的字体、样式或格式。通过属性可以设置行距和文本的其他特征。

FormattedText 对象最常见的用法是与 DrawingContext 类的 DrawText 方法一起使用。通常，您会在覆盖由 UIElement 定义的 OnRender 方法时遇到 DrawingContext 类。这是应用程序能做的最低级别的图形输出，仍视为纯 WPF 应用程序。DrawText 方法只需要一个 FormattedText 对象和文本开始的坐标点。

相对于 FormattedText 中的任何其他内容，BuildGeometry 方法似乎有些格格不入。该方法具有一个参数（类型为 Point 的原点），并会返回一个 Geometry 对象。

Geometry 是 WPF 中的重要类，它显然与传统图形路径有关。Geometry 对象是指定为坐标点的直线与曲线的集合。其中的某些直线和曲线可能会有连接；某些连接的直线和曲线可能会闭合，用以描述封闭的区域。Geometry 对象中未包含呈现概念。在二维图形编程中，要呈现一个 Geometry 对象，您只需将它传递给便于使用的 Path 类即可，该类是高级 Shapes 库的一部分。另一种方法是 GeometryDrawing，它以 Geometry 对象、Brush 和 Pen 为基础。

进行二维图形编程时，通常几乎不需要深入特定的 Geometry 对象。但是将 Geometry 对象转换为三维文字的工作则大不相同。那么，FormattedText.BuildGeometry 返回的 Geometry 对象究竟是什么呢？

Geometry 是派生了其他七个类的抽象类。我的经验如下：从 FormattedText.BuildGeometry 返回的 Geometry 对象实际上是一个或多个嵌套 GeometryGroup 对象，其中包含多个 PathGeometry 对象，每个对象对应文本字符串中的一个字符。每个 PathGeometry 对象均包含一个或多个可定义封闭路径的 PathFigure 对象。有些字符（如 l、t 或 x）只需要一个 PathFigure。其他字符则需要两个。如小写的 i，其中的点便需要第二个 PathFigure。O 的外圈轮廓和内圈轮廓各自都需要一个 PathFigure。大写的 B 需要三个 PathFigure 对象。百分比符号则需要五个。

每个 PathFigure 都是 PathSegment（另一个抽象类）类型的对象的集合。根据我的经验，与文本轮廓相关的 PathFigure 对象包含多个 LineSegment、PolyLineSegment、BezierSegment 和 PolyBezierSegment 类型的对象，这些对象构成了单一封闭路径的定义。

WPF 三维没有折线或 Bezier 曲线的概念，但三角形网格以点的集合为基础，因此第一步需要将 FormattedText.BuildGeometry 的 Geometry 对象转换为一系列的封闭折线。原以为这会很简单。但我很快发现，将这些折线转换为三角形网格的算法显然是一项复杂的数学工作，非常可能超出我的能力范围。

轮廓和网格

在本杂志 2007 年 4 月期中，我讨论了生成与 WPF 三维图形配合使用的 MeshGeometry3D 对象的机制（请参阅 msdn.microsoft.com/msdnmag/issues/07/04/Foundations）。最起码，您需要设置 MeshGeometry3D 对象的 Positions 和 TriangleIndices 属性。Positions 属性是三维空间中各点的集合。TriangleIndices 集合说明了如何根据这些点构造三角形。TriangleIndices 中的每三个整数都会引用 Positions 集合中的三个点来形成一个三角形。

我发现，从文本轮廓生成的折线会形成 MeshGeometry3D 的 Positions 集合的一部分。图 2 显示了 sans-serif 字体的大写字母 A，由两条封闭的折线及十一个点组成。第二个图显示了该字母如何划分为七个三角形。对于类似的简单字符，手工书写轻而易举，但是描述代码中的过程对我来说完全没有那么清晰。

图 2 由点和三角形定义的字母
图 2 由点和三角形定义的字母

显而易见，您需要确保每个三角形的边都不会偏离出字符的边界，并且需要确保字符的整个外观由三角形集合所决定。同时请记住，我此处所用的示例是简单的 sans-serif 字体字符。考虑一下 serifed 字体的大写字母 S 的话，就会发现这个工作变得异常困难。现在有一种称为 Delaunay Triangulation 的技术可能会有用，但其中的数学相当复杂。

庆幸的是，希望还是有的。在关注此问题时，我看到一个广告中的某些文字采用了不同的三维文字方式，只包含轮廓，而将每个字符的主体留空。我意识到，这就是我可以做的事。首先我需要一个类名，于是我想到了 RibbonText。

Text3D 层次结构

本文的可下载代码中包含一个名为 Text3D 的 Visual Studio® 解决方案。该解决方案由以下部分组成：一个名为 Petzold.Text3D 的 DLL 项目和使用该 DLL 的若干个基于 XAML 的小型演示程序。请注意，DLL 中的文件具有命名空间 Petzold.Text3D。

Petzold.Text3D.dll 库中的类层次结构以 ModelVisualBase 开头，这是从 WPF 三维类 ModelVisual3D 派生的抽象类，即我在本杂志 2007 年 4 月期中讨论过的技术。但是，此 ModelVisualBase 类与前一专栏中的 ModelVisualBase 不尽相同，因为它需要多一些灵活性。但请注意，其中的概念是相同的：ModelVisualBase 会在内部存储 GeometryModel3D 对象和 MeshGeometry3D 对象，并定义要传输到 GeometryModel3D 对象的公共 Material 和 BackMaterial 属性。

Petzold.Text3D 类中的大多数属性受依赖关系属性支持。ModelVisualBase 类定义了两个 PropertyChanged 事件处理程序（一个静态，一个实例），后代类在定义依赖关系属性时便可使用这两个处理程序。实例版本会适当准备内部 MeshGeometry3D 对象的各种属性以应对各种变化（我在 4 月份专栏中讨论过此过程），然后调用抽象方法 Triangulate。后代类可以覆盖 Triangulate 以定义 MeshGeometry3D 的各种集合。

在 Petzold.Text3D 库中，抽象类 Text3DBase 从 ModelVisualBase 派生而来。此类定义了大量与文本相关的属性，包括 Text、FontFamily、FontStyle、FontWeight、FontStretch 和 FontSize，全部都是 FormattedText 构造函数需要的属性。该类还定义了 BuildGeometry 方法所需的 Origin 属性。上述任一属性发生变化时，该类都会创建一个新的 FormattedText 对象，并根据 BuildGeometry 返回的 Geometry 对象设置 Text3DBase 的第八个属性 TextGeometry：

FormattedText formtxt =
    new FormattedText(Text, CultureInfo.CurrentCulture,
        FlowDirection.LeftToRight,
        new Typeface(FontFamily, FontStyle,
            FontWeight, FontStretch),
            FontSize, Brushes.Transparent);

TextGeometry = formtxt.BuildGeometry(Origin);

此 TextGeometry 对象可用于所有的后代类。

进行堆阵分配之后，Text3DBase 类才能创建新的 TextGeometry 对象。该类需要分配新的 FormattedText 对象，而 BuildGeometry 无疑进行了很多自身内存分配。这些堆阵分配意味着，由 Text3DBase 类定义的属性可能不应为动态。我已经使用由 UIPropertyMetadata 定义的 IsAnimationProhibited 属性来标记一些将会是动画首选的属性。

我所编写的创建三维文字的所有类中，Text3DBase 定义的 Origin 属性是唯一表明文字在三维空间中所处位置的属性，该属性的类型为 Point，表明了二维空间中的位置。我考虑过定义更大的一组属性，将文字精确地放置在三维空间中。这些属性不仅必须包含三维文字的原点，还必须包含表明基线方向的三维矢量，以及表明垂直方向的另一三维矢量。我决定选用 Origin 属性来将文字放置在三维空间中的 XY 平面上，然后使用转换来执行其他所有定位操作。此方法最大的好处就是简化了数学计算。

由 Text3DBase 定义的 FontSize 属性指出了字体的 em 尺寸，这与字体字符的总高度大致相关。在三维中，字体还有深度。DeepTextBase 类从 Text3DBase 派生而来，纯粹用于定义 Depth 属性。（您最后会看到此扩展类层次结构的原因；在这些类的编程过程中发生了相当多的重构，从而形成了现在的结构。） 由于文字位于 XY 平面上，因此我考虑用 Depth 属性来说明文字在负 Z 轴上扩展的深度。

到目前为止，这些类还未进行任何实质性工作。图 3 所示的抽象类 GeometryTextBase 从 DeepTextBase 派生而来，并且覆盖由 ModelVisualBase 定义的 Triangulate 方法。借助 Geometry 类中的 GetFlattenedPathGeometry 方法，GeometryTextBase 类可将 Geometry 对象（作为 TextGeometry 属性出现）转换为多条相连的折线，这些折线与组成文本轮廓的封闭图形相对应。针对每个封闭图形，GeometryTextBase 类会调用一个抽象方法，如下所示：

Figure 3 GeometryTExtBase 类 (Close Figure 3)
public abstract class GeometryTextBase : DeepTextBase
{
    // Field prevent re-allocations during mesh generation.
    CircularList<Point> list = new CircularList<Point>();

    protected override void Triangulate(
        DependencyPropertyChangedEventArgs args,
        Point3DCollection vertices, Vector3DCollection normals,
        Int32Collection indices, PointCollection textures)
    {
        // Clear all four collections.
        vertices.Clear();
        normals.Clear();
        indices.Clear();
        textures.Clear();

        // Convert TextGeometry to series of closed polylines.
        PathGeometry path =
            TextGeometry.GetFlattenedPathGeometry(0.001,
            ToleranceType.Relative);

        foreach (PathFigure fig in path.Figures)
        {
            list.Clear();
            list.Add(fig.StartPoint);

            foreach (PathSegment seg in fig.Segments)
            {
                if (seg is LineSegment)
                {
                    LineSegment lineseg = seg as LineSegment;
                    list.Add(lineseg.Point);
                }
                else if (seg is PolyLineSegment)
                {
                    PolyLineSegment polyline = seg as PolyLineSegment;

                    for (int i = 0; i < polyline.Points.Count; i++)
                        list.Add(polyline.Points[i]);
                }
            }

            // Figure is complete. Post-processing follows.
            if (list.Count > 0)
            {
                // Remove last point if it's the same as the first.
                if (list[0] == list[list.Count - 1])
                    list.RemoveAt(list.Count - 1);

                // Convert points to Y increasing up.
                for (int i = 0; i < list.Count; i++)
                {
                    Point pt = list[i];
                    pt.Y = 2 * Origin.Y - pt.Y;
                    list[i] = pt;
                }

                // For each figure, process the points.
                ProcessFigure(list, vertices, normals,
                    indices, textures);
            }
        }
    }

    // Abstract method to convert figure to mesh geometry.
    protected abstract void ProcessFigure(CircularList<Point> list,
        Point3DCollection vertices, Vector3DCollection normals,
        Int32Collection indices, PointCollection textures);
}

protected abstract void ProcessFigure(
    CircularList<Point> list, Point3DCollection vertices,
    Vector3DCollection normals, Int32Collection indices,
    PointCollection textures);

第一个参数是图形中二维点的集合。CircularList 是我定义的集合类，功能类似于一个循环缓冲区。每当按索引访问成员时，该对象会规范化索引，将其调整到适当范围。换句话说，索引 -1 会访问集合的最后一个成员，索引 list.Count 则会访问集合的第一个成员。该集合包含封闭折线中的所有点。

ProcessFigure 的其他参数分别对应 MeshGeometry3D 对象的 Positions、Normals、TriangleIndices 和 TextureCoordinates 集合。实现 ProcessFigure 方法的类至少需要依据 CircularList 集合中的点来填充顶点和索引集合。

RibbonText 和 SliverText

我编写的真正生成三维文字的第一个类是 RibbonText，如图 4 所示。正如您看到的，该类从 GeometryTextBase 派生而来，完全由 ProcessFigure 方法的实现构成。它生成的 Point3D 对象完全基于 CircularList 集合中的二维点和 Depth 属性。这些点在 XY 平面（其中 Z 等于零）上的点以及 XY 平面后的 Depth 单元之间交替。索引集合将这两组点连接起来，以定义一系列的三角形。

Figure 4 RibbonText 类 (Close Figure 4)
public class RibbonText : GeometryTextBase
{
    protected override void ProcessFigure(CircularList<Point> list,
        Point3DCollection vertices, Vector3DCollection normals,
        Int32Collection indices, PointCollection textures)
    {
        int offset = vertices.Count;

        for (int i = 0; i <= list.Count; i++)
        {
            Point pt = list[i];

            // Set vertices.
            vertices.Add(new Point3D(pt.X, pt.Y, 0));
            vertices.Add(new Point3D(pt.X, pt.Y, -Depth));

            // Set texture coordinates.
            textures.Add(new Point((double)i / list.Count, 0));
            textures.Add(new Point((double)i / list.Count, 1));

            // Set triangle indices.
            if (i < list.Count)
            {
                indices.Add(offset + i * 2 + 0);
                indices.Add(offset + i * 2 + 2);
                indices.Add(offset + i * 2 + 1);

                indices.Add(offset + i * 2 + 1);
                indices.Add(offset + i * 2 + 2);
                indices.Add(offset + i * 2 + 3);
            }
        }
    }
}

请记住，会为特定的文本字符串多次调用 ProcessFigure 方法。GeometryTextBase 中的 Triangulate 方法负责最初清除 MeshGeometry3D 集合；ProcessFigure 类使用名为偏移的整数来确定添加到顶点集合的新点的索引。

图 5 显示了使用 RibbonText 类的一个小 XAML 文件，图 6 则显示了其外观。从某种意义上说，它看起来要比普通的三维文本块“更美观”一些，可能因为它确实不寻常。但是从算法上讲，这是能够想到的三维文字的最简单形式。

Figure 5 RibbonTextDemo.xaml (Close Figure 5)
<!-- RibbonTextDemo.xaml by Charles Petzold, June 2007 -->
<Page xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:src="clr-namespace:Petzold.Text3D;assembly=Petzold.Text3D"
      WindowTitle="RibbonText Demo"
      Title="RibbonText Demo">

    <Viewport3D>
        <src:RibbonText Text="Ribbon"
                FontFamily="Times New Roman" Depth="2">
            <src:RibbonText.Material>
                <DiffuseMaterial Brush="Cyan" />
            </src:RibbonText.Material>

            <src:RibbonText.BackMaterial>
                <DiffuseMaterial Brush="Pink" />
            </src:RibbonText.BackMaterial>
        </src:RibbonText>

        <!-- Lights. -->
        <ModelVisual3D>
            <ModelVisual3D.Content>
                <Model3DGroup>
                    <AmbientLight Color="#404040" />
                    <DirectionalLight Color="#C0C0C0"
                        Direction="2 -3 -1" />
                </Model3DGroup>
            </ModelVisual3D.Content>
        </ModelVisual3D>

        <!-- Camera. -->
        <Viewport3D.Camera>
            <PerspectiveCamera Position="-3 0 8" UpDirection="0 1 0"
                               LookDirection="1 0 -2" FieldOfView="45" />
        </Viewport3D.Camera>
    </Viewport3D>
</Page>
图 6 RibbonTextDemo 外观
图 6 RibbonTextDemo 外观

这些字符实际上是空心的。如果从正面看文字，那么您可以直接看透它。因为只有从某一角度看文字，您才会看到外部和内部的颜色是不同的。字符的外部使用 Material 画笔（在 RibbonTextDemo.xaml 中设置为 Cyan）着色，内部则使用 BackMaterial 画笔（设置为 Pink）着色。但是对于某些字体，这些颜色可以反转。这取决于定义字符轮廓的各个点的方向。可以想象，对于相同字体中的不同字符，此方向甚至会有所不同。

RibbonText 类定义了 TextureCoordinates 集合的点，因此您并不局限于实心颜色画笔。TextureCoordinates 集合中的点以 Y 坐标 0 表示功能区的前景部分，1 表示背景部分，X 坐标则以 CircularList 集合中各点的索引为基础。对此文本所用的任何非实心画笔都会分别应用于每个功能区。当您以起始坐标 (0, 0) 和结尾坐标 (0, 1) 使用 LinearGradientBrush 时，便会产生最可预测的结果。其他画笔可能会在文本字符中无法预测的位置暴露出可见接缝。

比 RibbonText 更难一些的是被我称为 SliverText 的类。此类也仅仅侧重于文本字符的轮廓，但它赋予这些轮廓一个非零宽度，我将其定义为 SliverWidth 属性。图 7 显示了使用默认 FontSize 1、默认 Depth 1 和默认 SliverWidth 0.05 运行的 SliverTextDemo 程序。我根据各点的 X 和 Y 坐标定义了 SliverText 的 TextureCoordinates，允许画笔因文本正面不同而有所不同。该示例显示了一个渐变画笔。字符的顶部看上去有点圆，因为顶部三角形和侧面三角形在边缘共享坐标。WPF 三维会根据平均值计算法线（它控制光线反射）。

图 7 SliverTextDemo 外观
图 7 SliverTextDemo 外观

如果您将 SliverWidth 设置为比 FontSize 高很多的值，那么字符会相互合并。如果之后文字通过转换成为动态，那么可能会有一些难看的颤动，因为表面会争夺对前景的控制。（此效果的技术术语称为“Z 值争夺”。）

SliverText 类某些二维分析几何中纳入一些偏移。每个字符轮廓都是一条二维折线，但 SliverText 需要两组平行折线。我曾试着使用由 Geometry 定义的 GetWidenedPathGeometry 方法，但我在加宽路径方面的经验是，它们经常会带来我极力想避免的产物。

因此，我改为自己加宽路径。此工作需要计算与折线中每条单独的线平行的线。然而，这些平行线通常需要缩短或加长，以便达到与基础路径相同的连续性和形状。助我一臂之力的是我编写的一个小结构，称为 Line2D。此结构将 Line2D 对象和矢量的新增项定义为线条位移。两个 Line2D 对象相乘会返回一个表明交集的 Point 对象。

SolidText 突破

在我开始编写 RibbonText 和 SliverText 的多个月后，我很担心在三维文字道路上不会有更大进展。我偶尔会回到将文本字符外观分成三角形的问题上，但是我一直找不到一种方法，能够不牵涉相当可怕的数学。

当然，我非常了解如何将文本字符串放在三维的平面中。您唯一需要的是基于 TextBlock 组件的 VisualBrush。我当然想到过可以将该平面覆盖在 RibbonText 对象顶部，并创建实心文字效果。但据我的经验，这些问题涉及混合光栅化文字（TextBlock 所显示的内容）和根据几何轮廓构造的文字。这两种不同的算法在视觉上并不匹配。

当我意识到可以依据 RibbonText 中使用的相同几何学，改用 DrawingBrush 覆盖三维空间的平面时，我终于找到了突破口。概念很清晰；但实现过程并非如此。

我将生成此 DrawingBrush 的类称为 PlanarText，它从 Text3DBase 派生而来。PlanarText 需要 Text3DBase 生成的 TextGeometry 属性，但不需要由 DeepTextBase 定义的 Depth 属性，因为（正如类名所示）PlanarText 是在平面上显示文字。但是，PlanarText 类确实定义了名为 Z 的属性，表明文本平面所在位置与 XY 平面平行。

PlanarText 根据两个三角形简单定义一个平面矩形，从而实现了 Triangulate 方法。此矩形四个角的坐标来自与 Geometry 对象（从 TextGeometry 属性获得）相关的 Bounds 属性。

PlanarText 与其他从 ModelVisualBase 派生的类的明显区别在于对 Material 和 BackMaterial 属性的处理方式不同。

我发现有一点非常有趣，最初设想 ModelVisualBase 时，这些 Material 和 BackMaterial 属性都被认为是个麻烦。ModelVisualBase 从 ModelVisual3D 派生而来，但 ModelVisual3D 并不定义 Material 和 BackMaterial 属性。定义 Material 和 BackMaterial 属性的 WPF 三维类实际上是 GeometryModel3D，ModelVisualBase 将该类的一个实例内部存储为其 Content 属性。

ModelVisualBase 需要定义 Material 和 BackMaterial 属性，这样您便可以在标记中的派生类中进行设置，如下所示：

<src:RibbonText Text="Ribbon">
    <src:RibbonText.Material>
        <DiffuseMaterial Brush="Cyan" />
    </src:RibbonText.Material>

    <src:RibbonText.BackMaterial>
        <DiffuseMaterial Brush="Pink" />
    </src:RibbonText.BackMaterial>
</src:RibbonText>

与 ModelVisualBase 所定义的 Material 和 BackMaterial 属性相关的是属性更改处理程序 MaterialPropertyChanged，它只是将分配到这些属性的所有对象传输给内部 GeometryModel3D 对象的相同属性。

但是 PlanarText 需要做一些不同的工作。例如，当 PlanarText 的 Material 属性设置为 DiffuseMaterial 对象时，PlanarText 需要提取与该 DiffuseMaterial 相关的 Brush 对象，然后根据此现有的画笔和 TextGeometry 属性创建新的 DrawingBrush 对象：

new DrawingBrush(new GeometryDrawing(brush, null, TextGeometry))

此 DrawingBrush 会成为新 DiffuseMaterial 对象的基础，PlanarText 然后会将该对象设置为内部 GeometryModel3D 的 Material 属性。

如果分配到 PlanarText 的 Material 属性是 DiffuseMaterial 对象，那么这一过程听上去相当简单。但是，分配到 Material 属性可能是 MaterialGroup 对象，并且此 MaterialGroup 对象可能具有 DiffuseMaterial、SpecularMaterial、EmissiveMaterial 甚至是其他嵌套 MaterialGroup 类型的子项。通常情况下，PlanarText 类需要构造一个要分配给其内部 GeometryModel3D 对象的 Material 对象平行结构，并且与这些 Material 对象相关的每个 DrawingBrush 都需要通过 Brush 对象（来自与 PlanarText 相关的 Material 对象）计算出来。

仅仅因为此 Material 传输逻辑的原因，PlanarText.cs 成了 Text3D 库中最长的文件，即使结果看起来并不多，如图 8 所示。

图 8 PlanarTextDemo 外观
图 8 PlanarTextDemo 外观

我仍对 PlanarText 类不太满意。例如，如果文字大小发生变化，PlanarText 就需要重新创建画笔。如果该类的公共 Material 和 BackMaterial 属性的结构与内部 GeometryModel3D 对象的属性结构相同，则可避免重新创建大量的 Material 对象。但是如果将与这些 Material 对象相关的其中一个画笔动态化（可能通过 ColorAnimation），则 PlanarText 必须在每次传递时都重新创建 GeometryDrawing 和 DrawingBrush 对象，那样成本会很高。

PlanarText 本身不是很重要，但在实现 SolidText 类过程中起了相当作用。图 9 所示的 SolidText 从 DeepTextBase 派生而来，这意味着它具备 Text3DBase 定义的所有文本相关属性及 Depth 属性。但 SolidText 本身无意于生成任何三角形网格。它会覆盖 Triangulate 方法，但只从该方法返回。

Figure 9 SolidText 类 (Close Figure 9)
public class SolidText : DeepTextBase
{
    public SolidText()
    {
        // Create RibbonText and two PlanarText children.
        RibbonText ribbon = new RibbonText();
        ribbon.Depth = Depth;
        Children.Add(ribbon);

        PlanarText planar = new PlanarText();
        Children.Add(planar);

        planar = new PlanarText();
        planar.Z = -Depth;
        Children.Add(planar);
    }

    // SideMaterial dependency property and property.
    public static readonly DependencyProperty SideMaterialProperty =
        DependencyProperty.Register("SideMaterial",
            typeof(Material), typeof(SolidText),
            new PropertyMetadata(SideMaterialChanged));

    public Material SideMaterial
    {
        set { SetValue(SideMaterialProperty, value); }
        get { return (Material)GetValue(SideMaterialProperty); }
    }

    // SideMaterialChanged handlers.
    static void SideMaterialChanged(DependencyObject obj,
        DependencyPropertyChangedEventArgs args)
    {
        ((SolidText)obj).SideMaterialChanged(args);
    }

    void SideMaterialChanged(DependencyPropertyChangedEventArgs args)
    {
        // Transfer SideMaterial to RibbonText.
        Text3DBase txtbase = Children[0] as Text3DBase;
        txtbase.Material = args.NewValue as Material;
        txtbase.BackMaterial = args.NewValue as Material;
    }

    // MaterialChanged override.
    protected override void MaterialPropertyChanged(
        DependencyPropertyChangedEventArgs args)
    {
        // Transfer Material and BackMaterial properties to PlanarText.
        if (args.Property == MaterialProperty)
            ((PlanarText )Children[1] ).Material =
                                    args.NewValue as Material;

        else if (args.Property == BackMaterialProperty)
            ((PlanarText)Children[2]).BackMaterial =
                                   args.NewValue as Material;
    }

    // TextPropertyChanged override.
    protected override void TextPropertyChanged(
        DependencyPropertyChangedEventArgs args)
    {
        base.TextPropertyChanged(args);

        // Transfer text-related property to all three children.
        for (int i = 0; i < 3; i++)
        {
            Text3DBase txtbase = Children[i] as Text3DBase;
            txtbase.SetValue(args.Property, args.NewValue);
        }
    }

    // PropertyChanged override.
    protected override void PropertyChanged(
        DependencyPropertyChangedEventArgs args)
    {
        if (args.Property == DepthProperty)
        {
            // Set Depth property to RibbonText and PlanarText children.
            double depth = (double)args.NewValue;
            ((DeepTextBase )Children[0]).Depth = depth;
            ((PlanarText)Children[2]).Z = -depth;
        }

        base.PropertyChanged(args);
    }

    // Move on, move on. Nothing to see here.
    protected override void Triangulate(
        DependencyPropertyChangedEventArgs args,
        Point3DCollection vertices, Vector3DCollection normals,
        Int32Collection indices, PointCollection textures)
    {}
}

实际上，SolidText 会充分利用它从 ModelVisual3D 继承的属性，即 Children 属性。SolidText 可具有类型 ModelVisual3D 的子项，因此也可具有 RibbonText 和 PlanarText 类型的子项。应用到 SolidText 的任何转换都会应用到它所有的子项。此外，SolidText 定义了要应用到 RibbonText 子项的 SideMaterial 属性。SolidText 的主要工作是将对其自身设置的属性分发到其所有子项。

SolidTextDemo 程序会旋转 SolidText 对象，这可在上文的图 1 中看到。为测试 PlanarText 中的 Material 传输逻辑，我为该图提供了一个渐变画笔和旋转时可捕获光线的反射材料。

改进工作

解决了将 RibbonText 和 PlanarText 图形组合到统一的文本块这一问题后，我决定尝试将表面变得圆滑些。EllipticalText 类与 SliverText 相似，只是文本字符轮廓上的每个点变成了椭圆而不是矩形。EllipticalText 类实际上要比 SliverText 短一点。

RoundedText 从 SolidText 派生而来，并使用其构造函数来替换 SolidText 通过 EllipticalText 对象创建的 RibbonText 子项。此组合使得文字在 PlanarText 对象的边缘具备了有些圆形的外观，并让文字块的中间部分饱满一点，在图 10 中可以清楚地看到。

图 10 实心三维文字
图 10 实心三维文字

圆形程度由 EllipticalText 和 RoundedText 的 EllipseWidth 属性控制，默认值为 0.05。如果作为 TextSize 属性一部分的该属性设置得过大，将会导致字符相互合并，并在动画期间出现 Z 值争夺。

我希望有一天能够实现一种算法，让我们更灵活地将矢量字分成三角形。就现在而言，这些类已经足够。我知道目前世界上正严重缺乏融入了三维文字效果的飞行徽标，我谨希望此处的微薄贡献能一解燃眉之急。

请将您想要向 Charles 询问的问题和提出的意见发送至 cp@charlespetzold.com.

Charles Petzold是《MSDN 杂志》的特约编辑。他的最新著作是“3D Programming for Windows”（Windows 3D 编程）(Microsoft Press, 2007)。


借助 C++ 进行 Windows 开发: Task Scheduler 2.0

借助 C++ 进行 Windows 开发
Task Scheduler 2.0
Kenny Kerr

代码下载位置: WindowsWithC++2007_10.exe (156KB)
目录
任务服务和存储
使用任务服务
任务定义
任务操作
任务触发器
下一步是什么？

T
ask Scheduler 在 Windows Vista® 中得到了彻底的革新。尽管有一些相似之处，但新的 Task Scheduler（称为 Task Scheduler 2.0）比原来的工具（自 Windows® 98 起便已存在）要强大许多。它不再仅仅是一个供最终用户使用的工具，而是一个用于设计和管理复杂后台操作的强大平台——甚至在很多情况下，它可以避免对 Windows 服务进行开发。

假设您的项目需要自动检查更新。您可以考虑编写一个在后台运行的 Windows 服务，每隔几天就会检查是否有更新。如果服务不是必须全天候运行，那么可以设计一个计划任务，每隔几天才运行一次，检查是否有更新，然后停止。更好的是，您可以确保它只在用户登录后才运行，这样没有人执行更新时就不会不必要地消耗资源。

Windows Vista 的很多部件都用新的 Task Scheduler 来管理后台任务，这一简单事实证明了 Microsoft 开发人员认为它会大有作为。另外一个好处是减少了原本在处理所有这些任务时必然要创建的新服务数量。同时，它还可以简化 Windows 管理和诊断，因为通过询问任务来了解它在做什么比找出黑盒子 Window 服务在忙什么要简单得多。对于本身就构成 Windows 一部分的任务，Task Scheduler 也充当任务主机，将许多任务特定的进程合并成一个，从而进一步减少承载这些操作所需的资源。

在本专栏中，我将探讨主要概念和组成 Task Scheduler 的构造块，以便您尽快上手并立即开始从这个出色的新服务受益。

任务服务和存储

故事还得从 Task Scheduler 服务说起，该服务负责对任务进行实际计划。由于 Task Scheduler 使用文件系统来存储任务信息，所以可以在没有服务帮助的情况下枚举和准备任务，但是在没有服务的情况下不可能坚持很久。未来版本的 Task Scheduler 确实可能会在很大程度上改变存储格式和位置，因此建议不要指望它。实际上，不依赖于直接访问存储这一点很关键，因为 Task Scheduler 不能识别直接写入文件系统的任务，并将可能拒绝运行已被篡改的任务。既然 Task Scheduler 是 Windows Vista 如此不可或缺的一部分，管理员就不再可能只是禁用它。这对于开发人员来说是一个好消息，因为它意味着您可以安全地依赖它来执行后台操作，并将任务存储抽象化。

按照惯例，任务会根据公司、产品和组件名称进行分组。例如，Windows 磁盘碎片整理程序将其任务存储在 \Microsoft\Windows\Defrag 子文件夹中。每个任务都存储为由 Task Scheduler 架构定义的 XML 文档。然后即可使用诸如 XmlLite 的 XML 分析器直接创建任务（请参阅我以前的《MSDN® 杂志》文章，位于 msdn.microsoft.com/msdnmag/issues/07/04/Xml/），或使用大量由 Task Scheduler API 提供的扩展 COM 接口。这些 COM 接口还允许您直接提供和查询任务的 XML 定义。这样，您就可获得以下好处：使用 XML 编写和查询任务，同时避开任务的实际存储。Task Scheduler 还可以远程访问。

使用任务服务

ITaskService 接口是通向 Task Scheduler API 的网关。您将需要的所有定义都位于 taskschd.h 头文件中。当然，您也会需要最新的 Windows SDK，其中包含对 Windows Vista 的支持。首先，创建一个本地的进程内实例，如下所示：

CComPtr<ITaskService> service;
HR(service.CoCreateInstance(__uuidof(TaskScheduler)));

（在这些示例中，我使用 HR 宏来明确标识方法返回需要被检查的 HRESULT 的位置；这可以替换为适当的错误处理方法——不管是引发异常还是返回 HRESULT 自身都可以。）

接下来，调用 Connect 方法，与您所选计算机上的任务计划程序建立连接。Connect 方法的定义如下：

HRESULT Connect(
    VARIANT computer, VARIANT user,
    VARIANT domain, VARIANT password);

虽然 VARIANT 类型的使用令人遗憾，但是在使用方便的活动模板库 (ATL) CComVariant 派生类方面，处理起来却非常容易。要连接到本地计算机，只要省略计算机名称即可。要使用调用方的身份或有效的令牌进行连接，则不要指定用户名。要使用本地计算机的当前域，则不要指定域名。最后，如果指定了用户名，则仅指定一个密码。因此，调用最简单形式的 Connect 方法（如下所示）即可连接到本地计算机的 Task Scheduler 服务：

HR(service->Connect(
    CComVariant(),   // local computer
    CComVariant(),   // current user
    CComVariant(),   // current domain
    CComVariant())); // no password

现在便可执行各种不同的操作，例如创建任务和枚举已有的文件夹和任务。因为在将任务存储到文件夹之前无法使用它们，因此第一步是要了解 ITaskFolder 接口。ITaskService 的 GetFolder 方法会为指定文件夹返回一个 ITaskFolder 接口指针。反斜杠表示根任务文件夹，如下所示：

CComPtr<ITaskFolder> folder;
HR(service->GetFolder(CComBSTR(L"\\"), &folder));

然后可以为您的应用程序创建一个文件夹，如下所示：

CComPtr<ITaskFolder> newFolder;
HR(folder->CreateFolder(
    CComBSTR(L"Company\\Product"),
    CComVariant(), &newFolder));

CreateFolder 可选的第二个参数为新创建的文件系统文件夹指定安全描述符。如果省略这一步，该文件夹便从它的父项继承安全描述符。您可以使用安全描述符定义语言 (SDDL) 覆盖默认的访问控制。例如，为了向管理员和本地系统提供完全控制，可以使用以下 SDDL：

HR(folder->CreateFolder(
    CComBSTR(L"Company\\Product"),
    CComVariant(L"D:(A;;FA;;;BA)(A;;FA;;;SY)"),
    &newFolder));

简单地说，D: 表明后面的访问控制项 (ACE) 可以作为自定义访问控制列表 (DACL) 的组成部分。第一项表明允许访问 (A)，并且将文件全部 (FA) 授予内置管理员 (BA)。第二项表明将相同的访问权限授予本地系统 (SY)。请记住，安全描述符定义只用于创建 Product 文件夹，而父文件夹 Company 仍将使用继承的安全描述符来创建。同时确保您不会拒绝访问 Local System 帐户，因为那是 Task Scheduler 服务使用的身份，当计划程序无法访问其应该计划的任务时肯定会发生意外行为！

给定一个已有的文件夹，您可以使用 ITaskService 的 GetFolder 方法来打开相对于根任务文件夹的文件夹。注意，ITaskFolder 会提供同一个方法来打开相对于该接口实例所代表文件夹的文件夹：

CComPtr<ITaskFolder> productFolder;
HR(service->GetFolder(
    CComBSTR(L"Company\\Product"),
    &productFolder));

CComPtr<ITaskFolder> componentFolder;
HR(productFolder->GetFolder(
    CComBSTR(L"Component"),
    &componentFolder));

图 1 阐明了本专栏下载随附的 Task Scheduler Explorer 示例应用程序。它可供您方便地查看任务，并且在开始使用任务计划程序 API 时很有帮助。

图 1 Task Scheduler Explorer
图 1 Task Scheduler Explorer(单击该图像获得较小视图)
图 1 Task Scheduler Explorer
图 1 Task Scheduler Explorer (单击该图像获得较大视图)

任务定义

在创建任务之前，需要根据任务定义对其建模。图 2 说明了组成任务定义的各个部分。由于任务定义的复杂性及其在创建任务时的必要性，所以我建议不要立即开始创建任务。首先了解一下基本构造块会有所帮助。在开始深入讨论定义操作和触发器的具体细节之前，让我们简单了解一下任务定义的构成和创建方式。

Figure 2 任务定义组成部分 (Close Figure 2)

任务 | 定义
操作 | 一个或多个操作定义了任务将执行的工作。
触发器 | 一个或多个触发器指示何时启动任务。
主体 | 授权和审核是基于安全上下文的。
设置 | 这些设置控制任务的运行时行为和限制。
数据 | 可供操作使用的字符串。
RegistrationInfo | 管理记帐信息。

首先，要求 Task Scheduler 服务创建一个可以填充的空任务定义：

CComPtr<ITaskDefinition> definition;
HR(service->NewTask(0, // reserved
                    &definition));

在特定文件夹中注册任务定义之前，需要对它填充至少一个操作，以及可选的触发器和其他信息。准备好之后，就可以使用 RegisterTaskDefinition 方法在文件夹中注册它，如下所示：

CComPtr<IRegisteredTask> registeredTask;
HR(folder->RegisterTaskDefinition(
    CComBSTR(L"Task"),
    definition,
    TASK_CREATE_OR_UPDATE,
    CComVariant(), // user name
    CComVariant(), // password
    TASK_LOGON_INTERACTIVE_TOKEN,
    CComVariant(), // sddl
    &registeredTask));

系统会返回一个 IRegisteredTask 接口指针，代表新注册的任务。此接口可用于启动或停止任务，以及检索与任务有关的运行时信息。

RegisterTaskDefinition 的第一个参数指定新任务的名称。由于这个值也可用来命名保存在文件夹中的文件，因此它必须符合文件系统的命名约定。此参数可以设置为空值，该方法会生成一个 GUID 以用作名称，但是不建议这么做，因为对计算机的管理员来说，这没有什么意义或帮助。第二个参数指定描述即将创建的任务的任务定义。我将在下面的部分中深入探讨更多细节。第三个参数指定 TASK_CREATION 枚举中的一个值。最常见的值是 TASK_ CREATE 和 TASK_UPDATE，而 TASK_CREATE_OR_UPDATE 只是两者的位或。

接下来的三个参数指定注册凭据。RegisterTaskDefinition 将确保指定的用户对已注册任务至少有读取权限，即使您在随后的参数中指定的 DACL 没有特别对其授权。Task Scheduler 支持多种不同的登录技术。前一个示例使用 TASK_LOGON_INTERACTIVE_TOKEN，表明只有在为指定的用户给定交互式登录会话时才运行任务。如果未提供用户名和密码，它就会假设调用方的身份。因为要求进行交互式登录会话，所以不会存储密码。

TASK_LOGON_PASSWORD 是另一个选择，它指示 Task Scheduler 为任务创建一个批处理登录会话。在这种情况下，必须同时提供用户名和密码，并且必须授予该帐户“作为批处理作业登录”特权。

还有一个选择是 TASK_LOGON_S4U，它提供了更为安全的方法。它利用 S4U (Service-for-User) 登录，代表指定用户运行任务，但是不需要存储密码。由于 Task Scheduler 在本地系统帐户内运行，所以它可以创建 S4U 登录会话，并接收不仅可用于标识、而且可在本地计算机上进行模拟的令牌。通常 S4U 令牌仅适用于标识。

除了在注册任务时提供的凭据以外，IPrincipal 接口还可进一步控制将针对任务而提供的安全上下文。特别是，对于为任务承载任务计划程序引擎的进程而言，IPrincipal 提供了一个控制其执行级别的简便方法。首先，查询用于相关主体对象的任务定义，然后根据需要设置 RunLevel 属性。这一步需要在注册任务定义之前完成。以下是一个示例：

CComPtr<IPrincipal> principal;
HR(definition->get_Principal(&principal));
HR(principal->put_RunLevel(TASK_RUNLEVEL_HIGHEST));

TASK_RUNLEVEL_HIGHEST 表明应为特定的用户在最不具限制性的安全上下文中运行任务。假设用户是管理员，它会为进程提供不受用户帐户控制 (UAC) 限制的令牌。另一个选择是 TASK_RUNLEVEL_LUA，它提供一个受限制的，或未经提升的令牌。

任务操作
Task Scheduler .NET

尽管 不存在用于 Task Scheduler 的 Microsoft® .NET Framework 包装，但是因为有一种称为运行时可调用包装 (RCW) 的公共语言运行库 (CLR) 功能，因此它还是非常易于使用的。

既然 Task Scheduler 提供了易于进行脚本编写的 COM 接口，因此不费吹灰之力便可使用托管代码中的 API。只需使用 Visual Studio®“添加引用”对话框来引用类型库即可。由于 Task Scheduler 2.0 的类型库命名为 TaskScheduler1.1 Type Library，所以会产生一点误解。不过，Visual Studio 会创建一个互操作程序集，该程序集会提供 COM 接口的所有托管定义，因此用 RCW 便可直接在托管代码中构造和引用不同的 COM 对象。

下面是一个示例，它阐明了本专栏第一部分中代码示例的 C# 同等功能代码，以及连接到 Task Scheduler 服务并为您产品的任务创建一个文件夹：

ITaskService service = new TaskSchedulerClass();
service.Connect(null, // local computer
                null, // current user
                null, // current domain
                null); // no password

ITaskFolder folder = service.GetFolder("\\");
ITaskFolder newFolder = folder.CreateFolder(
    "Company\\Product", "D:(A;;FA;;;BA)(A;;FA;;;SY)");

从这里开始，您可以继续创建任务定义、操作、触发器等等，完全以您喜欢并且针对 .NET Framework 的语言进行。

在为下一步工作进行了铺垫之后，让我们来了解一下任务计划程序所提供的操作类型的多样性。用 IActionCollection 接口指针（由任务定义的 Actions 属性返回）可创建任务并将其添加到任务定义中：

CComPtr<ITaskDefinition> definition;
HR(service->NewTask(0, // reserved
                    &definition));

CComPtr<IActionCollection> actions;
HR(definition->get_Actions(&actions));

IActionCollection 提供 Create 方法，可创建新的操作对象并将其添加到集合中。这些操作本身可通过派生自 IAction 的接口来公开。以下是相关的示例：

CComPtr<IAction> action;
HR(actions->Create(TASK_ACTION_EXEC, &action));

TASK_ACTION_EXEC 是迄今为止最为常见的操作类型，它说明一种命令行操作，并可用于启动所有类型的程序。它甚至可以启动文档和通过 Windows 外壳注册的其他文件类型。要设置操作特定的属性，需要先查询类型特定的接口，然后 CComQIPtr 会妥善处理这项工作：

CComQIPtr<IExecAction> execAction(action);

假如有一个 IExecAction 接口指针，现在即可将操作配置为运行您选择的命令。下面是一个示例，它启动 Sysinternals Contig 工具，对我的虚拟硬盘驱动器映像进行碎片整理：

HR(execAction->put_Path(CComBSTR(L"g:\\Tools\\contig.exe")));
HR(execAction->put_Arguments(CComBSTR(L"-s g:\\VirtualMachines")));

尽管运行一个命令对管理员来说并不麻烦，但是对于开发人员来说这并不总是最合适的解决方案，因为命令并不知道它在任务中所代表的操作，也不知道它甚至是计划任务的一部分。庆幸的是，Task Scheduler 提供了另一种操作类型，它旨在解决此问题。TASK_ACTION_COM_HANDLER 操作类型创建一个 COM 服务器，并向其查询您必须要实现的 ITaskHandler 接口。这样便在操作和 Task Scheduler 引擎之间建立一个通信渠道，使应用程序特定的操作和 Task Scheduler 之间的集成更简洁。

下面介绍创建 COM 操作的方法：

CComPtr<IAction> action;
HR(actions->Create(TASK_ACTION_COM_HANDLER, &action));

CComQIPtr<IComHandlerAction> comAction(action);
HR(comAction->put_ClassId(CComBSTR(
    L"{25C6DB11-4ADC-4e89-BA47-04576C7AA46A}")));

您无疑需要一个 COM 服务器，它用指定的类标识符 (CLSID) 进行注册，并实现 ITaskHandler 接口。图 3 提供了借助于 ATL 实现 ITaskHandler 的 COM 类的示例。

Figure 3 COM 操作类 (Close Figure 3)
class DECLSPEC_UUID("25C6DB11-4ADC-4e89-BA47-04576C7AA46A")
DECLSPEC_NOVTABLE CoSampleTask :
    public CComObjectRootEx<CComMultiThreadModel>,
    public CComCoClass<CoSampleTask, &__uuidof(CoSampleTask)>,
    public ITaskHandler
{
public:
    DECLARE_REGISTRY_RESOURCEID(IDR_SAMPLETASK)
    BEGIN_COM_MAP(CoSampleTask)
        COM_INTERFACE_ENTRY(ITaskHandler)
    END_COM_MAP()

private:
    STDMETHODIMP Start(IUnknown* taskScheduler, BSTR data);
    STDMETHODIMP Stop(HRESULT* actionResult);
    STDMETHODIMP Pause();
    STDMETHODIMP Resume();
};

此外还有另外一个步骤需要执行，截至撰写本文为止它还没有编入任何文档，但是如果没有这一步，您的 COM 操作将无法加载。当 Task Scheduler 引擎使用您的 CLSID 调用 CoCreateInstance 函数时，它预期使用 CLSCTX_LOCAL_SERVER 上下文将您的 COM 服务器加载到单独的进程中（除非您的操作是 Windows 本身的一部分，在这种情况下它可能会进行进程内加载）。为了能获得成功，COM 服务器需要配置成允许在替代进程中激活。从技术上说，您可以使用传统的进程外组件，但是这在大多数情况下都不太常见，也不太实用。实际上通过更新注册代码便可方便地解决这个问题。更新您的 ATL 注册以包括 DllSurrogate 值。以下是一个示例：

HKCR
{
    NoRemove AppID
    {
        '%APPID%' = s 'SampleTask'
        {
            val DllSurrogate = s ''
        }
        'SampleTask.DLL'
        {
            val AppID = s '%APPID%'
        }
    }
}

COM 操作在很多方面都要优于其他操作类型。值得注意的是，COM 操作是允许以妥善和可预测的方式停止操作的唯一操作类型。当 Task Scheduler 引擎创建 COM 类的实例之后，它会调用 Start 方法，并提供可用来与 Task Scheduler 通信的接口指针。然后 Start 方法会查询 ITaskHandlerStatus 接口，并为任务提供一个简单的机制，将其进度和最终完成情况通知给任务计划程序。该操作可以用两种方式结束。用户可以决定使用各种方法来结束任务，所有这些方法最终都会调用 COM 类的 Stop 方法。或者，您的 COM 类可以通过调用 ITaskHandlerStatus 的 TaskCompleted 方法来报告它已完成自己的职责。

提供的另一个有用操作类型允许任务发送电子邮件消息。与前一个操作类型不同，此类型对于执行操作或管理任务的用处要小一些，但是它非常适合于作为通知机制使用。下一部分将讨论触发器，但现在我们只需要知道任务可以由事件启动，而不是传统的基于日历或时间的触发器。以下是创建电子邮件操作的方法：

CComPtr<IAction> action;
HR(actions->Create(TASK_ACTION_SEND_EMAIL, &action));

CComQIPtr<IEmailAction> emailAction(action);
HR(emailAction->put_From(CComBSTR(L"kenny@example.com")));
HR(emailAction->put_To(CComBSTR(L"karin@example.com")));
HR(emailAction->put_Subject(CComBSTR(L"subject")));
HR(emailAction->put_Body(CComBSTR(L"body")));
HR(emailAction->put_Server(CComBSTR(L"mail.example.com")));

任务触发器

Task Scheduler 提供各种触发器，允许您在无用户干预的情况下启动任务。可供使用的有基于时间和日历的常见触发器，以及很多基于事件的有用触发器。

任务定义的 Triggers 属性返回的 ITriggerCollection 接口指针可用来创建任务并将其添加到任务定义中：

CComPtr<ITriggerCollection> triggers;
HR(definition->get_Triggers(&triggers));

ITriggerCollection 提供了一个 Create 方法，该方法可以创建新的触发器对象，并将其添加到集合中。触发器本身可通过派生自 ITrigger 的接口来公开。以下是一个触发器的示例，可能适用于上一部分中启动碎片整理操作的示例：

CComPtr<ITrigger> trigger;
HR(triggers->Create(TASK_TRIGGER_WEEKLY, &trigger));

CComQIPtr<IWeeklyTrigger> weeklyTrigger(trigger);
HR(weeklyTrigger->put_StartBoundary(CComBSTR(
    L"2007-01-01T02:00:00-08:00")));
HR(weeklyTrigger->put_DaysOfWeek(0x01)) // Sunday

TASK_TRIGGER_WEEKLY 是 11 个触发器类型之一，新 Task Scheduler 的初始版本中提供了这些触发器。图 4 提供了可供您使用的触发器类型概要，以及与各个触发器相关的 COM 接口。StartBoundary 属性指出了将触发任务的最早日期，以及将在当天启动任务的时间。DaysOfWeek 属性指定要在某星期的哪几天运行任务。它使用了位掩码，所以您可以根据需要合并任意天数。在上例中的触发器会导致任务在每个星期日的凌晨 2 点开始运行。PST 始于 2007 年的第一天。

Figure 4 触发器类型 (Close Figure 4)

类型 | 接口 | 用法和选项
TASK_TRIGGER_EVENT | IEventTrigger | Windows 事件日志定义的事件。
TASK_TRIGGER_TIME | ITimeTrigger | 当日时间；可选的随机延迟。
TASK_TRIGGER_DAILY | IDailyTrigger | 每 n 天；可选的随机延迟。
TASK_TRIGGER_WEEKLY | IWeeklyTrigger | 指定的周天数；每 n 周；可选的随机延迟。
TASK_TRIGGER_MONTHLY | IMonthlyTrigger | 指定的月天数；指定的月数；可选的月份的最后一天；可选的随机延迟。
TASK_TRIGGER_MONTHLYDOW | IMonthlyDOWTrigger | 指定的周天数；指定的月周数；指定的年月数；可选的每月的最后一周；可选的随机延迟。
TASK_TRIGGER_IDLE | IIdleTrigger | 当计算机空闲时。
TASK_TRIGGER_REGISTRATION | IRegistrationTrigger | 当创建或更新任务时；可选的延迟。
TASK_TRIGGER_BOOT | IBootTrigger | 当启动计算机时；可选的延迟。
TASK_TRIGGER_LOGON | ILogonTrigger | 当用户登录时；可选的延迟。
TASK_TRIGGER_SESSION_STATE_CHANGE | ISessionStateChangeTrigger | 各种会话事件。

下一步是什么？

有很多无法抗拒的原因让我们使用 Windows Vista 及更高版本中的新 Task Scheduler。如果您已依赖于 Windows 以前版本中的 Task Scheduler，您肯定会为所有的新特点和功能拍手叫好。实际上，Task Scheduler 可能很快就会替代现在为应用程序编写的许多自定义构建的计划程序。

在本专栏中我还未来得及介绍 Task Scheduler 的所有功能。如果花点时间研究一下 Windows SDK (msdn2.microsoft.com/en-us/library/aa383614.aspx) 中的文档，您会发现一些其他功能，包括直接用 XML 格式编写任务的功能、对任务执行时所在的安全上下文拥有更多控制、枚举和管理运行中任务的功能等等！

请将您想要向 Kenny 询问的问题发送至 mmwincpp.

Kenny Kerr是一名专门从事 Windows 软件开发的软件专家。他热衷于撰写有关编程和软件设计的文章并向开发人员讲授与此有关的知识。可通过 weblogs.asp.net/kennykerr 与 Reach Kenny 联系。


从 C++ 到 .NET: EEK!-osystem 之详解

从 C++ 到 .NET
EEK!-osystem 之详解
Stanley B. Lippman

目录
环境中的危险和奖励
布置领域
总结

在
本专栏上一期内容中，我开始构建我的老鼠和称为 EEK! 的老鼠环境模拟。我讨论了分析环境 XML 文件的两种主要方法：firehose 和文档对象模型 (DOM)。本月我会从创建 XML 文档开始，然后向您展示将 XML 文件并入程序的另一种方法：使用 .NET 基础类库的 System::Data 命名空间内的 DataSet。DataSet 会将文档用作一组关系表，通过唯一的键值便可遍历这些表。

第一件事是生成模拟环境的说明，以便让程序在应用过程中重建和操作该环境。为此，我需要确定如何以最佳方式设计 XML 文档，以展现老鼠在模拟环境中的生存场景。

我所创建的环境（如果您对此不熟悉，请阅读 2007 年 6 月期的专栏 msdn.microsoft.com/msdnmag/issues/07/06/NettingC）具有唯一的约束。我需要在宏观和微观级别表现它，并且能够上下滑动以便同时检查各自的状态，也就是说在模拟器的 Tick 之内。例如，在微观级别，我想跟踪环境内化学分子的移动（不同实体的气味，如食物或者一只猫）。在宏观级别，我需要跟踪结果进度和老鼠的动作。老鼠的很大一部分行为是由触发相关程序功能的化学分子驱动的，因为和人类不同，它依赖于嗅觉而非视觉来探索所在环境。我称之为“可伸缩环境地图”或 SEM。

环境中的危险和奖励

该模拟环境虽然包含危险和奖励，但它本身只是一个映射。它代表一个连续的区域，老鼠在其中四处活动，遭遇记忆深刻的实体，如筑巢地点、食物、芒刺、电击、气味等等（请参见图 1）。老鼠和环境之间的这些碰撞毫不夸张地为环境增添色彩。

图 1 中性领域区隔属性
图 1 中性领域区隔属性(单击该图像获得较小视图)
图 1 中性领域区隔属性
图 1 中性领域区隔属性 (单击该图像获得较大视图)

在模拟中，老鼠探索环境的经历通过维护一张个体地图来“记忆”。该地图从空白开始，地图中的颜色表示老鼠如何经历该部分的环境（与它找到的实体交互），并且允许它通过识别相似的模式来确定和评估环境中的后续区域。共有五种颜色，与四类评估相关。

绿色和深蓝表示奖励性的体验——食物和饮料、筑巢地点等。亮蓝表示恐惧——一些可怕的遭遇（此案例中用模拟电击表示）。红色表示愤怒或挫折——老鼠在目标寻求过程中遇到的某些挫折（通过我称之为“芒刺”的实体体现）。橙色则表示中性体验。

该模拟显示的一个面板展现了随之展开的环境实时着色。由于环境是特意安排的，因此根据老鼠配置的本性，应能够预测结果地图以及老鼠实现其目标的能力。

在实践中，这意味着模拟中的每个移动（有感觉的）实体都有一张“事实”地图和一张主观地图。事实地图依据 XML 文档构建，代表模拟环境的物理特性。每张主观地图则通过探索而构建，并按个体体验着色。它始终是“事实”地图的子集。老鼠的最初搜寻动力是通过其主观地图划出一块个体领地，其中包含食物、水和筑巢地点，并且能够进行防御。我在模拟中填充了竞争领地的多个有感知实体之后，环境中的芒刺和电击才能表示危险和约束。图 1 可让您初步了解如何填充该环境的领域区隔。

环境划分为多个区隔，通过一到四个表示老鼠可用作入口和出口的“doorto”相连。该模拟从一个初始笼室开始，该笼室具有三个表示可选路径的 doorto。此笼室对于老鼠来说“不甚舒适”：这里没有地面覆盖层，完全暴露在强烈的白光之下。出于性能原因，该模拟只保留老鼠目前探索的区隔，以及通过 doorto 与该区隔相连的所有区隔。这构成了工作集（请参见图 2）。

图 2 内存中的领域区隔
图 2 内存中的领域区隔

布置领域

XML 文档内需要表示的元素由区隔集、起连接作用的 doorto，以及区隔内出现的实体所构成：

<element name="tile" ...
<element name="doorto" ...
<element name="burr" ...
<element name="e-shock" ...

首要的问题是确定如何将各元素关联起来。每种类别的元素都具独一无二的属性，还可以反射出老鼠先前可能遇到过的场合的各个方面（每次它遇到令老鼠“记起”先前体验的元素，表示先前体验的颜色就会传到新的元素。随着时间的推移，这会加快老鼠对所在环境的反应速度）。

组织 XML 的一种方法是将每个元素说明嵌套在相应的区隔中。在图 1 中，这意味着两个芒刺、两种气味、电击、筑巢地点以及食物和水都会在区隔说明中进行描述。这样，通过分析区隔定义，您也会得到区隔内所有实体的完整说明。这也使说明变得复杂，而且使人类读者难以理解文档本身。

文档选择将单独的实体组合起来——首先是所有的区隔，然后是 doorto、芒刺等等。区隔内的每个实体都通过一个唯一 ID 以及区隔内的位置来表示。也就是说，元素在文档内显示为按键进行关联的一组表。

未在元素所在的区隔内定义元素的第二个目的可以说是 doorto 的双重身份。一个 doorto 连接了两个（而且仅两个）区隔。它并不真正属于内部。它与部分引发了 C++ 中友元机制的设计问题相似。我创建了 Matrix 类和 Vector 类。现在我创建一个乘法运算符，将 Matrix 与 Vector 相乘。我应将它置于何处？它也不属于内部；但是出于性能原因，我将它作为两者的友元。这便是我对 doorto 的理解；它们并不真正属于它们所连接的区隔。

和大多数设计解决方案一样，这存在一个问题：如何将 doorto 连接到区隔、芒刺、气味或其他任何实体。我通过在两组条目之间匹配 uniqueID 来解决问题。碰巧，这反映了关系数据库的表现形式，并且正如我先前所述，我可以充分利用 Microsoft® .NET Framework 基础类库的 System::Data 命名空间内的数据库支持。

DataSet 表示一个或多个 DataTable 实例的集合。在此情况下，区隔集表示主表。每个环境实体（如芒刺或 doorto）都存储为一个附加的 DataTable。对于区隔内的每个属性，我会检索其 uniqueID 并交叉引用到适当的相关表。

将 XML 表现形式转换为 DataSet 实际上轻而易举。XML 文档和关系数据库之间存在一种特殊的关系，在某种意义上与水和冰之间的关系类似。您可以将它们视为同一数据的不同替代形式，各自适用于不同的活动。

DataSet 具有一个 ReadXml 方法，接受 XML 文档路径名并填充 DataSet 对象。例如，

DataSet^ ds = gcnew DataSet;
ds->ReadXml( eek_world_xml_path_string );

虽然现在 DataSet 对象将 XML 文档保留为一组 DataTable，但它尚未认识到各表间的关系。它需要进行清楚说明。通过将 DataRelation 属性添加到 DataSet 可实现这一点。DataRelation 定义了基于共享列的两个表间的关系，该列代表元素的 unique ID 或键。

DataSet 类的 Tables 属性会返回一个 DataTableCollection 对象，它包含与 DataSet 相关的 DataTable 对象集。要访问特定的实例，您可以将它按位置进行索引，

enum class tableIndex {
    tile, doorto, burr, e-shock, ...
};

DataTable^ world_tile = ds->Tables[tile];

或使用您要访问的表的字符串表现形式按关联对其进行索引：

DataTable^ world_tile = ds->Tables[tile];
world_tile = ds->Tables[ worldTile_table_string ];

与 DataTable 关联的属性有很多，例如 TableName、Rows、Columns 和 PrimaryKey：

// returns the name of the table ...
String^ tabName = world_tiles->TableName;
DataRowCollection^    drows = world_tiles->Rows;
DataColumnCollection^ ccols = world_tiles->Columns;
int countRows = drows->Count;
int countCols = ccols->Count;
array< DataColumn^ >^ keys = world_tile->PrimaryKey;

主键是确保每个记录项包含唯一值的列，同时还充当表内记录的标识（主键也可以是一组列。这就是 PrimaryKey 属性返回一组 DataColumn 对象而非一个对象的原因）。

例如，在区隔表中，doorto 属性具有 doorto 的 uniqueID。在 doorto 表中，用于组织 doorto 项的主键便是该键。

我通过定义 DataRelation 类对象来标识此关系，该对象链接了具有键 ID 值的两个表的列：

DataRelation^ doorto = gcnew DataRelation("UniqueID",
    ds->Tables["worldTile"]->Columns[ doortoID_string ],
    ds->Tables["doorto"]->Columns[ doortoID_key ]);

第一列称为父项。它的值用来检索第二列中的匹配项，第二列便称为子项。我们将该关系添加到 DataSet：

ds->Relations->Add(doorto);

这样，我没有显式搜索 doorto 表以获得显式 ID，而是使用 DataRelation 对象来执行检索。

总结

.NET 的一个最有效的方面便是它丰富的工具集。如果由于环境增长导致受到空间限制，那么使用 firehose XML 支持便能获得最佳效果。另外，DOM 会提供数据最丰富的 XML 表现形式。在此情况下，我将它读入 DataSet 以充分利用文档布局的关系方面。这些解决方案在所有 .NET 兼容的语言中均有效。正如我重复提到的，它不再和语言相关；而是和库相关。在下一专栏中，我将开始实现这只老鼠。

请将您想要向 Stanley 询问的问题和提出的意见发送至 purecpp@microsoft.com.

Stanley B. Lippman 因二十世纪八十年代和九十年代在贝尔实验室与 C++ 的发明者 Bjarne Stroustrup 共同研究 C++ 而闻名业界，而他所撰写的入门课本“C++ Primer”（C++ 入门指南）(Addison-Wesley, 2005) 也为人所熟知，该书现已是第四版。Stan 还曾担任架构师，与 Microsoft 的 Visual C++ 团队一起开发 C++/CLI，这便是本专栏要重点讨论的内容。


.NET 相关问题: 死锁监控

.NET 相关问题
死锁监控
Stephen Toub

代码下载位置: NETMatters2007_10.exe (156KB)

问：我在应用程序中使用锁来同步一组线程上的工作。不幸的是，由于我的不正确操作，我的线程有时似乎会停止工作。我想我是遇上了死锁，但我不清楚如何找到它们。有没有办法通过编程来找到它们？我希望遇到死锁时能引发异常。

首先，重要的是要理解什么是线程中的死锁，以及导致死锁的条件。线程出现死锁的条件是互相等待释放某些资源，但是随着执行该阻塞等待，他们不会释放其他线程需要用来解除阻塞的资源。在资源得到释放之前，线程不会有任何进度，但是因为它们没有进度，资源就永远不会被释放，于是线程就锁住了，形成了“死锁”。许多操作系统课本都会引用死锁产生的四个必备条件：

  • 数量有限的特定资源。如果是 C# 中的监视器（在使用 lock 关键字时使用），此限定数量为一，因为监视器是互斥锁（意味着一次只有一个线程能占有监视器）。
  • 占用一个资源再请求另一个资源的能力。在 C# 中，这类似于锁定一个对象，然后在释放第一个锁前再锁定另一个，例如：

lock(a)
{
    lock(b)
    {
        ...
    }
}

  • 没有抢占的能力。在 C# 中，这表示一个线程不能迫使其他线程释放锁。
  • 循环等待的条件。这表示在线程中存在一个循环，每个线程都在等待下一个线程释放资源才能继续。

如果不符合这些条件的任意一条，死锁就不可能发生。第一个条件是监视器固有的，因此只要您使用监视器，这一条件是不可更改的。只要确保一次只锁定一个对象，第二个条件就可以避免，但是在大型软件项目中此要求经常是不可行的。第三个条件在 Microsoft® .NET Framework 中有可能避免，只要中止或打断占用您的线程所需资源的线程即可，但是 a) 这需要了解是哪个线程占有了资源，b) 这是一个有固有危险性的操作（要了解存在的众多原因，请参阅 msdn.microsoft.com/msdnmag/issues/05/10/Reliability）。因此，避免死锁的方法是避免（或阻止）第四个条件。

在 2006 年 4 月期的文章（请访问 msdn.microsoft.com/msdnmag/issues/06/04/Deadlocks）中，Joe Duffy 讨论了一些避免和检测死锁的技术，其中一个称为锁级别。在锁级别中，锁被赋予数值，线程只能获得其数值大于已获得锁的数值的那些锁。这防止了出现循环的可能。在目前典型的软件应用程序中，这通常也很难做得很好，一旦在每个锁的获得上没有遵循锁级别，即会引发死锁。

许多系统不是大规模地预防死锁，而是试图检测死锁，并且一旦发现立即清除。例如，SQL Server® 能在产生死锁时检测到它们，并通过中止循环中的任务之一将其删除。在文章中，Joe 构建了公共语言运行库 (CLR) 主机，它能在使用监视器的 .NET 应用程序中实现这种形式的死锁检测，这是一个非常棒的功能。遗憾的是，使用自定义 CLR 主机对于许多 .NET 应用程序来说并不总是实际的，包括那些已有自定义主机的应用程序，比如 ASP.NET 应用程序。因此，如能在无需自定义 CLR 主机的前提下利用相似的死锁检测功能，这将会非常有益，这样就能在运行中检测出这些类型的死锁。这对于在开发和调试阶段识别误用锁的代码错误非常有益。同时，它也可以在生产中用于随时检测和消除产生的死锁（阻止线程尝试进入产生循环所需的关键等待，从而防止其引起死锁），但是典型的死锁检测算法开销非常大，可能会由于性能原因而不适合在生产系统中使用。（我会在本专栏的结尾处就性能做一些评论。）

为了满足此需求，我为 .NET System.Threading.Monitor 类构建了一个示例包装。如同 Monitor，我的 DdMonitor 类提供 Enter 和 Exit 方法，并在后台委托给 Monitor 上的等效方法。不过，它还跟踪监视器的使用情况，当试图获得锁将结束循环（导致死锁）时引发异常。在专栏的其余部分，我将更详细地介绍此死锁监视器类的实现，并就其用法及其部署的优点和缺点提供一些附加信息。

DdMonitor 类的概要如图 1 所示。请注意，从一开始 DdMonitor 并没有完全模拟 System.Threading.Monitor 的公共接口。它提供了相同的公共静态 TryEnter、Enter 和 Exit 方法，但是它没有提供其对应体的 Wait、Pulse 和 PulseAll 静态公共方法。

Figure 1 DdMonitor 类 (Close Figure 1)
class DdMonitor
{
    public static IDisposable Lock(object monitor) { ... }

    public static void Enter(object monitor) { ... }

    public static bool TryEnter(object monitor) { ... }
    public static bool TryEnter(
        object monitor, TimeSpan timeout) { ... }
    public static bool TryEnter(
        object monitor, int millisecondsTimeout) { ... }

    public static void Exit(object monitor) { ... }
}

DdMonitor 还提供了一个公共静态 Lock 方法。因为 C# lock 关键字对 Monitor 提供了一个很好的抽象，所以为 DdMonitor 提供一个相似的抽象也会有用。我们没有能力修改 C# 语言，因此就无法得到完全相同的语法，但是我们能做到非常接近：

// with Monitor
lock(obj) { ... }

// with DdMonitor
using(DdMonitor.Lock(obj)) { ... }

此语法功能是通过实现 Lock 来完成的，如图 2 所示。Lock 方法委托 DdMonitor.Enter 获得锁。但是，它还实例化一个实现 IDisposable 的 DdMonitorCookie 对象。此对象的 Dispose 方法将调用 DdMonitor.Exit 来释放锁，这使得开发人员能够如之前所示的那样，将 DdMonitor.Lock 封装到一个 C# 的 using 语句中（Visual Basic® 中的 Using 或 C++/CLI 中的堆栈分配）。

Figure 2 实现 Lock 方法 (Close Figure 2)
public static IDisposable Lock(object monitor)
{
    if (monitor == null) throw new ArgumentNullException("monitor");
    IDisposable cookie = new DdMonitorCookie(monitor);
    Enter(monitor);
    return cookie;
}

private class DdMonitorCookie : IDisposable
{
    private object _monitor;

    public DdMonitorCookie(object obj) { _monitor = obj; }

    public void Dispose()
    {
        if (_monitor != null)
        {
            DdMonitor.Exit(_monitor);
            _monitor = null;
        }
    }
}

Enter 方法和三个 TryEnter 方法中的两个仅仅是第三个 TryEnter 方法的包装，如图 3 所示。这些实现的目的是为了遵循 Monitor 的 Enter 和 TryEnter 方法所实现的相同规范。调用 Enter 会阻塞，直到获得锁（我们在设计上略有不同，即如果检测到死锁，我们的 Enter 方法会引发异常而不是永远阻塞），从而委托给 TryEnter 并带有一个 Timeout.Infinite 超时。请注意，Timeout.Infinite 等于 -1，这是一个特殊值，目的是通知 TryEnter 在获得锁之前阻塞，而不是在特定时间后失败。相比较而言，不接受时间值的 TryEnter 重载默认情况下使用 0 作为超时，这意味着如果不能立即获得锁，它会返回 false（同样，如果获得锁会导致死锁，我们的实现也将引发异常；但是这个与 TryEnter 有关的决定具有争议性，因此您可以选择相应地修改该实现）。请注意，如果已获得锁，TryEnter 会返回 true，反之返回 false。TryEnter 的第二个重载仅仅将所提供的 TimeSpan 超时值转换为一个毫秒值、验证参数并委托给最终的 TryEnter 重载，所有真正的工作都在此发生。

Figure 3 实现 Enter (Close Figure 3)
public static void Enter(object monitor)
{
    TryEnter(monitor, Timeout.Infinite);
}

public static bool TryEnter(object monitor)
{
    return TryEnter(monitor, 0);
}

public static bool TryEnter(object monitor, TimeSpan timeout)
{
    long totalMilliseconds = (long)timeout.TotalMilliseconds;
    if (totalMilliseconds < -1 ||
        totalMilliseconds > Int32.MaxValue)
            throw new ArgumentOutOfRangeException("timeout");
    return TryEnter(monitor, (int)totalMilliseconds);
}

我们的设计相对简单，尽管它确实包含一些有趣的实现细节。执行实现前先验证所提供的参数，确保真正提供了要锁定的对象并提供了有效的超时（意味着 -1 或者非零的毫秒数）。

此时，DdMonitor 需要访问一些共享状态。因为没有方法可以支持 DdMonitor 向 CLR 询问关于监视器的信息（包括哪些线程可能占有监视器以及哪些可能在等待），DdMonitor 必须手动跟踪这些信息。它达成此目的的方法是存储一个静态数据结构表，其中包含与系统中已获得监视器相关的所有信息，每次在监视器上执行 Enter 或 Exit 操作时，它便更新此共享状态。

请注意，这里有几个含义。首先，虽然我们看到 DdMonitor 最终确实将用户提供的同一监视器对象委托给 Monitor，但是 DdMonitor 对直接调用 Monitor 的方法一无所知，因此它无法根据任何此类已进行的调用更新其内部状态信息。这意味着如果在应用程序中混合使用 DdMonitor 和 Monitor（或者 lock 关键字），死锁检测将无法正常工作，从而导致“假阴性”并且可能无法预防一些死锁。

此实现的另一个含义是，此静态表是特定于 AppDomain 的。这应该不是问题，因为您锁定的对象应只存在于一个 AppDomain 中。但是，有一些特殊类型的对象（例如 Type 和 String）能够穿越 AppDomain 的边界，如果用来自多个域的 DdMonitor 锁定这些“领域敏捷”对象中的一个，DdMonitor 在各个域中维护的静态表将只会包含相关信息的子集，同样可能会导致“假阴性”。

这个方法的第三个问题与可靠性相关。C# 中的 lock 语句扩展到对 Monitor.Enter 的调用，后面紧随 try 块，该块的主体与 lock 语句的主体相同，其 finally 块则释放监视器。通过一点 JIT hackery，CLR 可确保在调用 Monitor.Enter 成功的情况下进入 try 块；这确保了在 Monitor.Enter 成功的情况进入 finally 块，否则在调用 Monitor.Enter 和进入 try 块之间就可能产生异步异常，就此而言这相当重要（有关更多信息，请参阅 msdn.microsoft.com/msdnmag/issues/05/10/Reliability 和 www.bluebytesoftware.com/blog/2007/01/30/MonitorEnterThreadAbortsAndOrphanedLocks.aspx）。但是，正如您即将在我们的 DdMonitor 实现中要看到的，在对 Monitor.Enter 的实际底层调用之后以及调用 DdMonitor.Enter 之后的 try 块之前，有代码存在；因此我们的实现中将失去这些可靠性保证。

您应该牢记所有这些问题，以防止对您的方案形成任何危险。

回到我们的 TryEnter 实现，如图 4 所示，它下一步是为此共享状态获取其内部锁。一旦获得，它将访问词典来查找所进入的监视器上之前的任何数据。如果不存在这样的数据，就表示目前没有线程占有或等待监视器。在这种情况下，TryEnter 会初始化一个新的 MonitorState 对象来跟踪此监视器，并将其放回表中。

Figure 4 实现 TryEnter (Close Figure 4)
private static Dictionary<object, MonitorState> _monitorStates =
    new Dictionary<object, MonitorState>();

public static bool TryEnter(object monitor, int millisecondsTimeout)
{
    if (monitor == null) throw new ArgumentNullException("monitor");
    if (millisecondsTimeout < 0 &&
        millisecondsTimeout != Timeout.Infinite)
            throw new ArgumentOutOfRangeException("millisecondsTimeout");

    bool thisThreadOwnsMonitor = false;
    MonitorState ms = null;
    try
    {
        lock (_globalLock)
        {
            if (!_monitorStates.TryGetValue(monitor, out ms))
            {
                _monitorStates[monitor] = ms = new MonitorState(monitor);
            }

            if (ms.OwningThread != Thread.CurrentThread)
            {
                ms.WaitingThreads.Add(Thread.CurrentThread);
                ThrowIfDeadlockDetected(ms);
            }
        }

        thisThreadOwnsMonitor = Monitor.TryEnter(
            monitor, millisecondsTimeout);
    }
    finally
    {
        lock (_globalLock)
        {
            if (ms != null)
            {
                ms.WaitingThreads.Remove(Thread.CurrentThread);

                if (thisThreadOwnsMonitor)
                {
                    if (ms.OwningThread != Thread.CurrentThread)
                        ms.OwningThread = Thread.CurrentThread;
                    else ms.ReentranceCount++;
                }
            }
        }
    }

    return thisThreadOwnsMonitor;
}

private class MonitorState
{
    public MonitorState(object monitor) { MonitorObject = monitor; }
    public object MonitorObject;
    public Thread OwningThread;
    public int ReentranceCount;
    public List<Thread> WaitingThreads = new List<Thread>();
}

MonitorState 存储了一些关于监视器的信息。首先，它存储监视器本身；系统使用它仅仅是为了在检测到死锁时提供诊断信息（因为了解哪些监视器陷入死锁循环对程序调试很有帮助）。MonitorState 还为目前占有监视器的任意线程存储 Thread 对象（如果目前没有获得，此值为空）、表明已进入监视器次数的 ReentranceCount（因为占有监视器的线程能安全地重复进入监视器），以及表明目前正在尝试获得此监视器的等待线程的列表。

现在有了此监视器的 MonitorState 跟踪对象之后，TryEnter 将当前的 Thread 添加到 MonitorState 的 WaitingThreads 列表中，从而注册该线程等待对象的意图。一旦此意图得到注册，TryEnter 就会调用私有 ThrowIfDeadlockDetected 方法，该方法的名称就是其能够实现的功能（我们稍后将会看一下此方法的实现）。如果 ThrowIfDeadlockDetected 检测到获得此锁将导致死锁循环，它会引发异常来防止获得该锁。同时请注意，在执行此操作时，包装此调用的 finally 块将会从等待线程列表中删除当前线程。

如果没有检测到死锁，此线程试图获得监视器将是安全的，TryEnter 仅仅委托 Moni- tor.TryEnter 进入实际的监视器。不论是否获得锁，随后都需要共享状态的锁。请注意，在这里我有自己的锁级别方案来防止 DdMonitor 引起死锁（从本来目的就是为了防止死锁的工具而言，这真是非常糟糕的事） 当在用户提供的监视器上占用锁时，获得 globalLock 是可以的，但是反之则不行。这就是我可以在 finally 块（其中我可能已经占有了用户提供的监视器）中得到 _globalLock 上的锁的原因，但是当占用 _globalLock 时就不能试图获得用户提供的监视器上的锁，这就是我在调用 Monitor.TryEnter 前先退出 _globalLock 的原因。

此时，不论用户提供的监视器是否被成功获得，我都从监视器的等待列表中删除当前线程（它要么占有监视器要么等待失败，因此不论何种情况，都不需要继续等待）。接下来，如果监视器被获得，MonitorState 中的相关字段会得到更新。

如图 5 所示，退出锁更加简单。除了参数验证之外，整个主体都封装到 _globalLock 监视器上的一个锁中。用户提供的监视器上的 MonitorState 将被检索（如果它不存在，说明此监视器没有锁定，并引发异常），而且会检查它以确定哪个线程占有它。如果无人占有它，或占有者不是当前线程，说明调用 Exit 出现错误，并会引发异常。否则，如果当前线程已经多次进入此监视器，ReentranceCount 即会减少，如果没有进入，OwningThread 将被设为空，以表示在监视器上释放此线程的锁。此外，如果没有线程在等待此监视器，MonitorState 会从表中删除，这样就可对它进行垃圾收集处理（如果我们不这样做的话，表会随着时间的推移无限增大）。最后，方法委托 Monitor.Exit 释放实际的监视器。

Figure 5 实现 Exit (Close Figure 5)
public static void Exit(object monitor)
{
    if (monitor == null) throw new ArgumentNullException("monitor");

    lock (_globalLock)
    {
        MonitorState ms;
        if (!_monitorStates.TryGetValue(monitor, out ms))
            throw new SynchronizationLockException();

        if (ms.OwningThread != Thread.CurrentThread)
        {
            throw new SynchronizationLockException();
        }
        else if (ms.ReentranceCount > 0)
        {
            ms.ReentranceCount--;
        }
        else
        {
            ms.OwningThread = null;
            if (ms.WaitingThreads.Count == 0)
                monitorStates.Remove(monitor);
        }

        Monitor.Exit(monitor);
    }
}

接下来的内容很有意思。除了实际的死锁检测算法之外，我们已经看到了完整的实现，其核心已在 ThrowIfDeadlockDetected 中实现（请参见图 6），之前我们已经在从 DdMonitor.Enter 的调用中看到。

Figure 6 检测等待链中的循环 (Close Figure 6)
private static void ThrowIfDeadlockDetected(MonitorState targetMs)
{
    if (targetMs.OwningThread == null) return;

    Dictionary<Thread, List<MonitorState>> locksHeldByThreads;
    Dictionary<MonitorState, List<Thread>> threadsWaitingOnLocks;
    CreateThreadAndLockTables(
        out locksHeldByThreads, out threadsWaitingOnLocks);

    Queue<CycleComponentNode> threadsToFollow =
        new Queue<CycleComponentNode>(locksHeldByThreads.Count);

    threadsToFollow.Enqueue(new CycleComponentNode(
        Thread.CurrentThread, targetMs, null));

    while (threadsToFollow.Count > 0)
    {
        CycleComponentNode currentChain = threadsToFollow.Dequeue();
        Thread currentThread = currentChain.Thread;

        List<MonitorState> locksHeldByThread;
        if (!locksHeldByThreads.TryGetValue(
            currentThread, out locksHeldByThread)) continue;

        foreach (MonitorState ms in locksHeldByThread)
        {
            List<Thread> nextThreads;
            if (!threadsWaitingOnLocks.TryGetValue(
                ms, out nextThreads)) continue;

            foreach (Thread nextThread in nextThreads)
            {
                if (currentChain.ContainsThread(nextThread))
                    throw new SynchronizationLockException(
                        CreateDeadlockDescription(
                            currentChain, locksHeldByThreads));

                threadsToFollow.Enqueue(new CycleComponentNode(
                    nextThread, ms, currentChain));
            }
        }
    }
}

ThrowIfDeadlockDetected 方法接受 MonitorState 实例作为参数，该实例代表了将要等待的监视器。要出现死锁，此监视器（当前线程即将等待它，我们已经注册了该意图）需要由直接或间接占用它的线程占用，而该线程则正在等待当前线程占用的某个监视器。我说“直接或间接占用”的意思是，要么它已获得监视器上的锁，要么它正在等待由直接或间接占用此监视器的线程占用的某个监视器（对不起，我喜欢饶舌）。换句话说，我们在寻找一个循环，从此监视器开始，到占用它的线程，到该线程正在等待的监视器，到占有该监视器的线程，等等，直到我们回到最初的监视器和占有它的线程。如果我们能找到这样的循环，就引发代表死锁的异常。

实现遵循该方法。首先，我们检查以确保目标 MonitorState 真正由线程占有，如果不是，等待它就没有问题。接着，我们根据当前拥有的所有信息（有关锁占有的对象以及在监视器上等待的对象）生成查找表（所有这些信息都在 _monitorState 表中，所以我们只需将该信息重构为两个 Dictionary 数据结构，即可使其余的算法变得更容易实现）。

此时，我们需要开始检查线程。我们从当前的线程开始，我们知道它正在等待所提供的 MonitorState。为了捕获这一配对，我创建了一个简单的帮助器容器 CycleComponentNode，除了存储此数据之外，还同时维护对在识别出的循环中下一个节点的引用。开始算法时，我们唯一了解的节点是初始线程和 MonitorState，因此 Next 值为空。

下一个将要检查的线程（或者更确切地说是存储该线程的 CycleComponentNode）会从队列中检索到，直到没有线程可检查为止。使用之前创建的表，我们能检索到此该线程占用的所有锁，如果它没有占用任何锁，它就无法成为死锁的一部分（请记住死锁的必备条件，特别是那条关于占用一个资源并请求另一个的条件）。这些锁中的每一个都得到检查，再次使用之前创建的表，我们可以查找哪些线程正在等待这些锁。如果任何这些线程存在于我们构建的当前等待链中（由 CycleComponentNode 以列表的形式表示），我们就找到了死锁！ CreateDeadlockDescription 方法枚举了链中的所有 CycleComponentNode 实例并生成一个描述，如下所示（接着就引发异常）：

Thread 24 waiting on lockP (39C82F0)
    while holding lockH (36BEF47), lockK (3B3D5E3)
Thread 30 waiting on lockH (36BEF47)
    while holding lockB (2CF7029), lockN (349C40A
Thread 29 waiting on lockB (2CF7029)
    while holding lockP (39C82F0)

如果线程不存在于当前等待链中，我们仍需检查它。因此，我们围绕它及其正在等待的 MonitorState 创建一个 CycleComponentNode，它的 Next 值将设置为当前等待链。按此方式，当最终检查此线程和所有在其占用的监视器上等待的线程时，我们可以正确地搜索那些线程的等待链。

这就是解决方案。如果它不能满足您的所有需求，您当然可以根据需要修改它。例如，当阻塞时，您可以将当前线程的堆栈跟踪存储到 MonitorState 中，接着，当发现死锁时，将所有相关线程的堆栈跟踪都包含在引发异常的消息中。或者您可以扩展该解决方案来支持更多的同步原语，例如 System.Threading.Mutex。

同时需要注意，如果您编写的是本机代码并且锁定在 Windows 同步原语上，那么就可以利用新的 Wait Chain Traversal API 来执行类似并更强大的分析。有关这方面的更多信息，请参阅 John Robbins 在 2007 年 7 月期的《MSDN® 杂志》上的 Bugslayer 专栏，地址为 msdn.microsoft.com/msdnmag/issues/07/07/Bugslayer。

最后，还有关于此实现的性能方面的一些看法。我在这里实现的死锁检测代码开销大、清楚且简单。正因为如此，您可以在调试版本而非零售版本中使用它。要实现这个目的有一些方法，但是最简单的就是用一个静态属性扩展 DdMonitor 的实现。

private static bool _useDeadlockDetection = false;

public static bool UseDeadlockDetection
{
    get { return _useDeadlockDetection; }
    set { _useDeadlockDetection = value; }
}

这样的话，您可以扩展 Enter、TryEnter 和 Exit 的实现以检查此属性，并用它来确定是要实现完备的死锁检测算法，还是简单地将其委托给 Monitor 上的相应方法：

public static void TryEnter(object monitor, int millisecondsTimeout)
{
    if (UseDeadlockDetection)
    {
         ... // existing logic here
    }
    else TryEnter(monitor, millisecondsTimeout);
}

要在代码中设置 UseDeadlockDetection，可从配置文件中读取一个值，或者如果您想要根据生成配置设置该值，可使用如下代码：

public static void Main(string [] args)
{
    DdMonitor.UseDeadlockDetection =
#if DEBUG
        true;
#else
        false;
#endif

    ... // the rest of your app here
}

将 UseDeadlockDetection 设置为 false 并在整个基本代码中使用 DdMonitor 不会提供与仅使用 Monitor 等同的行为和性能，但是应该非常相近。除了在程序开头，确保您不会再更改 UseDeadlockDetection 的值；如果您确实在其他地方更改它了，那么您最终将会是使用死锁检测来获得一些监视器而非其他目的，这其中的危险我们已经讨论过。Joe Duffy 建议了一个替代办法，就是更改 TryEnter 的实现，首先避免死锁检测代码，然后通过一些可配置的超时委托给 Monitor.TryEnter，超时可以是 500ms（或用户提供的更小的超时）。如果该调用成功，就不需要执行死锁检测，因为监视器已经成功被获得。如果该调用超时，且选择的超时小于用户提供的超时，那么就可以运行完备的死锁检测算法。通过这个办法，已部署程序中的死锁仍然可以发现，但是在第一个 Monitor 获取超时之前，您不用承担检测算法的成本。而且如果您选择了一个合理的超时，那么它可能始终都会因为死锁而超时。

请将您想要向 Stephen 询问的问题和提出的意见发送至 netqa@microsoft.com.

Stephen Toub 是 Microsoft 并行计算平台团队的一位高级项目经理。他还是《MSDN 杂志》的特约编辑。


{ End Bracket }: 婚礼事务

{ End Bracket }
婚礼事务
Michael Richter

代码下载位置: WeddingVEAddIn2007_10.exe (338KB)

作为 《MSDN® 杂志》和《TechNet 杂志》的网站经理，我在工作中经常需要决定是做一些可能会花上几天时间、令人头脑麻木的体力劳动，还是编写一个应用程序来完成一切。当然，编写该应用程序本身可能也需要几天时间，因此自动化带来的时间/成本效益并不总能实现。但构建应用程序仍然让我很开心，并且贯穿我的工作，而重复的手动任务会让我厌恶我的工作。

我将于十月份结婚，并且我决定借助自己的开发技能来实现部分婚礼筹划工作（这同样令人头脑发麻，不过千万别跟我未婚妻说这是我说的）的自动化。幸好，她包揽了大部分的筹划事宜，但还是叫我做两件事？创建婚礼网站和管理宾客名单。

(单击该图像获得较小视图)

(单击该图像获得较大视图)

我没有采用键入方向和动态地图链接的方法，而是使用 Virtual Earth™ API 创建了一个动态页面（请参见 msdn.microsoft.com/msdnmag/issues/06/09/EarthlyDelights）。我用一个 XML 文件中的地址填充了两个下拉列表，并将其中一个用作路线起点，另一个用作路线终点。每个控件都有一个 onchange 事件，可检测两个路线点是否均已设置，并将地址发送到 VEMap 对象的 GetRoute 方法。这会更新地图并显示两点之间的方向。

当我在两个地址之间绘制路线时这很管用，但我还希望能够绘制特定的经度和纬度坐标之间，以及坐标和地址之间的路线。现在，自动化地图和方向的简单页面立刻变复杂了。

我尝试将坐标作为字符串发送到 GetRoute，因为那是地址的发送方式，但发现必须将该坐标作为 VELatLong 对象发送。我最终在列表上的坐标值中放置了一个标志，以提示 JavaScript 为路线点创建新的 VELatLong：

<option
   value="821 Stewart Avenue, Garden City, NY">Wingate Hotel</option>
<option value="POSITION,40.729877,-74.038788">Holland Tunnel</option>
<option value="POSITION,40.60294,-74.066305">Verazzano Bridge</option>

我的 JavaScript 代码能够通过 POSITION 标志来检测路线点是否为一组坐标，并将 VELatLong 对象（而非字符串）发送到 GetRoute。

我还想在一个地图上看到所有宾客的地址。能够查看哪些宾客是远道而来，因此需要宾馆房间，这是非常有用的。能够了解哪些宾客可能可以合用一辆车，同样也是很不错的。当然，如果能在我们的网站上显示地图，那就真是太棒了。

我决定使用 Virtual Earth 图钉，但我没有任何地址的坐标。在第 5 版的 Virtual Earth 控件中，VEMap 对象具有一个 Find 方法，只要提供地址，它就会将坐标返回到一个回调函数（回调的名称会作为参数传入该 Find 方法）。需要查找一个地址的话，这是个不错的方法，但如果您需要的是查找 150 个地址呢？调用 Find 方法 150 次时，每个回调方法的调用都会覆盖先前的调用，我意识到这一问题时已经太晚了。所以我们不能使用 Find 方法来获取同一地图上不同地址的坐标，至少不能快速连续获取。

因此我采用了地理编码 Web 服务。Microsoft 的 MapPoint® Web 服务提供了众多功能，其中包括正好我需要的那个功能 (msdn2.microsoft.com/bb545004)。

我没有在网页上构建此地图，而是选择了在 Microsoft® Excel 内进行构建。受 Trupin 的 Geopegging 文章的启发（请参见 msdn.microsoft.com/msdnmag/issues/07/04/EndBracket)），我采用了 Visual Studio® Tools for Office 2nd Edition，在 Excel 2007 自定义窗格内承载 Web 浏览器控件。当我选择一组单元格时，加载项会检测我的地址是否存在，如果存在，即会在 Web 浏览器控件中显示地址的地图。这样，我便可以将不同的地址添加到 Excel 中，或者突出显示几个地址并立即在地图上查看。您可以从《MSDN 杂志》网站上下载最终解决方案。

我认为大多数开发人员都会同意这一点：即让一项工作自动执行比手动执行更有意思，即便手动执行可能会更快。只需在自定义应用程序中按一个按钮便能完成所有工作，这让人更有满足感。在我的婚礼中，我揽下了一些可能比较乏味的工作，但我从中获得了乐趣。这让我很开心，同时真正重要的一点是，这让我的未婚妻也很开心。

Michael Richter 是《MSDN 杂志》和《TechNet 杂志》的网站经理。现居纽约。