设计多线程应用程序的8个简单规则





0/5 (0投票)
遵循这些规则,您将能更成功地为您的应用程序编写出最佳、最高效的线程实现。
这是我们对 The Code Project 赞助商的展示性评测。这些评测旨在为您提供我们认为对开发人员有用且有价值的产品和服务信息。
引言
英特尔采用的线程方法论有四个主要步骤:分析、设计与实现、调试和性能调优。这些步骤用于从串行基础代码创建多线程应用程序。除了Mattson、Sanders和Massingill最近的《并行编程模式》之外,关于如何进行设计与实现部分的过程并没有太多著述。
多线程编程仍然更多地是艺术而非科学。本文提供了8条简单规则,您可以将它们添加到您的线程设计方法库中。遵循这些规则,您将能更成功地为您的应用程序编写出最佳、最高效的线程实现。
规则1:确保您识别出真正独立的计算。
除非并行执行的操作可以相互独立运行,否则您无法并发执行任何操作。我们可以很容易地想到现实世界中为了实现一个目标而执行独立操作的各种例子。例如,建造房屋可能涉及许多具有不同技能的工人。即木匠、电工、玻璃工人、水管工、屋顶工、油漆工、泥瓦工、园丁等。在两组工人之间存在一些明显的调度依赖关系(在墙壁建成之前不能铺设屋顶瓦片,在安装石膏板之前不能粉刷墙壁),但总体而言,参与建造房屋的人员可以相互独立工作。
在某些情况下,您将只有无法并发的完全顺序计算;其中许多将是循环迭代或必须按特定顺序执行的步骤之间的依赖关系。后者的一个例子是怀孕。你不能让九个女人一个月内生一个孩子,但你可以让这九个女人每个人在九个月后生一个新击球手,组建一支未来的棒球队。
规则2:尽可能在最高层实现并发。
在处理将串行代码线程化的项目时,您可以使用两种方法:**自下而上**和**自上而下**。在线程方法论的分析阶段,您识别出代码中占用大部分执行时间的部分(**热点**)。如果您能够并行运行这些代码部分,您将最有机会实现最大的性能。
在自下而上的方法中,您将尝试对代码中的热点进行线程化。如果这不可能,您可以向上搜索应用程序的调用栈,以确定代码中是否存在可以并行运行并仍然执行热点的另一个位置。即使有可能在热点代码处采用并发,您仍然应该查看是否有可能在调用栈中更靠上的代码点实现该并发。这可以增加每个线程执行的粒度。
采用自上而下的方法,您首先考虑整个应用程序,计算的目标以及从非常抽象的层面看,构成该计算的所有应用程序部分。如果没有明显的并发性,您应该将计算的各个部分逐步分解为更小的部分,直到能够识别出独立的计算。分析阶段的结果可以指导您的调查,以包括最耗时的模块。
并发计算的**粒度**粗略定义为在需要同步之前完成的计算量。同步之间的时间越长,粒度就越粗。细粒度并行存在分配给线程的工作量不足以克服使用线程的开销成本的危险。粗粒度并行具有较低的开销成本,并且也往往更容易扩展以增加线程数量。自上而下的线程化方法(或将线程化点推高到调用栈中)是实现粗粒度解决方案的最佳选择。
规则3:及早规划可扩展性,以利用不断增加的核心数量。
处理器最近已从双核发展到四核。未来处理器中可用的核心数量只会增加。因此,您应该在软件中规划这种处理器增长。**可扩展性**是衡量应用程序处理系统资源(核心数量、内存大小、总线速度等)或数据集大小变化(通常是增加)的能力。面对更多可用的核心,编写灵活的代码以利用不同数量的核心。
引用C. Northecote Parkinson的话:“数据会膨胀以填补可用的处理能力。”随着计算能力的增加(更多核心),需要处理的数据也更有可能膨胀。通过数据分解方法设计和实现并发将比功能分解更具可扩展性。独立函数的数量在应用程序执行过程中可能是有限且固定的。
尽管应用程序已编写好并将线程分配给独立函数,但当输入工作负载增加时,仍然可能使用额外的线程。考虑上面建造房屋的例子。要完成的独立任务数量是有限的。然而,如果建造房屋的规模增加一倍,您会期望在某些任务中分配额外的工人(例如,额外的油漆工、额外的屋顶工、额外的水管工等)。因此,您应该注意即使在功能分解的任务中,由于数据集增加而可能出现的数据分解可能性。
规则4:尽可能使用线程安全库。
如果您的热点计算可以通过库调用执行,那么您应该强烈考虑使用等效的库函数,而不是执行手写代码。编写执行已被优化过的库例程封装的计算的代码,从而“重新发明轮子”绝不是一个好主意。许多库,例如Intel® 数学核心函数库 (MKL) 和Intel® 集成性能基元 (IPP),都有已线程化以利用多核处理器的函数。
然而,比使用线程库例程更重要的是,确保所有使用的库调用都是线程安全的。也就是说,如果从两个不同的线程调用库例程,每次调用的结果都将返回正确答案。请查阅您正在使用的任何第三方库的文档,以了解其线程安全性。
规则5:使用正确的线程模型。
如果线程库不足以覆盖应用程序的所有并发性,并且您必须使用用户控制的线程,那么如果OpenMP或Intel® Threading Building Blocks (TBB)具备您需要的所有功能,则不要使用(更复杂的)显式线程。如果您不需要显式线程提供的额外灵活性,那么可能没有理由做不必要的工作或增加实现复杂性,这会使代码更难维护。
OpenMP专注于数据分解方法,特别针对处理大型数据集的循环线程化。TBB也类似地专注,但还包含其他一些并行算法和并行数据容器。即使您最终需要使用显式模型来实现线程化,您仍然可以使用OpenMP来原型化计划的并发性,更好地估计潜在的性能提升、可能的可伸缩性,以及使用显式线程对串行代码进行线程化需要多少工作量。
规则6:切勿假定特定的执行顺序。
线程执行顺序是**非确定性**的,由操作系统调度程序控制。无法可靠地预测线程从一次执行到另一次执行的运行顺序,甚至无法预测哪个线程将按计划运行。这主要是为了隐藏应用程序中的执行延迟,尤其是在核心数量少于线程数量的系统上运行时。
数据竞争是这种调度非确定性的直接结果。如果您假设一个线程会在另一个线程读取该值之前将一个值写入共享变量,那么您可能总是对的,或者您可能有时是对的,或者您可能完全不对。仅仅通过积极思考来依赖线程之间特定执行顺序的代码将会遇到诸如数据竞争和死锁之类的问题。
从性能角度来看,最好让线程尽可能不受束缚地运行。除非绝对必要,否则不要试图强制执行特定的执行顺序。您需要能够识别那些绝对必要的时候,并实现某种形式的同步来协调线程之间的执行顺序。
规则7:尽可能使用线程局部存储;如果需要,将锁与特定数据关联起来。
同步是开销,它不促进计算的进行,除了保证应用程序并行执行产生正确的结果。它是一种必要的恶。即便如此,您也应该积极地寻求将同步量保持在最低限度。这最容易通过使用线程局部存储或使用独占内存位置(例如,通过线程ID索引的数组元素)来实现。
临时工作变量很少需要在线程之间共享,应声明为局部变量。保存每个线程部分结果的变量也应为线程局部变量。将部分结果组合到共享位置将需要一些同步。确保共享更新尽可能少地进行,将使开销保持在最低限度。
如果为每个线程使用本地存储不是一个有效的选项,并且您必须通过同步对象(如锁)来协调对共享资源的访问,请务必将锁正确地关联(或“附加”)到数据项。最简单的方法是让锁和数据项之间存在1:1的关系。如果内存位置集合总是在相同的临界区中访问,您可以设置一个锁来保护多个数据项。西格尔定律指出:“一个人有一块手表,他知道时间。一个人有两块手表,他永远不确定。”如果两个不同的锁保护对同一个变量的访问,代码的一部分可能使用一个锁进行访问,而代码的另一部分可以使用另一个锁。在这些两个代码部分中执行的线程将创建数据竞争,因为两者都将假定它们对争夺的数据拥有独占访问权。
规则8:不要害怕改变算法以获得更好的并发机会。
在比较应用程序(无论是串行还是并行)的性能时,最重要的是实际执行时间。在选择两种或更多算法时,程序员可能依赖于执行的渐近阶。这个理论度量几乎总是与应用程序的性能相关联。
在并行应用中,具有更好渐近执行顺序的算法也会运行得更快。然而,有时最好的串行算法并不适合并行化。如果一个热点被证明不容易转换为线程化代码(并且您在热点的调用栈中找不到可以并行化的更高点),您应该考虑使用一个次优的串行算法,它可能比代码中当前更好的串行算法更容易并行化。当然,可能还有其他不那么剧烈的改变,可以允许您并行化给定部分代码。
摘要
我们已经介绍了8条简单规则,在您设计将串行应用程序转换为并行版本的线程化方案时,您应该牢记这些规则。遵循这里提出的规则并牢记实际的编程规则,您可以更轻松地创建更健壮、更不容易出现线程问题、并在更短时间内实现最佳性能的并发解决方案。
资源
- 通过新的Intel® Software College 多核培训系列了解更多关于多线程编程方法论的信息。
- 阅读 Clay 的博客这里。
- 在Intel® 多核开发人员社区线程中学习、分享并与同行软件开发人员联系。