多线程编程的具体细节

来源:互联网 发布:知止阅读理解答案 编辑:程序博客网 时间:2024/05/17 18:12

 多线程编程的具体细节

介绍
作者:Tim Mattson

了解并行算法和并行编程 API 的基本信息,以及开始编写您自己的并行程序所需要的工具。

当前领先的硬件设计依赖于并行性,也就是说,多个活动在同一时间运行。CPU 内部性能依赖于指令级并行性。所有主要的 CPU 厂商在每个硅核上都采用多个内核,典型的服务器每个机箱内都有多个插座。覆盖全球的服务器集群和系统网格正在迅速普及。并行已成为主流,所以,如果软件开发人员想与时俱进,最好学会如何运用它。

最好的切入点是深入地了解并行性硬件。80年代初期,超级计算的设计者们通过将小型计算机连接构建成较大的系统,设计出了高性能的电脑。大部分的设计的都归属于两大阵营之一:分布式内存多指令多数据流(MIMD)架构或共享式内存 MIMD 架构。在 MIMD 中,每个处理单元都有其自己的指令流和数据流。如果系统中所有处理元件共享一个地址空间时,我们把这种系统称为“共享式内存”系统。而如果内存不同,每个处理元件只能通过网络连接相互作用,我们则称为“分布式内存”系统。

这两种架构都很重要,取决于需要解决的问题,软件开发人员应选择一个更加合适的架构。然而,在近几年内,共享式内存系统的数量将会突飞猛进。随着英特尔在一块芯片上置入两个内核,以及多插槽系统的持续增长,共享式内存系统将会越来越普及。笔记本电脑以至掌上电脑都将采用多核 CPU,并为程序员提供一个并行的硬件平台。

那么,程序员应如何运用这些并行系统呢?根据工作量以及目标用户的需求,他们有两种选择:第一种选择的目标是,在更少时间内完成收集独立的任务。也就是说,这种选择着重系统的生产能力或集合能力。这种情况下,每个单独的任务都可以在单个处理器上运行。软件开发商需要做的只是确保任务调度(通常在操作系统内)能有效地控制工作量。当需要在更少的时间内完成单个任务时,就有了第二种选择。比如,当今的业务环境经常要求电脑迅速响应,以支持实时的决策制定。这种情况下,单个任务需要更少的时间运行。根据当前问题的范围,在单个任务中采用并行性,以使其运行更短的时间,我们把这种情况叫做“并行编程”。

多年来,并行编程都是硬件内核高性能计算(HPC)的软件开发人员潜心研究的领域。然而今天,所有的软件开发人员都需要理解并行编程。有时,当您轻松地创建了一个并行程序,您会很庆幸,但在大部分情况下这很具有挑战性。

幸运的是,HPC 的程序员已经研究并行性几十年了,他们已经掌握很多关于并行算法和并行编程 API 的相关知识,并了解使程序员的工作更轻松、工作效率更高所需要的工具。本文将就这些问题进行概述,希望能够为您开始编写自己的并行程序指出正确的方向。

我们首先从一些并行编程领域的计算机科学背景知识入手。然后再对编程共享式内存 MIMD 计算机上具体的 API 和编程环境进行阐述。最后,我们做一总结,并列出您可以获得更多知识的资源。

并行处理:计算机科学的前景
 
并行编程的基础是并发,即在一个问题或一个系统内有两个或多个任务同时活动的状况。多年来,操作系统已经利用并发使系统响应速度变得更快。 在单个处理器情况下,任务是轮流执行的。在该处理器上,他们的指令被交叉存取和调度。这就优化了系统资源的利用率,隐藏了系统的延迟,但是,它并没有加快单个指令流的运行时间。

如果您想在更少的时间内完成固定的任务量,必须遵循如下流程:

  1. 查看包含有效并发的问题
  2. 重新安排这一问题,或者编写实现并发的具体算法
  3. 在包含有多个处理元件的计算机上运行该算法

让我们就以上步骤逐一讨论。

首先,问题必须包含并发。如果该问题本来就是一个按固定的事件顺序组织的单一任务,那么一定没有并发可言。比方说,我正在使用 Microsoft Word* 键入这篇文档,Word 程序必须等到我击键之后才能作出反应。这就构成了完成这一任务的基本连续顺序。整个过程无法实现并发。

现在我们来考虑一下渲染图像的过程。在这个过程中,3D 模型根据观察者的眼睛和光源之间的光线,转换成为实际的图像。由于每条光线都独立于其他光线,因此,可以单独地处理每条光线的计算。因为使用了多任务来定义并行,所以这个问题属于任务级的并行问题。并且,由于每条光线都是独立的,所以程序员大可不必在执行任务的过程中进行任何操作来协调各个任务。我们称这类任务级问题为“完全并行”。

最后,我们考虑一个较为复杂的问题——模拟喷气机引擎燃气涡轮上的压力。在这个问题中,要想实现并行就比较复杂了。这个问题的核心在于——在涡轮上添加网格,并在网格的每个点上解微分方程。这看上去好象是个繁重的工作,但是如果按照数据分析这个问题,您会发现可以将网格划分成较大的块,然后便可同时解这些块内部的微分方程。块的计算过程不能独立完成,这是因为这些块需要在边界处发生相互作用。但是,您可以利用内部的计算结果覆盖更新结果,从而实现大量的并行。这种方法不但找到了数据中的并行,还可以发现数据是如何分解的。这种算法称为“几何分解法”。

选择一种并行算法来实现某个问题中的并发,其过程可能非常复杂。好在软件工程师们已经对这类算法进行了 20 余年的研究,目前已经将大部分算法归纳为一组设计模式。这些模式构成了相互连接的模式网络,这个网络包括了从高层的问题声明到并行算法,最后到执行该并行程序所需要的低层结构。这种模式网络称为“模式语言”。您可从《并行编程的模式》一书(Timothy G. Mattson、Beverly A. Sanders、Berna L. Massingill、Addison Wesley,2004)中获得有关并行程序模式语言的详细信息。

有了并行算法后,您需要将算法转换成源代码,然后在并行计算机上运行这些代码。我们将在下一部分介绍这一重要步骤。而在这一节里,我们将详细介绍并行程序是如何在并行计算机上运行的。

现代操作系统按照进程组织任务。进程是支持执行某一程序指令的资源集。这些资源可以是虚拟内存、I/O 描述符、运行时堆栈、信号处理程序、用户和组 ID以及存取控制令牌。也就是说,进程是载有状态(包括其地址空间)的“重量级”执行单元。

如果想要在计算机内移动进程,或者想与另一进程交换活动进程,将要花费巨大的代价,需要进行大量的工作。这就极大地增加了操作系统试图优化系统资源利用率的难度。因此,现代操作系统还包括一个更为简单的“轻量级”执行单元——线程。线程是与进程相关的简单执行机构,与进程共享同一环境。每个线程都有其自己的堆栈,但对大多数线程来说,线程可以使用的内存取决于进程所享有的共享地址空间。也就是说,在线程之间实现内容的切换消耗很小,从而可方便地在系统内移动线程。

在共享式内存计算机上进行编程时,线程是执行过程的普通单元。整个应用程序拥有一个单一的进程,通过将并行算法映射到进程所拥有的多个线程上,即可实现并发过程。我们称之为多线程编程。由于线程共享同一地址空间,所以可以方便地在程序内部管理线程之间的交互。并且,与每个线程相关的状态较少,因此通过改变线程的数量,可轻松地创建程序,来满足应用的特殊需要。

但是请注意一点,多线程编程并不是在共享式内存计算机上实现编程的唯一方法。您可以将并行算法映射到多进程中。这些进程并不共享地址空间,因此只有明确在进程间传递信息时(比如使用消息传递 API,如 MPI),进程之间才进行交互。这种方法在科学计算界已得到广泛的应用,在这个领域中,通常要求程序既可以运行在分布式内存系统(如集群),又可以运行在大型的共享式内存系统中。但是我们相信,更多的主流应用程序将不再需要支持集群,而对于大多数程序员来说,更多的并行编程的编程方法都将以线程为基础。

使用线程编程
 
只要您手头有需要解决的问题,确定了问题中存在并发,并找到了实现并发的并行算法,那么真正的乐趣将由此开始:您需要将算法转换成并行程序。换句话说,您需要使用应用程序编程界面(Application Programming Interface, API),用程序的源代码表示并发过程。

多线程编程最常用的 API 是 OpenMP* 和显式线程库(如 Unix 环境下的 pthread)或 Windows* 线程。选择使用哪一种 API 的过程比较复杂,因为这取决于您所在编程团队的经验和待解决问题的种类。

OpenMP 是一组指令与所支持的运行时库例程的集合,用来支持多线程应用的编程。当您编写 OpenMP 程序时,通常会找到最耗时的循环,然后使用指令通知编译器在线程间分解循环迭代。OpenMP 还可用于定义任务队列。但一般来讲,多线程并行最常用于循环级并行。OpenMP 的优点是使用简单,您只需使用少量指令,并且在通常情况下,这些指令不会改变底层程序的语义。让人惊喜的是,采用编程规则,程序员可以利用 OpenMP 将程序从顺序程序顺利演化到并行程序,从而在每个转换阶段都可以进行测试。

然而显式线程库使用起来就复杂得多。线程中待执行的代码在例程中被打包。然后将线程插入在程序的特定位置,将所需例程传递到启动该线程的函数中。必须编写大量用于创建线程及在线程中运行的代码。其结果是使用显式线程库的困难更大,并且更容易出错。因此,使用显式线程 API 的缺点在于,您“必须”控制好管理线程的所有低级别详细信息。另一方面,使用显式线程 API 的优点在于,您可以控制管理线程的所有低级别详细信息。因为程序员控制了所有部分,因此他们可处理的算法范围更为广泛,同时可将精力更多地放在调整程序以满足特定系统的需求上来。最后,使用 OpenMP 要求编译器必须能够使用 OpenMP,而显示线程 API 只要求具有与多线程库之间的接口。所以,显示线程 API 可以支持更广泛的编译器和语言。

不管程序员选择使用何种 API,都要确保结果程序能够快速地执行,这一点很重要。我们根据加速来衡量并行程序的性能。加速是并行程序运行时间与现有的最佳顺序算法运行时间之比。

      S = t(顺序)/t(并行)

如果您使用的并行程序性能优良,则加速应与处理元件数 (P) 呈线性正比,即 S 应等于 P。我们称之为“完美线性加速”。但是程序员很少能够获得这种完美线性加速。这个问题在高度并行问题中更为严重。由于高度并行的问题本身存在大量的消耗,从而限制了性能和潜在的加速。

最重要的问题是,考虑到某个问题的连续部分,及其对加速产生的影响。决定这一关系的法则称为 Amdahl 定律。下面将快速简单地推导 Amdahl 定律。假设某一问题的总运行时间为 T(s)。我们可以将其分为两部分,一部分是可并行运行的时间 (fp),另一部分是只能连续运行的时间 (fs)。引起后者的操作有:I/O 性能消耗、固定启动成本,或者处理并行任务的结果所需要的工作等。如果增加了处理问题所使用的处理器个数,则只有并行部分会加速,因此,以处理器个数 (P) 为变量的时间函数为:

      T(P) = (fs + fp/P)T(s) = (fs + (1-fs)/P)T(s)

把该式代入加速的定义式中:

      S = T(s)/(fs+(1-f(s))/P)T(s) = 1/(f(s) + (1-f(s))/P)

其中,如果将 P 取最大值,将得到

      S = 1/f(s)

这些等式表明,当考虑并行算法时,算法连续部分的影响至关重要。如果仅对算法的 80% 进行了并行处理,那么不管使用多少处理器,得到的最佳加速都将是 5。如果使用两个处理器,则得到的最佳加速仅为 1.4。因此,如果您关心的是算法的性能,那么需要将注意力集中在如何最大限度地缩短算法中的顺序运行时间上。

并行编程之所以如此具有挑战性,是因为并行编程不但存在由并行算法产生的性能问题,还存在与顺序程序相类似的问题。例如,您在管理内存存取模式时必须多加小心,以便处理元件靠近其将要使用的数据。如果需要移动到多处理器系统时要更加小心——尤其是在多处理器系统规模扩大,并且从内存的不同区域进行存取而产生的消耗因处理器的不同而不同时(例如非统一的内存架构或 NUMA 系统)。

高性能就是并行编程中最为重要的核心。但是,快速获得错误结果的做法并不可取。实现多线程程序的正确性非常具有挑战性。所有的线程享用单一的地址空间,这样就大大简化了并行编程。但由于线程共享数据的方式并非按照程序员所期望的那样,所以这个特点同时也引发了并行编程所独有的挑战性。如果程序的输出会随线程变化的具体调度而变化,则称这种现象为“竞争条件”。这种现象在所有的编程错误中是最具危害的一种,因为几乎没有办法能够证明程序中不存在竞争条件。比如说,某个程序可能在前 1000 次正确地运行,而在第 1001 次运行时,线程调度将以某种方式排列,内存冲突将导致程序运行失败。

为了消除多线程编程中出现的竞争条件,程序员必须确保交叉存取指令所使用的每种可能方法都能产生正确的结果。这就是说,程序员必须识别出多线程需要读取或写入的对象,并利用同步结构保护这些对象,从而保证每次的存取顺序都能得到正确的结果。

例如,假设一个简单的程序,用来计算两个独立的结果(A 和 B),然后将这两个结果与第三个数值结合,计算出最终的结果。下面是多线程程序在两个线程上进行计算的方法:

线程 1线程 2
A = BigJob()B = BigJob()
Res += ARes += B

如果计算一个数值(例如 A)的时间比计算另一数值(例如 B)的时间长,则这一程序可能会产生正确的结果。但如果两个线程同时都将它们的值加到 Res 上,则无法预测产生的结果。例如,如果 A = 1,B = 2,并且 Res 为 3(在计算 A 和 B 之前),则结果可能为:

Res 的最终结果选择的顺序
6如果非常幸运,正好计算 A 和 B 所需时间不同
5如果线程 1 读取 Res,而在计算和的同时,线程 2 也在读取 Res,那么当线程 1 结束时线程 2 才计算和并输出结果
4如果线程 2 读取 Res,而在计算和的同时,线程 1 也在读取 Res,那么当线程 2 结束时线程 1 才计算和并输出结果

以上几种结果都来源于多线程编程中指令所进行的正当的交叉存取。但只有第一个结果是正确的。因此,程序员必须对程序加以约束,使得无论对指令采取何种交叉存取方式,都能得到正确的结果。通常情况下,同步结构可以保护 Res 的更新。每种多线程 API 都包括一个同步结构分类。对于 OpenMP 来说,这种微小的结构就是在这种情况下所使用的实际同步结构。它确保了一次只有一个线程进行保护操作,并且只有结束对一个线程的操作之后,才开始另一个线程。其结果程序如下:

线程 1线程 2
A = BigJob()B = BigJob()
#pragma omp atomic#pragma omp atomic
Res += ARes += B

虽然在内容较少的代码片断中,我们很容易发现这些问题,但是对于实际的应用程序而言,内存冲突问题可能会被许多不同文件中存在的数千行的代码所掩盖,这样,就很难确保能够发现所有潜在的内存冲突。同样,要找到造成并行效率低下的原因也极具挑战性。程序员必须对程序有深层的了解,以便能够处理性能和正确性的问题。最佳方法是使用合适的工具,英特尔具有一套工具——包括可分析程序的系统级行为(英特尔® VTune™ 可视化性能分析器)、分析多线程程序中存在的内存冲突(英特尔® 多线程检测器)以及可使程序的并行性能可视化的工具。如果您非常重视创建有效的多线程程序,则很有必要使用这些工具。

综述
 
从笔记本电脑到高端服务器,随处可以看到并行的存在。并行可以出现在每块芯片多个内核的各个层次中——每个机箱的多个插槽以及每个系统(集群)的多个机箱中。尽管只有少数程序员需要顾及集群的问题,但所有的程序员都必须考虑到共享式内存的多处理器。您对这些系统进行编程的方法就是利用多线程编程。

线程是共享地址空间的“轻量级”进程。程序员可使用如 OpenMP 或显式线程库等 API 来创建线程,并协调线程之间的行为。其挑战在于如何在最大限度地降低并行消耗的同时,又确保每种交叉存取进程的正当方法,都能获得正确的结果。通过“单独检查”来实现这一点是非常困难的。关键是选择正确的工具来帮助您实现这一目标。
 
其他选读材料
 
这篇简要的文章仅介绍了多线程编程中所引发的高级问题。本文旨在提供环境以及简要介绍了程序员需要力求解决的关键问题。如欲掌握多线程编程,请阅读其他材料。

如果您希望了解并行算法以及如何“思考并行”,设计模式将会对您大有裨益。如下书目列举了并行编程专家多年开发的主要设计模式:

  • Tim Mattson、Beverely Sanders 和 Berna Massingill合著。《并行编程的模式》:Addison Wesley Professional
  • 如果希望了解 OpenMP 的详细信息,可从 OpenMP 站点上获取并阅读 OpenMP 规范:www.openmp.org
  • 对指令驱动的多线程编程不熟悉的程序员来说,展开教学讨论非常重要。虽然教学内容未经更新(请仅参考 1.0 规范),但是基本思想都是正确的,并在下面的书目中有详细说明:《在 OpenMP 中进行并行编程》,Chandra、Rohit、旧金山和加利福尼亚州,:Morgan Kaufmann;伦敦:Harcourt, 2000, ISBN: 1558606718
  • 有关多线程库(包括 pthreads 和 Windows 线程)的信息,建议使用如下站点中的几本优秀书目:http://www.intel.com/intelpress/ 
  • 虽然我们鼓励大家购买图书,并支持那些付出辛勤劳动的作者,但英特尔网站为您提供了大量的免费信息。

关于作者

  • Timothy G. Mattson 因其在量子散射理论中的研究,于 1985 年在美国加利福尼亚大学 Santa Cruz 分校获化学博士学位。自 1993 年以来,Timothy G. Mattson 一直与英特尔合作,目前是英特尔并行算法实验室的一名研究科学家,从事支持并行算法表达技术的研究工作。Tim 把家庭作为生活的重心,爱好滑雪、科学和所有与皮艇有关的运动。
原创粉丝点击