编写并行程序:多语言指南介绍
2008-02-22 20:35
344 查看
编写并行程序:多语言指南介绍
发布日期: 2007年4月12日 | 最后修改日期: 2008年1月8日
简介
并行编程曾一度是顶级编程人员面临大型超级计算问题困扰时的唯一关注点。但是,随着面向主流应用的多核处理器的出现,并行编程现已成为所有专业软件开发人员都必须了解和掌握的一项技术。
并行编程也许会很难,但事实上,它只是“不一样”而已,并非“很难”。它不仅具有更为传统的串行编程的所有特点,还包含三个定义完备的附加步骤:
识别并发:分析问题,以识别能够同时执行的任务。
揭露并发:重新构建一个问题,确保高效完成任务。此步骤通常需要确定任务本身与组织源代码之间的依赖程度,以便任务得到高效管理。
表达并发:在源代码中使用并行编程符号来表示并行算法。
以上的每一步都很重要。前两步在最近一本关于并行编程设计模式的书中有详细介绍 [mattson05]。本文将重点介绍第三步:在源代码中使用并行编程符号来表示并行算法。这种符号可以是并行编程语言,也可以是通过程序库界面实施的应用编程接口 (API),或是添加到现有序列语言的语言扩展。
选择一种独特的并行编程符号绝非易事。这些符号的学习难度参差不齐,而且极其耗时。因此,掌握几种符号并从中选择一种进行运用的做法是不切实际的。编程人员所需的是一种能够充分详细地了解不同符号的“优势”,以及各项高级特征的快捷方法,以便明智地决定学习哪种符号。
在本文中,我们将对几种不同的并行编程符号进行高度全面概述,重点介绍其主要使用方法,并对它们的特殊优势和缺陷进行分析。我们将具体介绍以下几种符号:
OpenMP:针对简单并行编程的编译器指示
MPI:支持超高性能便携性的程序库例程
Java:基于领先对象的编程语言并行
为了使我们的讨论尽可能地具体详实,我们在每种情况下都部署了一款知名的 π 程序的并行版本。这是一种利用梯形法则(其中被积函数和积分极限被选中)来运算的简单数值积分,因此从数学角度讲,正确的答案为 π。很多人都将它视为并行编程中最为基本的程序。在本文最后,我们还将简单介绍如何选择一款并行编程符号来使用并掌握。
π 程序:并行梯形积分
在微积分研究中,我们了解到一个积分在几何上可以表示为曲线下面积。也就是说,积分的近似值可以通过计算得出。我们先将积分面积划分为许多的梯级,并在每一梯级内画出一个矩形,并让它的高等于该梯级中心的被积函数值。那么这样,每个矩形面积的和就约等于该积分。
![](http://softwarecommunity-zho.intel.com/UserFiles/en-us/Image/1252/1252a.gif)
如图 1所示:梯形积分——每一竖条都拥有固定宽度的“梯级”。每个竖条的高度便是被积函数的值。将所有竖条的面积加在一起便得出曲线下面积的近似值,即被积函数的值。
我们可以选择一个被积函数及积分极限,那么该积分在数值上便等于 π。这样便可更加直接地进行程序正确性检验。下面我们向您展示了一个执行该算法的简单的 C 程序:
OpenMP
OpenMP [omp] 是一款为共享内存计算机来编写并行应用程序的行业标准 API。OpenMP 的主要目的,是使高性能计算中常见的循环导向型程序更加易于编写。OpenMP 中的各种结构可支持 SPMD、Master worker、管线,以及大多数其他并行算法 [Mattson05]。
OpenMP 是一款非常成功的并行语言。市面上每款共享内存计算机均支持它的运行。最近,英特尔在 OpenMP 上创建了一个变量,从而实现了对集群的支持。OpenMP 还能逐步添加并行的编程方式,因此现有的顺序程序可发展为并行程序。但是,这一优势却也成了OpenMP 的最大缺陷。那是因为通过使用逐步并行,编程人员可能会错过某一程序的大规模重建,从而失去获得最大性能的机会。
OpenMP 的标准仍在不断演进。为此,一家名为“OpenMP 体系结构评审委员会 (OpenMP Architecture Review Board)”的行业团体定期举行会晤,共同开发该语言的全新扩展。OpenMP 的下一版本(3.0 版)将包含一种任务队列能力。这将支持 OpenMP 处理更广泛的控制结构,以及更多的一般递归算法。
OpenMP 简介
OpenMP 基于 fork-join 的编程模式而设计。OpenMP 程序起初以一条单线程的形式开始运行。如果编程人员希望在程序中利用并行,那么就需将额外的线程进行分支,以创建线程组。这些线程在称为“并行区域”的代码区域内并行执行。在并行区域末尾,将等待所有线程全部完成工作,并将其重新结合在一起。那时,最初线程或“主”线程将继续执行,直至遇到下一个并行区域(或程序结束)。
OpenMP 的语言结构根据编译器指示而定义,可为编译器布置任务,以实施理想的并行。在 C 和 C++ 中,这些指示根据制导语句来定义。
OpenMP 制导语句在任何情况下的形式都相同
#pragma omp construct_name one_or_more_clauses
其中“construct_name”规定了编程人员希望执行的并行动作,而“clauses”则对该动作进行修改,或对线程所见的数据环境进行控制。
OpenMP 是一种显式的并行编程语言。一旦线程创建,或者工作已经映射到该线程上,那么编程人员必须对希望执行的动作加以详述。因此,即使是 OpenMP 这样简单的 API 也有诸多结构和子句需要编程人员学习。所幸的是,仅利用整个 OpenMP 语言的一小部分子集,便可完成大量上述工作。
可利用“parallel”结构在 OpenMP 中创建线程。
独自使用时(没有修改任何子句),程序可创建出一系列线程供运行时环境选择(这些线程通常与处理器或内核的数量相等)。每条线程都将根据并行制导语句来执行语句块。该语句块可以是 C 中的任意合法语句组,但是唯一例外的是:您不能分支到并行语句块之中或之外。您只要稍微思考一下就能明白。如果线程要全面执行语句组,并且该程序的继发行为还要有意义,那么您便不能随意将线程分支到并行区域内的结构之中或之外。这是 OpenMP 的一项公共约束。我们将这种缺乏某些分支的语句块称为“结构块”。
您可以令所有线程执行相同的语句,从而进行大量的并行编程。但是要体验 OpenMP 的全部功能,我们要做的就不止这些。我们需要在多条线程之间共享执行语句集的工作。我们将这种方式称为“工作共享”。最常见的工作分享结构是循环结构,在 C 中即为“for”循环
#pragma omp for
但是,这一结构仅对具有规范形式的简单循环起作用
for(i=lower_limit; i<upper_limit; inc_exp)
“for”结构执行循环的迭代,并将其打包至那些利用并行结构创建的早期线程组中。循环极限和循环索引 (inc_exp) 的递增表达式需在编译时完全确定,并且这些符号中使用的任何恒量都必须在线程组中保持相同。您只要思考一下就能明白。系统需要得出循环的迭代数量,然后将其映射到能够分发至线程组的集上。如果所有线程均计算相同的索引集,那么上述工作只有通过持续稳定的方式才能实现。
请注意,“for”结构并不能创建线程,您只能借助并行结构来做到这点。为了简捷起见,您可以将并行结构和“for”结构放在一个制导语句中。
#pragma omp parallel for
此举可创建一个线程组,以便执行紧随其后的循环迭代。
该循环迭代必须是独立的,因此不论迭代的执行顺序如何,或是究竟由哪些线程执行循环的哪些迭代部分,循环结果都将相同。如果一条线程写入变量,另一条线程读取变量,那么将产生循环传递相关性 (loop-carried dependence),程序也将生成错误的结果。编程人员必须仔细分析循环体,以确保没有任何循环传递相关性的发生。在很多情况下,循环传递相关性来源于保存中间结果(用于指定的循环迭代)的变量。在此情况下,您可以通过声明每条线程都将具有自己的变量值,以除去循环传递相关性。这可通过私有子句来实现。例如,如果循环使用名为“tmp”的变量来保存临时值,那么您可将以下子句添加到 OpenMP 结构中,这样它便可用于循环体内部,而不会造成任何循环传递相关性。
private(tmp)
另一种常见情况是循环内出现变量,并用于从每个迭代中累积数值。例如,您可以利用循环将某项计算的所有结果进行求和,得出一个数值。这在并行编程中十分常见,通常被称为“规约”。在 OpenMP 中,我们的规约子句表示为
reduction(+:sum)
同私有子句一样,该子句可添加到 OpenMP 结构中,用以提示编译器等待规约。这时,程序便会创建一个临时私有变量,以便为每条线程计算累积操作的部分结果。当该结构运行到最后时,每条线程的值将结合起来产生最终答案。用于该规约的操作在子句中同样进行了详细说明。在这种情况下,此操作为“+”。根据对遭受质疑的数学操作进行特性识别,OpenMP可定义用于规约的私有变量值。例如,对于“+”来说,该值为零。
当然,OpenMP 还有更为复杂的情况,但是借助这两个结构和两个子句,我们便能够解释如何并行 π 程序。
OpenMP π 程序
为了简单起见,我们将统一规范所需的步骤,并且只使用默认数量的线程进行工作。在串行 π 程序中,还有一个需要并行的单循环。除因变量“x”和累积变量“sum”之外,该循环的迭代完全独立。请注意,“x”在此用于计算一个循环迭代内的临时存储。因此,我们可以通过一个私有子句将该变量定位到每条线程,以便对其进行处理
private(x)
从技术层面上讲,循环控制索引可创建一个循环传递相关性。但是,OpenMP 却认为该循环控制索引需要定位到每条线程之中,以使其自动实现对所有线程的私有化。
累积变量“sum”用于计算总和。这是一个经典规约,因此我们可以使用规约子句:
reduction(+:sum)
将这些子句添加到“parallel for”结构中,我们便借助 OpenMP 完成了 π 程序的并行。
请注意,我们的 OpenMP 中同样包括标准的 include file
#include <omp.h>
这规定了编程人员有时需要的 OpenMP 类型和运行时程序库例程。请注意,在此程序中,我们虽没有利用该语言的这些特性,但是将 OpenMP include file 包括在内却是一个很好的想法,以避免日后在程序需要时进行修改。
MPI
MPI,或者讯息传递界面(Message Passing Interface)是我们当今使用的一种最久的并行编程 API。MPI 程序作为一系列独立进程,主要通过收发讯息实现互动。MPI 的一大优势便是它只占用并行计算机中的极少部分硬件。它的唯一要求,便是处理器或内核共享同一简单网络,从而在任何进程组间充分地路由讯息。这也支持 MPI 在任何通用并行系统上运行——不论从对称多处理器到分布内存,还是从大规模并行超级计算机到各种集群。
MPI 诞生于 20 世纪 90 年代早期,那时集群刚刚兴起,大规模并行处理器(MPP)占据着高性能计算的大壁江山。每个 MPP 厂商都有自己的讯息传递符号。厂商很乐意看到这种状况,因为这样可以将用户锁定在自己的产品线内,但这却让编程人员大伤脑筋。因为软件的寿命远比硬件要长。再者,由于没有一种可移植性符号,所以,每次在研发新电脑时,应用编程人员都不得不将其软件从一种讯息传递符号转换为另一种,耗费了大量精力。
MPI 并不是第一款可移植性讯息传递库,但却是第一款由行业/国家实验室/学术人员联合创建的程序库。MPI 的创建过程集合了几乎所有的业内主要力量,并迅速成为高性能计算领域的标准讯息传递界面。现在,MPI 已走过了大约 15 个春秋,但它仍是高性能计算领域并行编程应用中应用最为常用的符号。
目前,大多数 MPI 程序均使用单程序多数据或 SPMD 模式 [mattson05]。它的原理非常简单:所有处理单元(PE)运行同一程序。并且它们都具有一个独特的整数 ID,以确定其在处理单元集内的排序。这样,程序便可利用该排序分配工作,并决定由哪个 PE 处理哪部分工作。换句话说,程序只有一个,但是由于根据 ID 所做的选择多种多样,因而,PE 间的数据也就有可能不同;即“单程序,多数据”模式。
MPI 概述
MPI 是一个可靠实用的讯息传递系统,它专为支持广泛的硬件而设计,并可支持带有完整模块化设计的复杂软件架构。
MPI 的设计理念基于通信子(communicator)。在创建一套进程时,它们可以定义群组。这样,进程组便可共享通信环境,从而更好地进行通信。这种进程组与通信环境的结合,可以定义一个独特的通信子。当您考虑在程序中使用程序库时,这一概念的力量就会凸现出来。如果程序员不细心,那么程序库开发人员创建的信息便可能与程序中用来调用程序库的信息发生干扰。但是通过通信子,程序库开发人员便可创建自己的通信环境,并确保就通过系统传递的相关信息而言,程序库的内部活动不会对其造成干扰。
当 MPI 程序启动时,将创建默认的通信子 MPI_COMM_WORLD。该通信子作为第一个参数被传递到每个 MPI 例程中。其它的参数则用来定义信息来源,以及定义保存信息的缓冲。在这种情况下,MPI 例程就会返回一个整数值作为错误参数,以查询例程执行期间出现的所有问题。
MPI 程序通常在靠近开始的位置上设置三个例程的调用,以设置 MPI 的使用方式。
int my_id, numprocs;
MPI_Init(&argc, &argv) ;
MPI_Comm_Rank(MPI_COMM_WORLD, &my_id) ;
MPI_Comm_Size(MPI_COMM_WORLD, &numprocs) ;
第一个例程(MPI_Init)用来输入任何 C 程序人员都熟悉的命令行参数,并初始化 MPI 环境。后两个例程用作输入 MPI 通信子(本例中为默认的通信子),并返回调用的进程排序(rank)和进程总数。这里,排序作为该进程的唯一标识符,可从 0 一直排到进程数量减去 1。
关于创建多少进程以及它们将在哪些处理器上运行的详细信息,都被置于 MPI 应用编程接口之外。由于支持 MPI 的平台各异,所以需要使用不同的方法。在大多数情况下,会有一个主机文件按名称顺序列出每颗处理器的信息。这些信息将传递给可用于大多数 MPI 平台的常见 shell script(称为 mpirun),以启动 MPI 程序。鉴于这一简单进程的详情在不同平台上表现各异,我们在这里就不进行讨论了。
在每个 MPI 程序的最后都应该有一个例程来关闭环境。此函数将返回整数值错误代码。
int MPI_Finalize();
MPI 程序运行于这些例程之间。程序的大部分都是由规则的串行代码组成,并以您所选择的语言来表示。正如上文所述,虽然每个进程都在执行相同的代码,但是程序行为却视进程排序而有所不同。并且在进程间需要通信或其它交互的点上,还插入了 MPI 例程。MPI 的第一版上即有超过 120 个例程,最新版本(MPI 2.0)的例程数量更为庞大。但是,大多数程序仅使用 MPI 函数中一个很小的子集。因此,我们只讨论一个程序;一个执行规约并将最终规约结果返回组中各进程的例程。
int MPI_Reduce(void* sendbuf, void* recvbuf,
int count, MPI_Datatype datatype, MPI_OP op,
int root, MPI_COMM comm.)
此函数采用缓冲“sendbuf”中类型为“datatype”的“count”值,并用“op”操作来累积每个进程的结果,最后将结果置于排序为“root”的进程的“recvbuf”缓冲中。借此,MPI_Datatype 和 MPI_OP 就可直观地获得期望的数值,如“MPI_DOUBLE”或“MPI_SUM”。
此外,借助MPI 广播讯息(MPI_Bcast)中的其它常用例程,还可定义“barrier”同步点(MPI_Barrier)、发送讯息(MPI_Send)或接收讯息(MPI_Recv)。您可以通过在线方式,或在 [mpi] 及 [mattson05] 中了解有关 MPI 的更多详情。
MPI π 程序
MPI π 程序是对最初串行代码进行的直接修改。为了简便起见,我们将继续在程序本身中设定步骤数量,而不是输入数值然后广播其它进程。
程序以 MPI include 文件开始,以定义 MPI 中的数据类型、常量以及各种例程。在此之后,我们添置了标准的 3 个例程,以初始化 MPI 环境并将基本参数(进程的数量和排序)用于程序中。
最后,我们利用一种常用技巧将进程集合中的循环迭代进行划分。请注意,这里的循环极限已经改变,它从运行每个进程的 ID,变为了随着组中进程数量而递增的迭代数量。这是由于 MPI 中被定义的排序用作了 ID,并且排序数量可以从 0 一直到进程数量减去 1。本质上,这种简单的转换是以循环方式将循环迭代加以分配,就像我们将一副纸牌分配到不同进程中一样。
每个进程完成后,部分总和会把得到的部分结果纳入变量“sum”中,这种规约主要包含在以下调用中:
MPI_Reduce(&sum, &pi, 1, MPI_DOUBLE, MPI_SUM, 0,
MPI_COMM_WORLD) ;
相比我们上文所讨论的 MPI_Reduce 定义,这里每个参数的意义应该更加清晰。我们正在使用的部分和中,“sum”作为发送缓冲,变量“pi”作为接收缓冲。根据 MPI_Reduce 例程的第六个参数,这一数值将到达排序为“0”的进程中。“发送缓冲”包含一个带有附加累积操作(即 MPI_SUM)的 MPI_DOUBLE类型数值。最后,涉及到这一规约操作的进程会使用通信子 MPI_COMM_WORLD进行运作。
Java 线程概述
Java 语言在设计之初便内置了多线程支持。作为Java 技术的一个重要组成部分,线程在语言(语法)级别和 Java 虚拟机,以及类别库(class library)级别上均能够得到支持。Java 线程与 POSIX pthread有很多相似之处。Java 类别库提供的线程类别可以支持丰富的方法集,用以启动、运行或停止线程,并检查线程的状态。
Java 的线程支持包括一套复杂的基于监控和条件变量的同步原语。在语言级别上,类别库或声明为同步的代码块中的方法并不同时运行。这种方法或模块在监控器的控制下运行,有助于确保在这些方法或模块中存取的数据能够始终处于一致状态。所有的 Java 对象都有自己的监控器,通常在第一次使用时由 JVM 展示并激活。监控器的作用非常类似于 pthread 中定义的条件变量对和 mutex。但是与 for pthread 不同的是,Java 线程处于等待状态时可能被打断,例如当它等待事件通知或在 I/O 调用过程中被拦截时就时常会发生这种情况。
Java 线程 π 程序
在这一简单的范例中,我们展示了如何借助“简单”Java 线程编写并行版 π 程序:
在 Java 中启动全新线程时,我们通常会将 Thread 类细分,并定义一个定制的 run() 方法,以保证主要工作能够并行完成。在我们的范例中,此项工作可在 PITask 类的 run() 方法中实施。出于性能原因,整个积分范围被分为 part_step 片断,这些片段的数量等于可用处理器的数量。PITask 对象通过 part_number(代表积分范围中的一个片断)实现参数化;这样,run() 的主体就在选定的积分附属范围内计算部分和。当调用 start() 方法时,实际的线程同时开始启动并并发执行。我们可在面向所有附属范围的循环中进行此项工作。然后,我们运行第二个循环,通过调用其 join() 方法,等待每条衍生线程 (spawned thread) 的完成,并接着对每条线程得出的结果进行总结。在本例中,每个积分范围都明确地映射到一条单独的 Java 线程中。
本例为我们明确创建了 Java 线程,因而,我们不得不将积分范围分成多个部分,以便在线程之间手动进行工作分区。也许人们认为这样做非常繁琐,但是如果不用这种方法,而去创建与积分范围中的步骤数量相同的线程,那么我们将会发现程序的表现实在让人无法接受。这是因为通常情况下,创建 Java 线程是一项非常昂贵的工程。
Java 并发模型 FJTask 框架
上文中所提到的“简单”Java 线程只是 Java 多线程支持的最低级别;还有很多更高级别的线程库,旨在增强 Java 多线程功能性的基本水平,并为一些常用任务增添解决方案。从 Java 标准 1.5起便开始提供的 java.util.concurrent 程序包是一个值得我们注意的范例。该程序包包括针对基本 Java 线程的诸多增强特性,如线程池支持、atomic 变量,以及复杂的同步原语等。但是,util.concurrent 程序包的一些片断不符合 J2SE 标准,因而它仍旧只能作为独立程序库使用(称为 EDU.oswego.cs.dl.util.concurrent)。在此之中,最重要的遗失部分就是 FJTask 框架,它针对Java采用了一种 fork-join 并行概念,旨在对定积分或矩阵乘法运算等计算密集型计算实现并行处理。FJTask 是对 Thread简单的直接模拟。它通常是指“基于任务的”并行,而不是“基于线程的”并行。FJTasks 往往在同一个 Java 线程池上执行。它还支持 Thread 等级中许多最为常见的方法,包括 start()、yield() 和 join()。
FJTask 不支持优先控制(priority control)等一些 Java 线程方法。因而,它的主要经济优势就在于它不支持任何类型的拦截操作。没有任何因素能够阻止 FJTask 中的拦截,并且极其短暂的等待/拦截也能执行得非常好。FJTask 并非设计用于支持任意同步,因为一旦开始执行,就没有任何方式能够暂挂和恢复独立任务的执行。在持续运行期间,FJTasks 也应该是有限的,而且不应该包含无限循环。FJTask 应顺利地完成运行,不应要求等待或运行阻拦式 IO。因而FJTask 和 Thread 之间存在着很大的成本差距。至少运行于 JVM 上时,FJTask可以在高性能垃圾回收(所有 FJTask 迅速成为垃圾)和良好的本地线程支持下,比 Thread 快 2 至 3 个数量级。
Java 并发模型 FJTask pi 程序
在以下范例中,我们展示了如何在 FJTask 框架的帮助下编写 PI 程序:
首先,与上一范例中的做法类似,我们为 PITask 类设立一个 run() 方法。但是这样,PITask 仅会计算出对应 i-th 步骤的单一值 x,而不是积分附属范围的部分和。随后,我们创建了 PITask 对象阵列,使用 FJTask.Par 对象进行打包,并通过调用 FJTaskRunnerGroup 对象上的 invoke() 加以执行。使用 FJtask.Par 对象进行打包,可指导框架并行执行线程池上的基本任务阵列(我们已将该线程池的线程数量设置为处理器的数量)。此范例中的 invoke() 方法必须等到完成阵列中的所有任务时才可使用。这就使得我们能够通过每个具体任务中所得出的独立和数值,来立即计算出总和。
请注意:本 Java π 程序小修订版不会明确创建任何线程,也不会在线程与任务之间划分任何的工作分区。然而,大家可能已注意到,即使与上文我们在线程之间进行明确工作分区的范例相比,它的执行情况仍然非常好。这是因为所有全新 FJtask 的创建与异步执行几乎都能像调用一种方法那样快速。但是,为了获得最佳性能,我们仍然建议您为每个 FJTask 对象适当分配大量工作,以确保这些对象数量的可管理性。这将有助于减少 JVM 内垃圾收集器(garbage collector)的压力。
选择并行编程符号
在本文中,我们已经谈及了众多并行编程的常用符号。文中使用的程序非常简单——甚至可能过于简单了。但是,我们希望您能从如此简单的举例中很好地了解所有的并行编程符号。
这些并行编程符号在复杂性、变更时需要的原始串行程序数量、以及共同使用时可能发生的错误等方面各不相同。鉴于您所倾向使用的并行算法类型的特性,建议您将所有这些因素都考虑在内。此外,您还需考虑:
可移植性:您需要支持哪些平台?MPI 如此流行的原因之一就是它在哪里都能够运行。但是如果您只计划支持具有共享地址空间的硬件,那么基于线程的符号也许更加合适。
性能:可管理的运行时和高级运行时环境大大减轻了程序员的负担。由于创建和维护软件需要较高成本,因此这些优势对您来说非常重要。但是这些优势也是需要成本的。由于低级 API (如 windows 线程、Pthreads 或 MPI)中的硬件直接面向程序员,所以需要更细致的优化。如果所有可用的内核都需要扩充,那么这样的优化就非常重要。
串行与并行产品发布:软件拥有较长的使用周期。成功的软件开发人员所支持的产品都会拥有较长的使用期限。因此,将软件的串行和并行版本保存在一个源代码树中就会显得十分重要。如果并行编程符号需要对软件进行大量改写以支持并行,那么这就很难做到。
熟悉程度:学习一门新的语言是非常困难的。此外,当多位开发人员学习一门不熟悉的语言时,成本也可能会非常昂贵。因此,如果并行符号是一种熟悉的串行语言的扩展,意义就会非常重大。
测试:软件产品必须进行广泛的测试。在专业开发环境中,测试的成本很容易在最初便超过创建软件的成本。这就是递增策略在并行编程(通常使用 OpenMP)中的优势所在。借助递增并行,每当添加一个结构时,开发人员便可测试结果,并确保结果仍然与最初的串行代码保持一致。
参考资料
[omp] OpenMP 应用编程接口 2.5 版,www.openmp.org
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
,2005 年
[Mattson05] Timothy G. Mattson、Beverly A. Sanders、Berna L. Massingill,《并行编程模式》,Addison Wesley 出版社,2005 年
[mpi] William Gropp、Ewing Lusk、Anthony Skjellum,“使用 MPI”,麻省理工学院出版社,1994 年
[JLS] Java 语言规范,http://java.sun.com/docs/books/jls
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
[JVMS] Java 虚拟机规范,http://java.sun.com/docs/books/jvms/
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
[JavaAPI] java.lang.Thread 规范,http://java.sun.com/javase/6/docs/api/java/lang/Thread.html
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
[JSR166] Doug Lea 的 util.concurrent Fork-Join 任务框架,http://gee.oswego.edu/dl/classes/EDU/oswego/cs/dl/util/concurrent/FJTask.html
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
作者简介
![](http://softwarecommunity-zho.intel.com/UserFiles/en-us/Image/1252/1252b.gif)
Tim Mattson 是一位并行程序员。在过去 20 年中,他曾利用并行计算机来制造化学反应、重新组合蛋白质、寻找石油、分析基因,以及解决众多其它科学问题。Tim 还决心在当前应用编程人员大都编写并行软件之时,开发一种比较少见的串行软件。多年来,他始终坚信寻找正确的并行编程环境是解决问题的关键所在。他尝试了无数种并行编程环境,并自行创建了一些新的环境(包括 OpenMP)。当这种方法被证实并没有他所期望的那样有效时,他迅速转换方式,并决定在人们被语言和软件工具困扰之前,帮助我们意识到需要了解专业程序员对并行编程的思考方式。为了解决这个问题,Tim 花了五年多的时间,与他人合作,共同为并行编程开发设计模式语言(《并行编程模式》,Addison Wesley 出版社,2004 年)。Tim 目前效力于英特尔,并在英特尔企业技术事业部的应用研究实验室中继续进行着并行应用编程问题的研究。
Andrey Y Chernyshev 是英特尔公司企业解决方案软件部门的一名软件工程师。他目前在俄罗斯工作,联系方式为:andrey.y.chernyshev@intel.com。
发布日期: 2007年4月12日 | 最后修改日期: 2008年1月8日
简介
并行编程曾一度是顶级编程人员面临大型超级计算问题困扰时的唯一关注点。但是,随着面向主流应用的多核处理器的出现,并行编程现已成为所有专业软件开发人员都必须了解和掌握的一项技术。
并行编程也许会很难,但事实上,它只是“不一样”而已,并非“很难”。它不仅具有更为传统的串行编程的所有特点,还包含三个定义完备的附加步骤:
识别并发:分析问题,以识别能够同时执行的任务。
揭露并发:重新构建一个问题,确保高效完成任务。此步骤通常需要确定任务本身与组织源代码之间的依赖程度,以便任务得到高效管理。
表达并发:在源代码中使用并行编程符号来表示并行算法。
以上的每一步都很重要。前两步在最近一本关于并行编程设计模式的书中有详细介绍 [mattson05]。本文将重点介绍第三步:在源代码中使用并行编程符号来表示并行算法。这种符号可以是并行编程语言,也可以是通过程序库界面实施的应用编程接口 (API),或是添加到现有序列语言的语言扩展。
选择一种独特的并行编程符号绝非易事。这些符号的学习难度参差不齐,而且极其耗时。因此,掌握几种符号并从中选择一种进行运用的做法是不切实际的。编程人员所需的是一种能够充分详细地了解不同符号的“优势”,以及各项高级特征的快捷方法,以便明智地决定学习哪种符号。
在本文中,我们将对几种不同的并行编程符号进行高度全面概述,重点介绍其主要使用方法,并对它们的特殊优势和缺陷进行分析。我们将具体介绍以下几种符号:
OpenMP:针对简单并行编程的编译器指示
MPI:支持超高性能便携性的程序库例程
Java:基于领先对象的编程语言并行
为了使我们的讨论尽可能地具体详实,我们在每种情况下都部署了一款知名的 π 程序的并行版本。这是一种利用梯形法则(其中被积函数和积分极限被选中)来运算的简单数值积分,因此从数学角度讲,正确的答案为 π。很多人都将它视为并行编程中最为基本的程序。在本文最后,我们还将简单介绍如何选择一款并行编程符号来使用并掌握。
π 程序:并行梯形积分
在微积分研究中,我们了解到一个积分在几何上可以表示为曲线下面积。也就是说,积分的近似值可以通过计算得出。我们先将积分面积划分为许多的梯级,并在每一梯级内画出一个矩形,并让它的高等于该梯级中心的被积函数值。那么这样,每个矩形面积的和就约等于该积分。
![](http://softwarecommunity-zho.intel.com/UserFiles/en-us/Image/1252/1252a.gif)
如图 1所示:梯形积分——每一竖条都拥有固定宽度的“梯级”。每个竖条的高度便是被积函数的值。将所有竖条的面积加在一起便得出曲线下面积的近似值,即被积函数的值。
我们可以选择一个被积函数及积分极限,那么该积分在数值上便等于 π。这样便可更加直接地进行程序正确性检验。下面我们向您展示了一个执行该算法的简单的 C 程序:
static long num_steps = 100000; double step; void main () { int i; double x, pi, sum = 0.0; step = 1.0/(double) num_steps; for (i=0;i<= num_steps; i++){ x = (i+0.5)*step; sum = sum + 4.0/(1.0+x*x); } pi = step * sum; }
OpenMP
OpenMP [omp] 是一款为共享内存计算机来编写并行应用程序的行业标准 API。OpenMP 的主要目的,是使高性能计算中常见的循环导向型程序更加易于编写。OpenMP 中的各种结构可支持 SPMD、Master worker、管线,以及大多数其他并行算法 [Mattson05]。
OpenMP 是一款非常成功的并行语言。市面上每款共享内存计算机均支持它的运行。最近,英特尔在 OpenMP 上创建了一个变量,从而实现了对集群的支持。OpenMP 还能逐步添加并行的编程方式,因此现有的顺序程序可发展为并行程序。但是,这一优势却也成了OpenMP 的最大缺陷。那是因为通过使用逐步并行,编程人员可能会错过某一程序的大规模重建,从而失去获得最大性能的机会。
OpenMP 的标准仍在不断演进。为此,一家名为“OpenMP 体系结构评审委员会 (OpenMP Architecture Review Board)”的行业团体定期举行会晤,共同开发该语言的全新扩展。OpenMP 的下一版本(3.0 版)将包含一种任务队列能力。这将支持 OpenMP 处理更广泛的控制结构,以及更多的一般递归算法。
OpenMP 简介
OpenMP 基于 fork-join 的编程模式而设计。OpenMP 程序起初以一条单线程的形式开始运行。如果编程人员希望在程序中利用并行,那么就需将额外的线程进行分支,以创建线程组。这些线程在称为“并行区域”的代码区域内并行执行。在并行区域末尾,将等待所有线程全部完成工作,并将其重新结合在一起。那时,最初线程或“主”线程将继续执行,直至遇到下一个并行区域(或程序结束)。
OpenMP 的语言结构根据编译器指示而定义,可为编译器布置任务,以实施理想的并行。在 C 和 C++ 中,这些指示根据制导语句来定义。
OpenMP 制导语句在任何情况下的形式都相同
#pragma omp construct_name one_or_more_clauses
其中“construct_name”规定了编程人员希望执行的并行动作,而“clauses”则对该动作进行修改,或对线程所见的数据环境进行控制。
OpenMP 是一种显式的并行编程语言。一旦线程创建,或者工作已经映射到该线程上,那么编程人员必须对希望执行的动作加以详述。因此,即使是 OpenMP 这样简单的 API 也有诸多结构和子句需要编程人员学习。所幸的是,仅利用整个 OpenMP 语言的一小部分子集,便可完成大量上述工作。
可利用“parallel”结构在 OpenMP 中创建线程。
#pragma omp parallel { …. A block of statements }
独自使用时(没有修改任何子句),程序可创建出一系列线程供运行时环境选择(这些线程通常与处理器或内核的数量相等)。每条线程都将根据并行制导语句来执行语句块。该语句块可以是 C 中的任意合法语句组,但是唯一例外的是:您不能分支到并行语句块之中或之外。您只要稍微思考一下就能明白。如果线程要全面执行语句组,并且该程序的继发行为还要有意义,那么您便不能随意将线程分支到并行区域内的结构之中或之外。这是 OpenMP 的一项公共约束。我们将这种缺乏某些分支的语句块称为“结构块”。
您可以令所有线程执行相同的语句,从而进行大量的并行编程。但是要体验 OpenMP 的全部功能,我们要做的就不止这些。我们需要在多条线程之间共享执行语句集的工作。我们将这种方式称为“工作共享”。最常见的工作分享结构是循环结构,在 C 中即为“for”循环
#pragma omp for
但是,这一结构仅对具有规范形式的简单循环起作用
for(i=lower_limit; i<upper_limit; inc_exp)
“for”结构执行循环的迭代,并将其打包至那些利用并行结构创建的早期线程组中。循环极限和循环索引 (inc_exp) 的递增表达式需在编译时完全确定,并且这些符号中使用的任何恒量都必须在线程组中保持相同。您只要思考一下就能明白。系统需要得出循环的迭代数量,然后将其映射到能够分发至线程组的集上。如果所有线程均计算相同的索引集,那么上述工作只有通过持续稳定的方式才能实现。
请注意,“for”结构并不能创建线程,您只能借助并行结构来做到这点。为了简捷起见,您可以将并行结构和“for”结构放在一个制导语句中。
#pragma omp parallel for
此举可创建一个线程组,以便执行紧随其后的循环迭代。
该循环迭代必须是独立的,因此不论迭代的执行顺序如何,或是究竟由哪些线程执行循环的哪些迭代部分,循环结果都将相同。如果一条线程写入变量,另一条线程读取变量,那么将产生循环传递相关性 (loop-carried dependence),程序也将生成错误的结果。编程人员必须仔细分析循环体,以确保没有任何循环传递相关性的发生。在很多情况下,循环传递相关性来源于保存中间结果(用于指定的循环迭代)的变量。在此情况下,您可以通过声明每条线程都将具有自己的变量值,以除去循环传递相关性。这可通过私有子句来实现。例如,如果循环使用名为“tmp”的变量来保存临时值,那么您可将以下子句添加到 OpenMP 结构中,这样它便可用于循环体内部,而不会造成任何循环传递相关性。
private(tmp)
另一种常见情况是循环内出现变量,并用于从每个迭代中累积数值。例如,您可以利用循环将某项计算的所有结果进行求和,得出一个数值。这在并行编程中十分常见,通常被称为“规约”。在 OpenMP 中,我们的规约子句表示为
reduction(+:sum)
同私有子句一样,该子句可添加到 OpenMP 结构中,用以提示编译器等待规约。这时,程序便会创建一个临时私有变量,以便为每条线程计算累积操作的部分结果。当该结构运行到最后时,每条线程的值将结合起来产生最终答案。用于该规约的操作在子句中同样进行了详细说明。在这种情况下,此操作为“+”。根据对遭受质疑的数学操作进行特性识别,OpenMP可定义用于规约的私有变量值。例如,对于“+”来说,该值为零。
当然,OpenMP 还有更为复杂的情况,但是借助这两个结构和两个子句,我们便能够解释如何并行 π 程序。
OpenMP π 程序
为了简单起见,我们将统一规范所需的步骤,并且只使用默认数量的线程进行工作。在串行 π 程序中,还有一个需要并行的单循环。除因变量“x”和累积变量“sum”之外,该循环的迭代完全独立。请注意,“x”在此用于计算一个循环迭代内的临时存储。因此,我们可以通过一个私有子句将该变量定位到每条线程,以便对其进行处理
private(x)
从技术层面上讲,循环控制索引可创建一个循环传递相关性。但是,OpenMP 却认为该循环控制索引需要定位到每条线程之中,以使其自动实现对所有线程的私有化。
累积变量“sum”用于计算总和。这是一个经典规约,因此我们可以使用规约子句:
reduction(+:sum)
将这些子句添加到“parallel for”结构中,我们便借助 OpenMP 完成了 π 程序的并行。
#include <omp.h> static long num_steps = 100000; double step; void main () { int i; double x, pi, sum = 0.0; step = 1.0/(double) num_steps; #pragma omp parallel for private(x) reduction(+:sum) for (i=0;i<= num_steps; i++){ x = (i+0.5)*step; sum = sum + 4.0/(1.0+x*x); } pi = step * sum; }
请注意,我们的 OpenMP 中同样包括标准的 include file
#include <omp.h>
这规定了编程人员有时需要的 OpenMP 类型和运行时程序库例程。请注意,在此程序中,我们虽没有利用该语言的这些特性,但是将 OpenMP include file 包括在内却是一个很好的想法,以避免日后在程序需要时进行修改。
MPI
MPI,或者讯息传递界面(Message Passing Interface)是我们当今使用的一种最久的并行编程 API。MPI 程序作为一系列独立进程,主要通过收发讯息实现互动。MPI 的一大优势便是它只占用并行计算机中的极少部分硬件。它的唯一要求,便是处理器或内核共享同一简单网络,从而在任何进程组间充分地路由讯息。这也支持 MPI 在任何通用并行系统上运行——不论从对称多处理器到分布内存,还是从大规模并行超级计算机到各种集群。
MPI 诞生于 20 世纪 90 年代早期,那时集群刚刚兴起,大规模并行处理器(MPP)占据着高性能计算的大壁江山。每个 MPP 厂商都有自己的讯息传递符号。厂商很乐意看到这种状况,因为这样可以将用户锁定在自己的产品线内,但这却让编程人员大伤脑筋。因为软件的寿命远比硬件要长。再者,由于没有一种可移植性符号,所以,每次在研发新电脑时,应用编程人员都不得不将其软件从一种讯息传递符号转换为另一种,耗费了大量精力。
MPI 并不是第一款可移植性讯息传递库,但却是第一款由行业/国家实验室/学术人员联合创建的程序库。MPI 的创建过程集合了几乎所有的业内主要力量,并迅速成为高性能计算领域的标准讯息传递界面。现在,MPI 已走过了大约 15 个春秋,但它仍是高性能计算领域并行编程应用中应用最为常用的符号。
目前,大多数 MPI 程序均使用单程序多数据或 SPMD 模式 [mattson05]。它的原理非常简单:所有处理单元(PE)运行同一程序。并且它们都具有一个独特的整数 ID,以确定其在处理单元集内的排序。这样,程序便可利用该排序分配工作,并决定由哪个 PE 处理哪部分工作。换句话说,程序只有一个,但是由于根据 ID 所做的选择多种多样,因而,PE 间的数据也就有可能不同;即“单程序,多数据”模式。
MPI 概述
MPI 是一个可靠实用的讯息传递系统,它专为支持广泛的硬件而设计,并可支持带有完整模块化设计的复杂软件架构。
MPI 的设计理念基于通信子(communicator)。在创建一套进程时,它们可以定义群组。这样,进程组便可共享通信环境,从而更好地进行通信。这种进程组与通信环境的结合,可以定义一个独特的通信子。当您考虑在程序中使用程序库时,这一概念的力量就会凸现出来。如果程序员不细心,那么程序库开发人员创建的信息便可能与程序中用来调用程序库的信息发生干扰。但是通过通信子,程序库开发人员便可创建自己的通信环境,并确保就通过系统传递的相关信息而言,程序库的内部活动不会对其造成干扰。
当 MPI 程序启动时,将创建默认的通信子 MPI_COMM_WORLD。该通信子作为第一个参数被传递到每个 MPI 例程中。其它的参数则用来定义信息来源,以及定义保存信息的缓冲。在这种情况下,MPI 例程就会返回一个整数值作为错误参数,以查询例程执行期间出现的所有问题。
MPI 程序通常在靠近开始的位置上设置三个例程的调用,以设置 MPI 的使用方式。
int my_id, numprocs;
MPI_Init(&argc, &argv) ;
MPI_Comm_Rank(MPI_COMM_WORLD, &my_id) ;
MPI_Comm_Size(MPI_COMM_WORLD, &numprocs) ;
第一个例程(MPI_Init)用来输入任何 C 程序人员都熟悉的命令行参数,并初始化 MPI 环境。后两个例程用作输入 MPI 通信子(本例中为默认的通信子),并返回调用的进程排序(rank)和进程总数。这里,排序作为该进程的唯一标识符,可从 0 一直排到进程数量减去 1。
关于创建多少进程以及它们将在哪些处理器上运行的详细信息,都被置于 MPI 应用编程接口之外。由于支持 MPI 的平台各异,所以需要使用不同的方法。在大多数情况下,会有一个主机文件按名称顺序列出每颗处理器的信息。这些信息将传递给可用于大多数 MPI 平台的常见 shell script(称为 mpirun),以启动 MPI 程序。鉴于这一简单进程的详情在不同平台上表现各异,我们在这里就不进行讨论了。
在每个 MPI 程序的最后都应该有一个例程来关闭环境。此函数将返回整数值错误代码。
int MPI_Finalize();
MPI 程序运行于这些例程之间。程序的大部分都是由规则的串行代码组成,并以您所选择的语言来表示。正如上文所述,虽然每个进程都在执行相同的代码,但是程序行为却视进程排序而有所不同。并且在进程间需要通信或其它交互的点上,还插入了 MPI 例程。MPI 的第一版上即有超过 120 个例程,最新版本(MPI 2.0)的例程数量更为庞大。但是,大多数程序仅使用 MPI 函数中一个很小的子集。因此,我们只讨论一个程序;一个执行规约并将最终规约结果返回组中各进程的例程。
int MPI_Reduce(void* sendbuf, void* recvbuf,
int count, MPI_Datatype datatype, MPI_OP op,
int root, MPI_COMM comm.)
此函数采用缓冲“sendbuf”中类型为“datatype”的“count”值,并用“op”操作来累积每个进程的结果,最后将结果置于排序为“root”的进程的“recvbuf”缓冲中。借此,MPI_Datatype 和 MPI_OP 就可直观地获得期望的数值,如“MPI_DOUBLE”或“MPI_SUM”。
此外,借助MPI 广播讯息(MPI_Bcast)中的其它常用例程,还可定义“barrier”同步点(MPI_Barrier)、发送讯息(MPI_Send)或接收讯息(MPI_Recv)。您可以通过在线方式,或在 [mpi] 及 [mattson05] 中了解有关 MPI 的更多详情。
MPI π 程序
MPI π 程序是对最初串行代码进行的直接修改。为了简便起见,我们将继续在程序本身中设定步骤数量,而不是输入数值然后广播其它进程。
程序以 MPI include 文件开始,以定义 MPI 中的数据类型、常量以及各种例程。在此之后,我们添置了标准的 3 个例程,以初始化 MPI 环境并将基本参数(进程的数量和排序)用于程序中。
#include <mpi.h> static long num_steps = 100000; void main (int argc, char *argv[]) { int i, my_id, numprocs; double x, pi, step, sum = 0.0 ; step = 1.0/(double) num_steps ; MPI_Init(&argc, &argv) ; MPI_Comm_Rank(MPI_COMM_WORLD, &my_id) ; MPI_Comm_Size(MPI_COMM_WORLD, &numprocs) ; my_steps = num_steps/numprocs ; for (i=my_id; i<num_steps; i+numprocs) { x = (i+0.5)*step; sum += 4.0/(1.0+x*x); } sum *= step ; MPI_Reduce(&sum, &pi, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD) ; MPI_Finalize(ierr); }
最后,我们利用一种常用技巧将进程集合中的循环迭代进行划分。请注意,这里的循环极限已经改变,它从运行每个进程的 ID,变为了随着组中进程数量而递增的迭代数量。这是由于 MPI 中被定义的排序用作了 ID,并且排序数量可以从 0 一直到进程数量减去 1。本质上,这种简单的转换是以循环方式将循环迭代加以分配,就像我们将一副纸牌分配到不同进程中一样。
每个进程完成后,部分总和会把得到的部分结果纳入变量“sum”中,这种规约主要包含在以下调用中:
MPI_Reduce(&sum, &pi, 1, MPI_DOUBLE, MPI_SUM, 0,
MPI_COMM_WORLD) ;
相比我们上文所讨论的 MPI_Reduce 定义,这里每个参数的意义应该更加清晰。我们正在使用的部分和中,“sum”作为发送缓冲,变量“pi”作为接收缓冲。根据 MPI_Reduce 例程的第六个参数,这一数值将到达排序为“0”的进程中。“发送缓冲”包含一个带有附加累积操作(即 MPI_SUM)的 MPI_DOUBLE类型数值。最后,涉及到这一规约操作的进程会使用通信子 MPI_COMM_WORLD进行运作。
Java 线程概述
Java 语言在设计之初便内置了多线程支持。作为Java 技术的一个重要组成部分,线程在语言(语法)级别和 Java 虚拟机,以及类别库(class library)级别上均能够得到支持。Java 线程与 POSIX pthread有很多相似之处。Java 类别库提供的线程类别可以支持丰富的方法集,用以启动、运行或停止线程,并检查线程的状态。
Java 的线程支持包括一套复杂的基于监控和条件变量的同步原语。在语言级别上,类别库或声明为同步的代码块中的方法并不同时运行。这种方法或模块在监控器的控制下运行,有助于确保在这些方法或模块中存取的数据能够始终处于一致状态。所有的 Java 对象都有自己的监控器,通常在第一次使用时由 JVM 展示并激活。监控器的作用非常类似于 pthread 中定义的条件变量对和 mutex。但是与 for pthread 不同的是,Java 线程处于等待状态时可能被打断,例如当它等待事件通知或在 I/O 调用过程中被拦截时就时常会发生这种情况。
Java 线程 π 程序
在这一简单的范例中,我们展示了如何借助“简单”Java 线程编写并行版 π 程序:
public class PI1 { static long num_steps = 100000; static double step; static double sum = 0.0; static int part_step; static class PITask extends Thread { int part_number; double x = 0.0; double sum = 0.0; public PITask(int part_number) { this.part_number = part_number; } public void run() { for (int i = part_number; i < num_steps; i += part_step) { x = (i + 0.5) * step; sum += 4.0 / (1.0 + x * x); } } } public static void main(String[] args) { int i; double pi; step = 1.0 / (double) num_steps; part_step = Runtime.getRuntime().availableProcessors(); PITask[] part_sums = new PITask[part_step]; for (i = 0; i < part_step; i++) { (part_sums[i] = new PITask(i)).start(); } for (i = 0; i < part_step; i++) { try { part_sums[i].join(); } catch (InterruptedException e) { } sum += part_sums[i].sum; } pi = step * sum; System.out.println(pi); } }
在 Java 中启动全新线程时,我们通常会将 Thread 类细分,并定义一个定制的 run() 方法,以保证主要工作能够并行完成。在我们的范例中,此项工作可在 PITask 类的 run() 方法中实施。出于性能原因,整个积分范围被分为 part_step 片断,这些片段的数量等于可用处理器的数量。PITask 对象通过 part_number(代表积分范围中的一个片断)实现参数化;这样,run() 的主体就在选定的积分附属范围内计算部分和。当调用 start() 方法时,实际的线程同时开始启动并并发执行。我们可在面向所有附属范围的循环中进行此项工作。然后,我们运行第二个循环,通过调用其 join() 方法,等待每条衍生线程 (spawned thread) 的完成,并接着对每条线程得出的结果进行总结。在本例中,每个积分范围都明确地映射到一条单独的 Java 线程中。
本例为我们明确创建了 Java 线程,因而,我们不得不将积分范围分成多个部分,以便在线程之间手动进行工作分区。也许人们认为这样做非常繁琐,但是如果不用这种方法,而去创建与积分范围中的步骤数量相同的线程,那么我们将会发现程序的表现实在让人无法接受。这是因为通常情况下,创建 Java 线程是一项非常昂贵的工程。
Java 并发模型 FJTask 框架
上文中所提到的“简单”Java 线程只是 Java 多线程支持的最低级别;还有很多更高级别的线程库,旨在增强 Java 多线程功能性的基本水平,并为一些常用任务增添解决方案。从 Java 标准 1.5起便开始提供的 java.util.concurrent 程序包是一个值得我们注意的范例。该程序包包括针对基本 Java 线程的诸多增强特性,如线程池支持、atomic 变量,以及复杂的同步原语等。但是,util.concurrent 程序包的一些片断不符合 J2SE 标准,因而它仍旧只能作为独立程序库使用(称为 EDU.oswego.cs.dl.util.concurrent)。在此之中,最重要的遗失部分就是 FJTask 框架,它针对Java采用了一种 fork-join 并行概念,旨在对定积分或矩阵乘法运算等计算密集型计算实现并行处理。FJTask 是对 Thread简单的直接模拟。它通常是指“基于任务的”并行,而不是“基于线程的”并行。FJTasks 往往在同一个 Java 线程池上执行。它还支持 Thread 等级中许多最为常见的方法,包括 start()、yield() 和 join()。
FJTask 不支持优先控制(priority control)等一些 Java 线程方法。因而,它的主要经济优势就在于它不支持任何类型的拦截操作。没有任何因素能够阻止 FJTask 中的拦截,并且极其短暂的等待/拦截也能执行得非常好。FJTask 并非设计用于支持任意同步,因为一旦开始执行,就没有任何方式能够暂挂和恢复独立任务的执行。在持续运行期间,FJTasks 也应该是有限的,而且不应该包含无限循环。FJTask 应顺利地完成运行,不应要求等待或运行阻拦式 IO。因而FJTask 和 Thread 之间存在着很大的成本差距。至少运行于 JVM 上时,FJTask可以在高性能垃圾回收(所有 FJTask 迅速成为垃圾)和良好的本地线程支持下,比 Thread 快 2 至 3 个数量级。
Java 并发模型 FJTask pi 程序
在以下范例中,我们展示了如何在 FJTask 框架的帮助下编写 PI 程序:
import EDU.oswego.cs.dl.util.concurrent.FJTask; import EDU.oswego.cs.dl.util.concurrent.FJTaskRunnerGroup; public class PI2 { static int num_steps = 100000; static double step; static double sum = 0.0; static int part_step; static class PITask extends FJTask { int i = 0; double sum = 0.0; public PITask(int i) { this.i = i; } public void run() { double x = (i + 0.5) * step; sum += 4.0 / (1.0 + x * x); } } public static void main(String[] args) { int i; double pi; step = 1.0 / (double) num_steps; try { FJTaskRunnerGroup g = new FJTaskRunnerGroup(Runtime.getRuntime() .availableProcessors()); PITask[] tasks = new PITask[num_steps]; for (i = 0; i < num_steps; i++) { tasks[i] = new PITask(i); } g.invoke(new FJTask.Par(tasks)); for (i = 0; i < num_steps; i++) { sum += tasks[i].sum; } pi = step * sum; System.out.println(pi); System.out.println(Math.PI); } catch (InterruptedException ie) { } } }
首先,与上一范例中的做法类似,我们为 PITask 类设立一个 run() 方法。但是这样,PITask 仅会计算出对应 i-th 步骤的单一值 x,而不是积分附属范围的部分和。随后,我们创建了 PITask 对象阵列,使用 FJTask.Par 对象进行打包,并通过调用 FJTaskRunnerGroup 对象上的 invoke() 加以执行。使用 FJtask.Par 对象进行打包,可指导框架并行执行线程池上的基本任务阵列(我们已将该线程池的线程数量设置为处理器的数量)。此范例中的 invoke() 方法必须等到完成阵列中的所有任务时才可使用。这就使得我们能够通过每个具体任务中所得出的独立和数值,来立即计算出总和。
请注意:本 Java π 程序小修订版不会明确创建任何线程,也不会在线程与任务之间划分任何的工作分区。然而,大家可能已注意到,即使与上文我们在线程之间进行明确工作分区的范例相比,它的执行情况仍然非常好。这是因为所有全新 FJtask 的创建与异步执行几乎都能像调用一种方法那样快速。但是,为了获得最佳性能,我们仍然建议您为每个 FJTask 对象适当分配大量工作,以确保这些对象数量的可管理性。这将有助于减少 JVM 内垃圾收集器(garbage collector)的压力。
选择并行编程符号
在本文中,我们已经谈及了众多并行编程的常用符号。文中使用的程序非常简单——甚至可能过于简单了。但是,我们希望您能从如此简单的举例中很好地了解所有的并行编程符号。
这些并行编程符号在复杂性、变更时需要的原始串行程序数量、以及共同使用时可能发生的错误等方面各不相同。鉴于您所倾向使用的并行算法类型的特性,建议您将所有这些因素都考虑在内。此外,您还需考虑:
可移植性:您需要支持哪些平台?MPI 如此流行的原因之一就是它在哪里都能够运行。但是如果您只计划支持具有共享地址空间的硬件,那么基于线程的符号也许更加合适。
性能:可管理的运行时和高级运行时环境大大减轻了程序员的负担。由于创建和维护软件需要较高成本,因此这些优势对您来说非常重要。但是这些优势也是需要成本的。由于低级 API (如 windows 线程、Pthreads 或 MPI)中的硬件直接面向程序员,所以需要更细致的优化。如果所有可用的内核都需要扩充,那么这样的优化就非常重要。
串行与并行产品发布:软件拥有较长的使用周期。成功的软件开发人员所支持的产品都会拥有较长的使用期限。因此,将软件的串行和并行版本保存在一个源代码树中就会显得十分重要。如果并行编程符号需要对软件进行大量改写以支持并行,那么这就很难做到。
熟悉程度:学习一门新的语言是非常困难的。此外,当多位开发人员学习一门不熟悉的语言时,成本也可能会非常昂贵。因此,如果并行符号是一种熟悉的串行语言的扩展,意义就会非常重大。
测试:软件产品必须进行广泛的测试。在专业开发环境中,测试的成本很容易在最初便超过创建软件的成本。这就是递增策略在并行编程(通常使用 OpenMP)中的优势所在。借助递增并行,每当添加一个结构时,开发人员便可测试结果,并确保结果仍然与最初的串行代码保持一致。
参考资料
[omp] OpenMP 应用编程接口 2.5 版,www.openmp.org
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
,2005 年
[Mattson05] Timothy G. Mattson、Beverly A. Sanders、Berna L. Massingill,《并行编程模式》,Addison Wesley 出版社,2005 年
[mpi] William Gropp、Ewing Lusk、Anthony Skjellum,“使用 MPI”,麻省理工学院出版社,1994 年
[JLS] Java 语言规范,http://java.sun.com/docs/books/jls
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
[JVMS] Java 虚拟机规范,http://java.sun.com/docs/books/jvms/
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
[JavaAPI] java.lang.Thread 规范,http://java.sun.com/javase/6/docs/api/java/lang/Thread.html
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
[JSR166] Doug Lea 的 util.concurrent Fork-Join 任务框架,http://gee.oswego.edu/dl/classes/EDU/oswego/cs/dl/util/concurrent/FJTask.html
![](http://cache-www.intel.com/plt/cd/ineng_cn.gif)
作者简介
![](http://softwarecommunity-zho.intel.com/UserFiles/en-us/Image/1252/1252b.gif)
Tim Mattson 是一位并行程序员。在过去 20 年中,他曾利用并行计算机来制造化学反应、重新组合蛋白质、寻找石油、分析基因,以及解决众多其它科学问题。Tim 还决心在当前应用编程人员大都编写并行软件之时,开发一种比较少见的串行软件。多年来,他始终坚信寻找正确的并行编程环境是解决问题的关键所在。他尝试了无数种并行编程环境,并自行创建了一些新的环境(包括 OpenMP)。当这种方法被证实并没有他所期望的那样有效时,他迅速转换方式,并决定在人们被语言和软件工具困扰之前,帮助我们意识到需要了解专业程序员对并行编程的思考方式。为了解决这个问题,Tim 花了五年多的时间,与他人合作,共同为并行编程开发设计模式语言(《并行编程模式》,Addison Wesley 出版社,2004 年)。Tim 目前效力于英特尔,并在英特尔企业技术事业部的应用研究实验室中继续进行着并行应用编程问题的研究。
Andrey Y Chernyshev 是英特尔公司企业解决方案软件部门的一名软件工程师。他目前在俄罗斯工作,联系方式为:andrey.y.chernyshev@intel.com。
相关文章推荐
- 编写并行程序:多语言指南介绍
- 黑马程序员—Java语言基础(人机交互方式、Java语言介绍、环境变量配置、Hello World程序编写)
- 汇编语言: 试编写一段程序,要求比较两个字符串 string1 和 string2 所含字符是否相等,如相 等则显示“MATCH”, 若不相同则显示“NO MATCH”。
- 分享一个C#编写简单的聊天程序(详细介绍)
- 用 C 语言编写 Windows 服务程序的五个步骤
- eclipse + JBoss 5 + EJB3开发指南(6):编写第一个实体Bean程序
- 易语言编写程序即时查询对应帮助信息的方法
- 汇编语言: 编写程序,从键盘接收一个小写字母,然后找出它的前导字符和后续字符,
- 并行编程是否需要新的程序语言
- 急求VC++语言编写的MFC记事本程序源码啊?
- 基于Asterisk的VoIP开发指南——(2)Asterisk AGI程序编写指南
- 用 C 语言编写 Windows 服务程序的五个步骤(附源代码)
- C编写Windows服务程序详细介绍
- 用汇编语言编写查表程序
- 用80x86汇编语言,编写一个判断闰年的程序
- 用 C 语言编写一个单片机流水灯程序
- go语言 -并行程序 wordcount
- 用Java语言编写程序最容易犯的21种错误
- 怎样编写支持多国语言的程序, 让它能够根据所运行的操作系统自动显示相应的文字。
- 在VS中如何让C#语言编写的程序自动以管理员身份运行