利用并发操作实现可伸缩性

利用并发操作实现可伸缩性

 

下载本文中所用的代码:CLRInsideOut2006_09.exe (151KB)

 

近来在并发操作问题上引发了许多讨论。主要原因是多数硬件供应商计划向客户端和服务器端的计算机上添加更多的处理器内核,还因为针对此类硬件,目前的软件尚未做好相关准备。许多文章关注的是如何在代码中保证并发操作的安全性,但并没有首先讨论如何将并发操作融入代码中。

这两项任务都很重要,但也可能由于各种原因而难以实现。随意创建新线程并在整个代码库中随意调用 ThreadPool.QueueUserWorkItem 并不会产生好的结果。您需要采取更具结构性的方法。首先,让我们简单估量一下情况。

在上世纪 90 年代,并行性已逐渐成为新一代处理器体系结构中软件可伸缩性的一个幕后支持。尽管我们多数人甚至不必意识到它的存在,或甚至以不同方式编写代码来利用它,但并行性仍然在发挥着它的用处。指令级并行性 (ILP) 技术是现有序列编程模型的基础,以单个指令流的粒度执行,并采用分支预测、推测和数据流乱序执行。例如,采用管道技术可使性能提高 25% 30%,具体视管道深度及工作负荷而定。如果将此类技术与提高的时钟速度相结合,可确保软件会随着每一代硬件持续提高运行速度,而同时将软件的额外工作量降至最低。

尽管芯片供应商仍期望摩尔定律继续有效(每 18 个月左右就使处理器中的晶体管数量翻倍),但这些晶体管对工程师的用处已开始发生转变。以过去的速率提高新芯片的时钟速度是完全不可能的,主要是因为发热量过高。但是,可以利用增加的晶体管在芯片上放置更多的低滞后时间内存和更多内核(如果软件支持多核)。请注意限定条件。当今多数软件都基于单线程设计开发,如果要利用这些额外内核,就需要改变这一点。

从某种意义上讲,要使软件在与下一代硬件结合时提高运行速度,很大一部分责任已从硬件方面转移到软件方面。这意味着在中长期内,如果要使代码自动提高运行速度,就必须开始考虑以不同方式构架和实现任务。本文将全面探讨这些体系结构问题,旨在引导您深入了解这个新领域。从长远看,很有希望出现新的编程模型,以解决您将遇到的许多难题。

硬件线程概述

能够运行 Windows® 的对称式多处理器 (SMP) 计算机已投放市场多年,尽管通常只有服务器端和高端工作站才使用此类计算机。这种计算机包含一个或多个主板,每个主板通常有多个插槽,每个插槽都插入一个完整的 CUP。在这方面,每个 CUP 都有其自己的 on-die 高速缓存、中断控制器、易失状态(寄存器)和一个带有其自己的执行单元的处理器内核。Windows 调度程序将各个软件线程映射到各个 CPU,在这种情况下,各 CPU 完全独立。从硬件线程的意义来说,我将这些 CPU 称为单线程 CPU。因为各单元相对隔离(共享内存体系结构除外,我马上就会就此进行讨论),如果提供了足够的软件线程以供执行,则可以提高添加到计算机的每个新 CPU 的执行吞吐量。

Intel 针对 Pentium 4 处理器系列采用了超线程 (HT) 技术。HT 将额外一组中断控制器和易失状态打包到某单个插槽中的单个物理 CPU 上,使多个软件线程可在各自的逻辑处理器上并行执行,尽管它们共享同一组执行单元。此方法类似于超型计算机公司(如 Tera)早期采用的方法。由于与访问内存相关的滞后时间以及其他因素,两个逻辑 CPU 线程之间的指令可频繁交错,从而导致并行加速。从这种意义上讲,启用 HT CPU 是双线程的,因为 Windows 调度程序可将两个可运行线程同时映射到一个 HT 处理器上。事实上,HT 适合用于某些工作负荷,并因可使实际环境中的程序性能提高 15% 40% 而受到赞誉。

多核技术已随时可供客户端和服务器端计算机以同样方式使用,它在单个芯片上复制每个 CPU 的体系结构,从而使单个插槽可包含多个完整 CPU。双核芯片(一个芯片上两个内核)目前也成为现实,而且 4 核、8 核等更高级芯片也离我们不远了。不同于 HT,双核 CPU 具有单独的执行单元,因此通常可实现更明显的并行加速。与单个 CPU 很类似,除了共享的内存体系结构之外,每个内核在逻辑上都截然不同。这意味着两倍的内核数可以使吞吐量倍增。从这种意义上讲,内核数就是可同时运行的线程数。当然,这些技术并不互相排斥。一个 4 插槽、4 内核 HT 计算机相当于 32 个硬件线程。那是相当大的马力了。

内存体系结构

内存交互作用通常是决定现代软件性能的一个实质性因素。典型的计算机包含一个相当复杂的内存系统,由处理器与实际的 DRAM 存储体之间的多级高速缓存组成。SMP 计算机传统上使用一致的层次结构设计,但现已出现更特殊的体系结构,并会随着大规模并行计算机可用性的提高而更为普及。此类特殊结构的一个代表就是非一致存储访问结构 (NUMA)”,在该结构中多个主内存专用于多个 CPU 的节点。尽管可以实现跨节点通信,但成本极其昂贵。Windows 的各个部分及 CLR 要改变策略才能实现 NUMA。通常智能并行代码也必须改变策略。

可缓存性并发软件通过智能方式有效使用内存,利用局部性减少某一特定计算所需的周期总数。局部性主要分为两大类。第一类为空间局部性:内存中紧靠一起的数据在程序的运算中也将紧靠一起使用。虽然更大的高速缓存线意味着最后可能会将不必要的数据放入高速缓存,但具有良好空间局部性的程序会通过接着访问已放入高速缓存的其他地址来充分利用这一点。例如,CLR 垃圾收集器通过连续进行分配来最大限度利用空间局部性。

时间局部性指的是存储内容由于某种原因而保留在高速缓存中的概念:如果此存储内容最近被访问,您可预期它可能很快会被再次访问。现代高速缓存使用的是清除策略,该策略充分利用了伪 LRU(最近最少使用)技术。

编写严谨的并发软件甚至可以观察到通过在高速缓存中保留更多数据并与其他线程共享更少数据而产生的超线性加速。也就是说,在装有 n CPU 的计算机上,软件的运行速度可能比在装有单个 CPU 的计算机上运行时快 n 倍。从另一方面来讲,高速缓存缺失的代价相当昂贵。这一点可通过图 1 中对高速缓存访问费用的相对比较来进一步说明。根据所有的经验法则,可对这些数字持保留态度,而更关注一下差值阶。

1 访问成本比较对数图

并行软件特别需要注意局部性。在相交高速缓存线上不断更新数据的两个线程可引起高速缓存线的“ping-pong(乒乓)”效应,其中处理器要花费格外多的时间获取对某一高速缓存线的独占访问权限,这涉及到作废其他处理器的副本。一些高速缓存线交互作用是显而易见的,因为在应用程序级存在真正的数据共享。而另一些交互作用就不那么明显,它们由内存中紧靠一起的数据产生,很遗憾,仅仅通过检查算法很难以确定这种交互作用。

与此类似,线程迁移(以后将详细讨论)可能会使某线程移至另一个处理器上,并必须随后获得其在原始处理器上曾经拥有的所有高速缓存线并使它们失效。对于需要迁移的每一次缓存线访问,此类高速缓存迁移的成本可能为一次 on-die 高速缓存命中成本的 50 倍左右。在 NUMA 计算机上,这可能会由于节点间的通信成本而造成严重损失,尽管可通过明智地利用处理器相似性而在部分程度上避免迁移问题也是如此。在编写高度并行的代码时要对这些成本做到心中有数。例如,通过 Windows Vista™ 中新的 GetLogicalProcessorInformation API 可查询有关计算机体系结构的信息(包括高速缓存布局及 NUMA 信息),在调度期间可动态使用这些信息。

工作单元

要使您的软件并行执行,无疑您需要以某种方式将以您的算法编码的问题划分为子问题:也就是我将称为任务的更小的工作单元。任务将接受一些输入并生成一些输出,无论输出是一段数据还是一个操作。任务可以孤立执行,然而它可能对状态或排序有着微秒的依赖性,这种依赖性起初可能不是很明显。

您也许会说,函数几乎已经做到了这一点。但是不同于在您编写代码时静态定义的普通函数,要编写在给定了任意数量的 CPU 时可伸缩的软件,就必须经常动态发现任务的界限。或者,至少必须将任务呈现给智能体系结构,通过其确定并行执行任务是否有益。此外,要使任务并行执行,您的代码必须以某种方式排列以使任务被并行调用,而不只是仅仅在当前线程上按顺序调用。在 Windows 上,这通常意味着在一个单独的 OS 线程上运行。在 CLR 上,这意味着可能对要在 ThreadPool 上执行的工作进行排队。

Windows 上的物理执行单元是一个线程。每个进程都以一个单线程为开头,当然在该进程中运行的代码可随意引入其他线程,并随后在认为适当的时候终止它们。Windows 调度程序负责将线程分配到各硬件线程并允许代码执行。如果线程数多于现有的硬件线程数,则调度程序的处理方式要复杂一些;它选取具有最高优先级的可运行线程(根据智能化防资源不足算法),然后让其一直执行到某一时间片到期为止。时间片到期后,发生上下文切换,然后同一调度算法选择要执行的下一线程。时间片的长度因 OS 类型和配置而异,但通常对客户端平台为 20ms 左右,对服务器端平台则为 120ms 左右。线程可能会由于执行 I/O、尝试获取被争用的锁等原因而阻塞。在这种情况下,正如上下文切换一样,调度程序将选取一个新线程来执行。

如先前所述,将尽可能多的数据驻留在高速缓存中对传统 SMP 系统的性能是至关重要的。这种意义上的数据指的是要执行的代码、由线程算法操纵的堆积数据以及线程堆栈。当线程切换进和切换出 CPU 时,Windows 会自动利用所谓理想的处理器相似性来试图将高速缓存效率提至最高。例如,在 CPU 1 上运行的某线程在使上下文切换出之后,会优选再次在 CPU 1 上运行,目的是希望它的一些数据仍可以驻留在高速缓存中。但如果 CPU 1 已被占用而 CPU 2 却空闲,则可能会将该线程改为调度到 CPU 2 上,同时也会产生所有隐含的高速缓存负面效应。

了解成本

线程不是免费的午餐。它们会产生 CPU 及内存成本,这一点您应铭记在心。如果您的目标是利用并发操作来提高算法的可伸缩性,大概您还要花费同样多(甚至更多)的时间来进行传统的性能剖析工作。并行运行结构松散的算法不会产生任何结果,只会使其用尽更多的系统资源。要充分利用并行伸缩性,确保代码最重要的热数据部分在序列情况下尽可能高效是至关重要的。

要确定您可以承担的成本,有一些通用的经验法则可以遵循。创建一个 Windows 线程的成本约为 200,000 个周期,而终止一个 Windows 线程的成本约为 100,000 个周期。那么,此时您知道了如果要创建一个新线程以执行 100,000 个周期的工作,将要付出巨大的额外开销,而且,如果我必须猜测的话,您也不会观察到任何类型的加速。

内存额外开销因配置而异。但多数受管理线程将保留 1MB 的用户堆栈空间并将调拨全部数量的空间,这意味着必须在实际 RAM 或页面文件中物理备份内存中的数据。还需要一个小型的内核堆栈页面集,在 32 位系统上为三页,在 64 位系统上为六页。其他数据结构使用另外的 10-20KB 虚拟内存,但相对于堆栈所需的内存而言,这是微不足道的。GUI 线程的成本还要略高一些,因为它们必须建立额外的数据结构,如消息队列。

现在,如果您最终创建过多的优先级相等的线程,则必须经常进行上下文切换。进行一次上下文切换的成本是 2,000–8,000 个周期(具体视系统负荷和体系结构而定),其中还要涉及保存当前易失状态、选择要运行的下一线程以及恢复下一线程的易失状态。这听起来好像没有花费太多成本,尤其与时间片的持续时间和随后高速缓存缺失的成本相比更是如此,但它表示从应用程序代码执行过程中减去的纯额外开销。

假定您要使引入和终止新 OS 线程的成本以及意外引入过多工作而产生的负面后果降至最低,则应考虑使用 CLR 的线程池。它将智能化线程注入和引退算法隐藏在一个简单接口之下,在整个程序生命周期内分摊创建和终止线程的成本。使用 ThreadPool 类很简单。

尽管如此,使用 ThreadPool 仍要花费一些成本。调用 QueueUserWorkItem 会对调用方产生连续成本,并且从待处理工作项目队列中分派工作的基础设施也会为正在执行的并行工作带来额外开销。对于大粒度的并行性,这些成本微不足道,以至于您可能不会注意到它们。但对于粒度极其精细的并行性,这些成本可能会成为明显的可伸缩性瓶颈。您可能会考虑从无锁数据结构构建自己的轻型 ThreadPool,以避免由一般用途的 ThreadPool 导致的一些成本,如确保 AppDomains 间的公平性、捕捉和恢复安全性信息等等。但对多数使用情况来说,可在任务中使用普通的 ThreadPool

定义界限

确定如何拆分工作并不是一项无足轻重的活动。当处理受 CPU 限制的工作负荷时,该工作更多集中于避免产生与并发执行相关的性能开销。但多数工作负荷不受 CPU 限制,它们结合了各种形式的 I/O 以及 CPU 工作间的同步,其中任意一项都可能导致不可预测的阻塞模式。因此,对于多数代码,与其说并发执行与低级性能相关,还不如说它涉及的是如何巧妙安排复杂的协调模式。

也许分割工作的最简单方法是使用并发操作的服务器模型。在 SQL Server™ ASP.NET 之类的服务器中,每个外来请求都被视为一个独立任务并因此在其自己的线程上运行。宿主软件通常会限制使用的实际 OS 线程数,以免过多引入并发操作。多数此类工作负荷都由访问数据和资源的不相交集的完全独立的任务组成,从而产生高效率的并行加速。但对于客户端程序,很少有工作负荷会完全适合该模型。例如,可通过该模型完成对等通信的区段划分和响应,但除非预期有大量工作密集型的外来请求,否则此时可能达到的加速上限将受到很大限制。

另一个备选方法是使用更具逻辑性、更主观的重要任务定义在代码中划分出任意子任务,这往往更有利于客户端工作负荷。一次复杂的软件运算通常包含多个逻辑步骤,例如,这些步骤可能在程序中表示为独立的函数调用,而这些函数调用自身又包含多个步骤,以此类推。您可考虑将各函数调用表示为一个独立任务,至少对于那些足够重要的函数调用是如此。从必须考虑排序从属性的意义上来讲,这相当棘手,因为这为此想法增添了很多复杂性。多数现代命令式程序充满了无组织的循环、通过不透明指针进行的数据访问(这些不透明指针在内存中可能不紧靠一起)以及各种函数调用(其中没有任何函数能够清楚说明存在哪些从属性)。当然,还有您可能不知道的隐藏的线程相似性。因此该技术显然需要您深入了解您的代码试图解决的问题,并产生关于并行执行代码的最有效方式的一些思路,从而消除尽可能多的从属性。

一种常见的相关模式是派生/联结式并行性,其中一个主任务派生出多个子任务(这些子任务自身也可派生出子任务),接着每个主任务在一些定义明确的点与自己的子任务联结。例如,假定有一个称为派生/联结式 future 的任务级并行性模型,它作为任务单元基于函数调用来封装此模式。这可通过一些新类型 Future <T> 加以说明(可从 MSDN®Magazine 网站下载 Future <T> 的实现代码):

int a() { /* 一些工作 */ }

int b() { /* 一些工作 */ }

int c()

{

    Future<int> fa = a();

    Future<int> fb = b();

    // 做一些工作

    return fa.Value + fb.Value;

}

该代码的含义是 a b 的调用可随 c 的主体并行执行,对此的决策由 Future<int> 引擎的实现来做出。当 c 需要这些调用的结果时,它会访问 future Value 属性。这所产生的结果是:等待工作完成;或者,如果工作还未开始异步执行,则在调用线程上本地执行函数。该语法与现有 IAsyncResult 类很相似,但多出了一个优点,就是在有关将多少并发操作引入程序方面更加智能化。尽管很容易就可以设想出更多智能化的实现方法,但此代码的直接译文可能如下所示:

int a() { /* 一些工作 */ }

int b() { /* 一些工作 */ }

delegate int Del ();

int c()

{

    Del da = a; IAsyncResult fa = da.BeginInvoke(null, null);

    Del db = b; IAsyncResult fb = db.BeginInvoke(null, null);

    // 做一些工作

    return da.EndInvoke(fa) + db.EndInvoke(fb);

}

还可以采用其他方法,如使用运行时间更长的子任务,而不是要求子任务的生命周期绝不能超过父任务。这通常需要更复杂的同步和会合模式。派生/联结模式很简单,因为单个工作单元的生命周期显而易见。

以上围绕代码对并行性进行了讨论。另一项技术通常更简单:数据并行性。该项技术通常适用于数据和计算密集型的问题和数据结构,或其各运算往往要频繁访问不相交数据的问题和数据结构。

一种常见的数据并行性技术称为分区。例如,基于循环的并行性使用此方法将计算分布于一系列元素。假定您有 16 个逻辑 CPU、一个包含 100,000 个元素的数组和一段以微乎其微的从属性执行并往往阻塞 20% 的时间的工作。您可以将数组分割为各有 5,000 个元素的 20 个连续块(稍后我将说明如何计算出该数字),派生出 19 个线程(重复使用一个分区的当前线程),并安排各线程并行执行各自的计算。数据库(如 SQL Server)中的并行查询处理使用的是类似方法。此技术在图 2 中进行说明。

2 基于分区的并行性

该例显示了一个分布于四个线程上的由 100,000 个元素组成的数组。您会留意到,为进行数组分割连续支付了一定的额外开销。在需要合并时,经常要为合并结果支付附加成本,包括连接待处理线程。

For-all 循环通常是以编程语言表示基于分区的并行性的一种传统方式。图 3 中显示了 ForAll<T> API 实现的示例。也可使用类似方法将循环并行化例如,可以不采用 IList<T>,而改为采用 int from int to 参数集,然后将循环迭代数馈入 Action<int> 委托。

此代码做出了一个可能具有灾难性的重大假定:预期传入的 Action<T> 委托会安全地并行执行。这意味着如果它指的是共享状态,则需要使用适当的同步来消除并发操作程序错误。如果不是,则可以预期我们程序的正确性和可靠性都相当差。

另一个数据并行性技术是管道操作,其中多个运算并行执行,使用一个快速的共享缓冲区来相互输送数据。这类似于装配线,其中流程中的每个步骤都有机会与一些数据交互,然后将其传递给装配线中的下一步骤。此技术需要巧妙的同步代码以尽量缩短花费在明显瓶颈处的时间:在瓶颈处,管道中的相邻阶段通过一个共享缓冲区进行通信。

多少任务?

选择要创建的任务数也是一个棘手的因素。如果吞吐量是唯一的优先考虑因素,则可以使用如下所示的一些理论目标,其中 BP 是任务将阻塞的时间百分比:

NumThreads = NumCPUs / (1 – BP)

也就是说,线程数最好等于 CPU 数与任务要花费在实际工作上的时间百分比的比率。这已在先前的 ForAll 示例中进行了说明。可惜的是,尽管理论上这是一个良好起点,但它不会带给您准确的答案。例如,它没有解释采用 HT 的原因(其中高内存滞后时间允许引发并行计算),但在其他方面它不应该是使用完整处理器的原因。而且它相当天真地假定您实际可以预测 BP 值,这一点我可以保证是相当困难的,特别是对于试图调度异类工作的组件,这非常像 CLR 的线程池。如果有疑虑,最好依靠线程池将任务调度给 OS 线程,并倾向于过度表示并发操作。

任何算法都有一个自带的加速曲线。关于这条曲线,有两点特别重要的问题要考虑。首先,可从计算并行化获益的最少任务数是多少?对于小型计算,情况可能会是这样:使用少量任务会导致过多的额外开销(线程创建和高速缓存缺失),但使用大量任务会使执行进度赶上相继的版本并超过它。其次,假定硬件线程的数量无穷大,则在开始发现性能下降而不是持续上升之前可以分配给某问题的最多任务数是多少?所有问题都会达到这一递减返回点。随着继续细分问题,最终将达到单个指令的粒度。

线性加速意味着使用 p 个处理器执行问题花费的时间是使用一个处理器执行问题所花费时间的 1/pAmdahl 定律往往限制了实现这种加速的能力。它相当简单地指出最大加速受到采用并行性后保持的序列执行量的限制。更正式地说,此定律指出,如果 S 是必须保持有序的问题(无法并行化)的百分比,p 是所使用 CPU 的数量,则预期的近似加速可如下表示:

1/(S + ((1 – S)/p))

随着处理器数量的增加,此表达式接近于 1/S。因此,如果只能并行化问题的(例如)85%,则只能达到 1/.85(大约 6.6)的加速。与同步化和采用并发操作相关的任何额外开销往往都成为 S 的一个因素。但是,在现实中还是有一个好消息:在多个处理器之间分配工作也具备难以量化和测度的好处,例如,使并发线程可以保持其(各自的)高速缓存随时可用。

任何管理实际资源的算法还必须考虑跨计算机使用情况。完全进行本地决策以最大化并行性的软件(特别是在 ASP.NET 之类的服务器环境中)可能会(并且总会!)导致混乱并增加对资源(包括 CPU)的争用。例如,ForAll 式循环在动态决定最佳任务数之前可能会查询处理器使用情况。可考虑使用 4 中所示的 GetFreeProcessors 函数,而取代 3 中使用的依赖于 System.Environment.ProcessorCount 属性的算法。

该算法并非十全十美。它只是在其运行时的计算机状态的一个统计快照,而并未表明在其返回结果之后出现的任何情况。它可能过度乐观或过度悲观。并且它当然不会解释这样的事实,被查询的某一处理器就是执行 GetFreeProcessors 函数的处理器本身,这会是一项有帮助的改进。另一个要考虑的值得关注的统计度量标准是系统/处理器队列长度性能计数器,它可以告诉您在调度队列中有多少线程正在等待空闲处理器。如果结果是一个很大的数字,则表示引入的新工作只能等待队列清空(假定所有线程都具有同等优先级)。

存在一些重要理由来创建过多而不是过少的并发操作。如果正在考虑异类任务,则让每个任务在某一线程上一直执行到完成的模型会带来公平性问题。如果不释放另外的资源,则运行时间远多于任务 B 的任务 A 会导致任务 B 资源缺乏。如果 A 决定阻塞而您的算法没有考虑到这一点,则这种情况会更加糟糕。

有意过度并行化的另一个原因是针对异步 I/OWindows 为实现高可伸缩性提供了 I/O 完成端口,在这种情况下,待处理的 I/O 请求甚至不需要使用 OS 线程。I/O 开始异步执行,一旦完成,Windows 即会向下层端口发布一个完成数据包。通常情况下,有效设定大小的线程池会被绑定到端口(在 CLR 上由该线程池负责此端口),等到完成数据包一旦可用就对它们进行处理。假定完成率不足,则尽可能快地并行创建大量 I/O 请求比起让每个任务排在其他任务后面等待轮流启动异步 I/O 会实现更好的可伸缩性。这适用于文件、网络和内存映射 I/O,但应始终认识到这一事实,计算机上共享资源的数量有限,过度争用这些资源只会降低而不是增强伸缩性。

共享状态

无论何时采用并发操作,都需要考虑保护共享状态。这一点至关重要。要想了解锁定为何如此重要,建议您阅读 MSDN®Magazine 2005 8 月刊中 Vance Morrison 的文章(英文)(msdn.microsoft.com/msdnmag/issues/05/08/Concurrency)。正确性应始终优先于性能,如果您使用并发操作而不考虑锁定,则您的代码很有可能不正确。我不打算重申 Vance 已经表述得很明确的内容,而是想把重点集中于这种技术的性能上。

最常见的同步技术是锁定和低位锁操作。锁定使用 Win32® Mutex CRITICAL_SECTION 之类的原语,或 CLR MonitorReaderWriterLock,或相关的语言关键字(例如 C# 中的 lock Visual Basic® 中的 SyncLock)来实现某种程度的相互排斥。要实现这种排斥,需对 API 进行调用;一些内部算法确保了两个使用同一个锁的代码段不可进入受保护的代码区。只要每个人都遵守此协议,代码就会一直保持安全。

低位锁操作可使用联锁原语生成,后者通过对加载-比较-存储原子指令的硬件支持来实现。它们确保了内存的单个更新为原子级更新,并可用于生成使用优化并发操作的高度可伸缩代码。这样的代码更难以编写,但其往往不会阻塞。(如果您对此感到疑惑,可以告诉您,锁就是使用此类原语编写而成。)

但进行这些调用会产生成本。图 5 说明了在不争用资源的情况下,获得各种类型锁的成本的微基准(以 CPU 周期为单位)。

5 比较各种锁的成本

尽管这样的度量对于理解锁定的性能本质非常重要(特别是在要做出关于并行执行的动态决策时,在这种情况下,需要就绪的代码量要多于将实际并行运行的代码量),但确保以正确的粒度进行同步有助于保证代码的执行不会受到此类成本的制约。还有一些我没有提到的成本,例如联锁操作和内存层级之间的交互。遗憾的是,受空间所限,不允许发生这种情况。然而,更重要的部分是对可伸缩性的影响。可惜的是,您经常需要在可伸缩性和序列直线执行性能之间进行权衡。应通过度量来获知这些权衡结果。

我们无法保证线程在持有锁时仍可运行,因此,如果其时间片到期,后续线程可能会运行并试图获取这同一个锁。此外,某一变为可运行状态的更高优先级的线程可以优先于在上锁情况下运行的线程。这会导致被称为优先级倒置的现象,如果某一被争用锁处的到达率特别高,则会导致锁保护。大多数锁通过在多 CPU 系统上以某种形式轻度旋转来回应争锁尝试,以期待持有锁的线程马上解锁。如果该方法失败(因为锁的持有方持锁时间超过预期时间,或也许因为在一次上下文切换后锁被交换出),则它会阻塞。对于高度并发的系统,阻塞量越多,则就需要越多的线程来保持 CPU 的占用状态,您的系统成功伸缩的可能性也就越低。

这样,一个时刻需要保持思考的重要问题就是:如何在持有锁的同时执行最少量的工作,以将所需的锁定量减至最低?读取方/写入方锁功能对此会有帮助,它允许多个线程读取数据,同时又仍确保写操作互斥。对于大多数系统,读取方与写入方的比率非常高,因此赢得可伸缩性的成功率极大。要了解更多信息,Jeffrey Richter MSDN Magazine 2006 6 月刊上发表的并发事务专栏是一个很好的起点(参见 msdn.microsoft.com/msdnmag/issues/06/06/ConcurrentAffairs)(英文)。

就已提过的内容而言,如果能首先避免共享状态,则根本就不需要将访问同步化。提高操纵热数据结构(即多数线程都必须访问的数据)的算法的可伸缩性的一个常用技术是避免将各锁一起使用。这可采取三种实现难度逐级提高的重要形式:不变性、隔离性和锁自由性。

不变性意味着某一实例一旦创建就不再改变,或者至少在一段固定的已知时间段内不再改变。例如,CLR 字符串具备不变性,因此不需要根据对其各字符的访问权限上锁。如果状态不变动,则不需要上锁。当有多个位置包含应在原子级观察的状态指针时,这就难以实现。

隔离性通过维护各自的副本避免了对数据的任何并发访问。例如,malloc 操作和 free 操作的许多线程安全的 C 实现对每个线程都维护一个可用内存池,以在线程分配时不会争用该池(该池很可能是任一 C 程序中的一个热点)。同样,CLR 的服务器垃圾收集器 (GC) 使用每个线程一个分配上下文和每个 CPU 一个内存段的方式来提高内存分配的吞吐量。这通常需要与数据结构的中心副本定期会合,并且有时可能需要产生与复制和确保重要数据位不失时效所相关的成本。

锁自由性是一种极复杂的技术,我将只对其一带而过。如果您真正理解目标计算机的内存模型并且乐于编写和维护大量代码,则可以创建在被并行访问时可成功伸缩的智能化数据结构。时常会出现这种情况,最终所得代码如此难以测试正确性和维护,以至于不值得为其花费这样的精力。对于程序中已被测量到与使用锁相关的伸缩性或性能问题的那些方面,这些技术值得探究。

用于剖析并行性的工具

让我们看看如何可以测量和提高代码的可伸缩性。在这整个专栏中,我在技术、方法和成本方面一直不太明确。可惜的是,没有一个可适用于所有并行问题的魔法公式。同样,对于如何剖析问题和/或发现更好的方法以达到并行加速的问题,也没有一个简单的答案。完全可能发生这种情况,您将经历我在这里罗列的所有工作(以及其他一些工作我还未讨论调试问题),但结果却不比坚持使用序列算法的结果好。还有一些所谓的令人困惑的并行问题,对于这些问题已编写了类似食谱的算法,可通过在线方式和在课本中获取这些算法。可惜的是,许多现实中的程序并不是这样简单易懂。

以下是对于剖析并行算法的一些提示。所有这些提示利用的都是新版 Visual Studio® 2005 剖析器。它内置于普通的 Visual Studio 界面中(在工具|性能工具|新性能会话菜单项下),它还有一个命令行版本,位于 /Team Tools/PerformanceTools/ Visual Studio 子目录中,名为 VSPerfCmd.exe(有关此工具的使用详细信息,请参见 msdn2.microsoft.com/ms182403.aspx)。此剖析器将创建 VSP 文件,这些 VSP 文件可通过 VSPerfReport.exe 命令进行管道输送以创建 CSV XML 文件,以供进一步分析。以下是要查找的几个项目。

确保 CPU 被占用。 如果处理器的利用率很低,则很可能是发生了以下两种情况之一。您没有使用足够的处理器来保持问题的占用状态,或者线程被备份以互相等待(这极有可能是因代码中热点处的过度同步引起)。通常任务管理器足以应付此问题,尽管也可使用处理器/ 处理器时间性能计数器(通过 PerfMon.exe)。

确保程序不大量出错。特别是对于数据密集型的应用程序,需要确保物理内存不定期溢出。在这种情况下,一个充满线程的系统可能会在其不断输入和输出页面数据时频繁磨损磁盘。(请回想一下,前面图表所示的磁盘访问的成本是多么昂贵?)与 PerfMon.exe 一样,任务管理器可以为您提供此数据(您需要将其选为一列)。VSPerfCmd 也可使用此命令通过 ETW 事件报告此数据:

VSPerfCmd.exe /events:on,Pagefault /start:SAMPLE /output:<reportFile name> /launch:<exeFile name>

然后当程序完成时使用下列命令。

VSPerfCmd.exe /shutdown

您可能还想斟酌一下采样间隔。

确定程序在哪里花费的 CPU 时间最多。 如果在持有锁时发生此 CPU 时间,则这一点特别重要。也可能出现这种情况,即创建线程、执行同步以及与这两件事相关的任何操作所需的附加代码在支配执行时间。

检查系统/上下文切换/秒和系统/处理器队列长度性能计数器。 这有助于确定线程是否过多,以使时间浪费在上下文切换和线程迁移上。如果情况如此,则尝试调整用于确定使用多少任务的算法。

查找内存层级和高速缓存问题。 如果上述建议没有一个有效,并且似乎应看到更大的加速,则可能存在内存层级和高速缓存问题。在高速缓存缺失和失效上花费的大量时间会极大限制加速程序的能力。对数据分区可更易于高速缓存线操作,并且使用上述一些方法(例如隔离)会有助于解决此问题。每个 CPU 都提供一组性能计数器,可在 Visual Studio 的剖析器中对其查询,包括已引退指令和缺失的高速缓存之类的信息。如果已引退指令的计数很低,则表明有更多时间花费在高滞后时间操作上(例如高速缓存缺失),并且可使用高速缓存特定的计数器来确定发生缺失的位置以及缺失频率。

尽管确切的计数器特定于处理器,但 Visual Studio 界面为您提供了一个方便的选项来使用它们(参见图 6),也可以通过下列命令查询这些计数器:

VSPerfCmd.exe /QueryCounters

6 剖析器属性

结论

从历史角度说,通过并行性实现可伸缩性仅限于服务器端和高端工作站环境。但随着新硬件向线程级并行性(即多核体系结构)发展的趋势,主流客户端软件最终将不得不应付和有效使用可用资源。随之将产生一系列独特的难题。并行性无疑不会替代高效的序列代码,但它的确是提高已优化序列算法的运行速度的一项技术。

这是一个前卫、高层次的看法。您可能会走开,说这条路太难走,您并没有错。但随着时间推移,新一代的多核处理器将越来越普及,这些技术会帮助您构建可在这些多核处理器上保持良好伸缩性的代码。在 CLR 线程池之类的基础结构和 Visual Studio 之类的工具演化到可更好支持这种形式的编程时,您就可以期待许多困难可以逐渐迎刃而解。

请将您的问题和意见发送至 clrinout@microsoft.com

Joe Duffy 就职于 Microsoft CLR 小组。他关注的重点是为平台开发并发操作抽象、编程模型和基础结构。他刚出版了一本书:.NET Framework 2.0 (Wrox/Wiley, 2006),并正在编写另一本书:Concurrent Programming on Windows (Addison-Wesley)。他经常在其博客网站上发表文章:www.bluebytesoftware.com/blog(英文)。

 

 
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值