当前位置:   article > 正文

《OpenMP编译原理及实现技术》摘录

openmp编译原理及实现技术

内容摘自《OpenMP编译原理及实现技术》第2章

代码测试环境:Windows7 64bit, VS2010, 4核机。

可以说OpenMP制导指令将C语言扩展为一个并行语言,但OpenMP本身不是一种独立的并行语言,而是为多处理器上编写并行程序而设计的、指导共享内存、多线程并行的编译制导指令和应用程序编程接口(API),可在C/C++和Fortran中应用,并在串行代码中以编译器可识别的注释形式出现。OpenMP标准是由一些具有国际影响力的软件和硬件厂商共同定义和提出。

1.OpenMP基本概念

1.1 OpenMP执行模式

OpenMP的执行模型采用fork-join的形式,其中fork创建线程或者唤醒已有线程;join即多线程的会合。fork-join执行模型在刚开始执行的时候,只有一个称为“主线程”的运行线程存在。主线程在运行过程中,当遇到需要进行并行计算的时候,派生出线程来执行并行任务。在并行执行的时候,主线程和派生线程共同工作。在并行代码执行结束后,派生线程退出或者阻塞,不再工作,控制流程回到单独的主线程中。OpenMP线程:在OpenMP程序中用于完成计算任务的一个执行流的执行实体,可以是操作系统的线程也可以是操作系统上的进程。

1.2 OpenMP编程要素

OpenMP编程模型以线程为基础,通过编译制导指令来显示地指导并行化OpenMP为编程人员提供了三种编程要素来实现对并行化的完善控制。它们是编译制导、API函数集和环境变量。

1.2.1编译制导:在C/C++程序中,OpenMP的所有编译制导指令是以#pragma omp开始,后面跟具体的功能指令(或命令)。其中指令或命令是可以单独出现的,而子句则必须出现在制导指令之后。制导指令和子句按照功能可以大体上分成四类:(1)、并行域控制类;(2)、任务分担类;(3)、同步控制类;(4)、数据环境类。并行域控制类指令用于指示编译器产生多个线程以并发执行任务,任务分担类指令指示编译器如何给各个并发线程分发任务,同步控制类指令指示编译器协调并发线程之间的时间约束关系,数据环境类指令处理并行域内外的变量共享或私有属性以及边界上的数据传送操作等。

编译制导指令:版本为2.5的OpenMP规范中的指令:(1)、parallel:用在一个结构块之前,表示这段代码将被多个线程并行执行;(2)、for:用于for循环语句之前,表示将循环计算任务分配到多个线程中并行执行,以实现任务分担,必须由编程人员自己保证每次循环之间无数据相关性;(3)、parallel for:parallel和for指令的结合,也是用在for循环语句之前,表示for循环体的代码将被多个线程并行执行,它同时具有并行域的产生和任务分担两个功能;(4)、sections:用在可被并行执行的代码段之前,用于实现多个结构块语句的任务分担,可并行执行的代码段各自用section指令标出;(5)、parallel sections:parallel和sections两个语句的结合,类似于parallel for;(6)、single:用在并行域内,表示一段只被单个线程执行的代码;(7)、critical:用在一段代码临界区之前,保证每次只有一个OpenMP线程进入;(8)、flush:保证各个OpenMP线程的数据影像的一致性;(9)、barrier:用于并行域内代码的线程同步,线程执行到barrier时要停下等待,直到所有线程都执行到barrier时才能继续往下执行;(10)、atomic:用于指定一个数据操作需要原子性地完成;(11)、master:用于指定一段代码由主线程执行;(12)、threadprivate:用于指定一个或多个变量是线程专用。

OpenMP的子句:(1)、private:指定一个或多个变量在每个线程中都有它自己的私有副本;(2)、firstprivate:指定一个或多个变量在每个线程都有它自己的私有副本,并且私有变量要在进入并行域或认为分担域时,继承主线程中的同名变量的值作为初值;(3)、lastprivate:是用来指定将线程中的一个或多个私有变量的值在并行处理结束后复制到主线程中的同名变量中,负责拷贝的线程是for或sections任务分担中的最后一个线程;(4)、reduction:用来指定一个或多个变量是私有的,并且在并行处理结束后这些变量要执行指定的归约运算,并将结果返回给主线程同名变量;(5)、nowait:指出并发线程可以忽略其他制导指令暗含的路障同步;(6)、num_threads:指定并行域内的线程的数目;(7)、schedule:指定for任务分担中的任务分配调度类型;(8)、shared:指定一个或多个变量为多个线程间的共享变量;(9)、copyprivate:配合single指令,将指定线程的专有变量广播到并行域内其他线程的同名变量中;(10)、copyin:用来指定一个threadprivate类型的变量需要用主线程同名变量进行初始化;(11)、default:用来指定并行域内的变量的使用方式,缺省是shared。

1.2.2 API函数集:用于控制并发线程的某些行为。

(1)、omp_in_parallel:判断当前是否在并行域中;(2)、omp_get_thread_num:返回线程号;(3)、omp_set_num_threads:设置后续并行域中的线程个数;(4)、omp_get_num_threads:返回当前并行区域中的线程数;(5)、omp_get_max_threads:获取并行域可用的最大线程数目;(6)、omp_get_num_procs:返回系统中处理器个数;(7)、omp_get_dynamic:判断是否支持动态改变线程数目;(8)、omp_set_dynamic:启用或关闭线程数目的动态改变;(9)、omp_get_nested:判断系统是否支持并行嵌套;(10)、omp_set_nested:启用或关闭并行嵌套;(11)、omp_init(_nest)_lock:初始化一个(嵌套)锁;(12)、omp_destroy(_nest)_lock:销毁一个(嵌套)锁;(13)、omp_set(_nest)_lock:(嵌套)加锁操作;(14)、omp_unset(_nest)_lock:(嵌套)解锁操作;(15)、omp_test(_nest)_lock:非阻塞的(嵌套)加锁;(16)、omp_get_wtime:获取wall time时间;(17)、omp_set_wtime:设置wall time时间。

1.2.3 环境变量:可以在一定程度上控制OpenMP程序的行为。

(1)、OMP_SCHEDULE:用于for循环并行化后的调度,它的值就是循环调度的类型;(2)、OMP_NUM_THREADS:用于设置并行域中的线程数;(3)、OMP_DYNAMIC:通过设定变量值,来确定是否允许动态设定并行域内的线程数;(4)、OMP_NESTED:指出是否可以并行嵌套。

1.2.4 ICV:OpenMP规范中定义了一些内部控制变量ICV(Internal Control Variable),用于表示系统的属性、能力和状态等,可以通过OpenMP API函数访问也可以通过环境变量进行修改。但是变量的具体名字和实现方式可以由各个编译器自行决定。

2. OpenMP编程

2.1 并行域管理:在OpenMP的相邻的fork、join操作之间称之为一个并行域,并行域可以嵌套。

2.1.1 parallel的使用方法:

#pragma ompparallel[for | sections][子句[子句]…]

{ … 代码 …}

parallel语句后面要用一个大括号对将要并行执行的代码括起来。为了指定使用多少个线程来执行,可以通过设置环境变量OMP_NUM_THREADS或者调用omp_set_num_threads()函数,也可以使用num_threads子句,前者只能在程序刚开始运行时起作用,而API函数和子句可以在程序中并行域产生之前起作用。

  1. #pragma omp parallel //parallel语句后面要用一个大括号对将要并行执行的代码括起来
  2. {//并行域的开始(对应fork)
  3. printf("hello, world\n");
  4. }//并行域的结束(对应join)
hello, world

hello, world

hello, world

hello, world

  1. #pragma omp parallel num_threads(8)
  2. {
  3. printf("hello, world! Threadid=%d\n", omp_get_thread_num());
  4. }
hello, world! Threadid=0

hello, world! Threadid=4

hello, world! Threadid=2

hello, world! Threadid=3

hello, world! Threadid=6

hello, world! Threadid=7

hello, world! Threadid=1

hello, world! Threadid=5

从Threadid的不同可以看出创建了8个线程来执行以上代码。所以parallel指令是用来产生或唤醒多个线程创建并行域的,并且可以用num_threads子句控制线程数目。parallel域中的每行代码都被多个线程重复执行。和传统的创建线程函数比起来,其过程非常简单直观。parallel的并行域内部代码中,若再出现parallel制导指令则出现并行域嵌套问题,如果设置了OMP_NESTED环境变量,那么在条件许可时内部并行域也会由多个线程执行,反之没有设置相应变量,那么内部并行域的代码将只有一个线程来执行。还有一个环境变量OMP_DYNAMIC也影响并行域的行为,如果没有设置该环境变量将不允许动态调整并行域内的线程数目,omp_set_dynamic()也是用于同样的目的。

2.2 任务分担:当使用parellel制导指令产生出并行域之后,如果仅仅是多个线程执行完全相同的任务,那么只是徒增计算工作量而不能达到加速计算的目的,甚至可能相互干扰得到错误结果。因此在产生并行域之后,紧接着的问题就是如何将计算任务在这些线程之间分配,并加快计算结果的产生速度及其保证正确性。OpenMP可以完成的任务分担的指令只有for、sections和single,严格意义上来说只有for和sections是任务分担指令,而single只是协助任务分担的指令。任务分担域和并行域的定义一样,既是指代码区间也是指执行时间区间。

2.2.1 for制导指令:for指令指定紧随它的循环语句必须由线程组并行执行,用来将一个for循环任务分配到多个线程,此时各个线程各自分担其中一部分工作。for指令一般可以和parallel指令合起来形成parallel for指令使用,也可以单独用在parallel指令的并行域中。

  1. int j=0;
  2. #pragma omp for
  3. for (j=0; j<4; ++j)
  4. {
  5. printf("j=%d, Threadid=%d\n", j, omp_get_thread_num());
  6. }
j=0, Threadid=0

j=1, Threadid=0

j=2, Threadid=0

j=3, Threadid=0

从结果可以看出,4次循环都在一个Threadid为0的线程里执行,并没有实现并发执行也不会加快计算速度。可见for指令要和parallel指令结合起来使用才有效果,即for出现在并行域中才能有多个线程来分担任务。

  1. int j=0;
  2. #pragma omp parallel for
  3. for (j=0; j<4; ++j)
  4. {
  5. printf("j=%d, Threadid=%d\n", j, omp_get_thread_num());
  6. }

j=0, Threadid=0

j=1, Threadid=1

j=3, Threadid=3

j=2, Threadid=2

  1. int j=0;
  2. #pragma omp parallel
  3. {
  4. #pragma omp for
  5. for (j=0; j<100; ++j)
  6. {
  7. //do someting
  8. }
  9. #pragma omp for
  10. for (j=0; j<100; ++j)
  11. {
  12. //do someting
  13. }
  14. //do someting
  15. }
此时只有一个并行域,在该并行域内的多个线程首先完成第一个for语句的任务分担,然后在此进行一次同步(for制导指令本身隐含有结束处的路障同步),然后再进行第二个for语句的任务分担,直到退出并行域只剩下一个主线程为止。

2.2.2 for调度:当循环中每次迭代的计算量不相等时,如果简单地给各个线程分配相同次数的迭代的话,会使得各个线程计算负载不均衡,这会使得有些线程先执行完,有些后执行完,造成某些CPU核空闲,影响程序性能。在OpenMP的for任务分担中,任务的划分称为调度,各个线程如何划分任务是可以调整的,因此有静态划分、动态划分等,所以调度也分成多个类型。for任务调度子句只能用于for制导指令中。在OpenMP中,对for循环任务调度使用schedule子句来实现。schedule子句使用格式为:schedule(type[, size]). type参数,表示调度类型,有四种调度类型如下:static、dynamic、guided、runtime。size参数为可选,表示以循环迭代次数计算的划分单位,每个线程所承担的计算任务对应于0个或若干个size次循环,size参数必须是整数。static、dynamic、guided三种调度方式都可以使用size参数,也可以不使用size参数。当type参数类型为runtime时,size参数是非法的。

2.2.2.1 static静态调度:当for或者parallelfor编译制导指令没有带schedule子句时,大部分系统中默认采用size为1的static调度方式。

  1. int i=0;
  2. #pragma omp parallel for schedule(static)
  3. for (i=0; i<10; ++i)
  4. {
  5. printf("i=%d, thread_id=%d\n", i, omp_get_thread_num());
  6. }
i=0, thread_id=0

i=1, thread_id=0

i=3, thread_id=1

i=4, thread_id=1

i=5, thread_id=1

i=2, thread_id=0

i=6, thread_id=2

i=7, thread_id=2

i=8, thread_id=3

i=9, thread_id=3

注意:由于多线程执行时序的随机性,每次执行时打印的结果顺序可能存在差别。

  1. int i=0;
  2. #pragma omp parallel for schedule(static, 2)
  3. for (i=0; i<10; ++i)
  4. {
  5. printf("i=%d, thread_id=%d\n", i, omp_get_thread_num());
  6. }

i=0, thread_id=0

i=1, thread_id=0

i=8, thread_id=0

i=9, thread_id=0

i=6, thread_id=3

i=7, thread_id=3

i=2, thread_id=1

i=3, thread_id=1

i=4, thread_id=2

i=5, thread_id=2

使用size参数时,分配给每个线程的size次连续的迭代计算。

2.2.2.2 dynamic动态调整:是动态地将迭代分配到各个线程,各线程动态的申请任务,因此较快的线程可能申请更多次数,而较慢的线程申请任务次数可能较少,因此动态调整可以在一定程度上避免前面提到的按循环次数划分引起的负载不平衡问题。

  1. int i=0;
  2. #pragma omp parallel for schedule(dynamic)
  3. for (i=0; i<10; ++i)
  4. {
  5. printf("i=%d, thread_id=%d\n", i, omp_get_thread_num());
  6. }

i=0, thread_id=0

i=4, thread_id=0

i=5, thread_id=0

i=6, thread_id=0

i=7, thread_id=0

i=8, thread_id=0

i=9, thread_id=0

i=1, thread_id=2

i=2, thread_id=1

i=3, thread_id=3

  1. int i=0;
  2. #pragma omp parallel for schedule(dynamic, 2)
  3. for (i=0; i<10; ++i)
  4. {
  5. printf("i=%d, thread_id=%d\n", i, omp_get_thread_num());
  6. }

i=0, thread_id=0

i=1, thread_id=0

i=2, thread_id=0

i=3, thread_id=0

i=4, thread_id=0

i=5, thread_id=0

i=8, thread_id=0

i=9, thread_id=0

i=6, thread_id=3

i=7, thread_id=3

动态调整时,size小有利于实现更好的负载均衡,但是会引起过多的任务动态申请的开销,反之size大则开销较少,但是不易于实现负载平衡,size的选择需要在这两者之间进行权衡。

2.2.2.3 guided调度:是一种采用指导性的启发式自调度方法。开始时每个线程会分配到较大的迭代块,之后分配到的迭代块会逐渐递减。迭代块的大小会按指数级下降到指定的size大小,如果没有指定size参数,那么迭代块大小最小会降到1.

  1. int i=0;
  2. #pragma omp parallel for schedule(guided, 2)
  3. for (i=0; i<10; ++i)
  4. {
  5. printf("i=%d, thread_id=%d\n", i, omp_get_thread_num());
  6. }

i=0, thread_id=1

i=1, thread_id=1

i=2, thread_id=1

i=9, thread_id=1

i=3, thread_id=0

i=4, thread_id=0

i=7, thread_id=3

i=8, thread_id=3

i=5, thread_id=2

i=6, thread_id=2

2.2.2.4 runtime调度:它不像static、dynamic、guided三种调度方式那样是真实调度方式。它是在运行时根据环境变量OMP_SCHEDULE来确定调度类型,最终使用的调度类型仍然是static、dynamic、guided中的一种。

2.2.3 sections编译制导指令:是用于非迭代计算的任务分担,它将sections语句里的代码用section制导指令划分成几个不同的段(可以是一条语句,也可以是用{…}括起来的结构块),不同的section段由不同的线程并行执行。

  1. #pragma omp parallel sections
  2. {
  3. #pragma omp section
  4. printf("section 1 thread=%d\n", omp_get_thread_num());
  5. #pragma omp section
  6. printf("section 2 thread=%d\n", omp_get_thread_num());
  7. #pragma omp section
  8. printf("section 3 thread=%d\n", omp_get_thread_num());
  9. }
section 1 thread=0

section 2 thread=2

section 2 thread=1

  1. #pragma omp parallel
  2. {
  3. #pragma omp sections
  4. {
  5. #pragma omp section
  6. printf("section 1 Threadid=%d\n", omp_get_thread_num());
  7. #pragma omp section
  8. printf("section 2 Threadid=%d\n", omp_get_thread_num());
  9. }
  10. #pragma omp sections//两个sections构造先后串行执行,与for制导指令一样,在sections的结束处有一个隐含的路障同步
  11. {
  12. #pragma omp section
  13. printf("section 3 Threadid=%d\n", omp_get_thread_num());
  14. #pragma omp section
  15. printf("section 4 Threadid=%d\n", omp_get_thread_num());
  16. }
  17. }

section 1 thread=0

section 2 thread=1

section 3 thread=0

section 4 thread=1

这里有两个sections构造先后串行执行的,即第二个sections构造的代码要等第一个sections构造的代码执行完后才能执行。sections构造里面的各个section部分代码是并行执行的。与for制导指令一样,在sections的结束处有一个隐含的路障同步,没有其他说明的情况下,所有线程都必须到达该点才能往下运行。使用section指令时,需要注意的是这种方式需要保证各个section里的代码执行时间相差不大,否则某个section执行时间比其他section过长就造成了其它线程空闲等待的情况。用for语句来分担任务时工作量由系统自动划分,只要每次循环间没有时间上的差异,那么分摊是比较均匀的,使用section来划分线程是一种手工划分工作量的方式,最终负载均衡的好坏得依赖于程序员。

2.2.4 single制导指令:单线程执行single制导指令指定所包含的代码只由一个线程执行,别的线程跳过这段代码。如果没有nowait从句,所有线程在single制导指令结束处隐式同步点同步。如果single制导指令有nowait从句,则别的线程直接向下执行,不在隐式同步点等待;single制导指令用在一段只被单个线程执行的代码段之前,表示后面的代码段将被单线程执行。#pragma omp single[子句]

  1. #pragma omp parallel
  2. {
  3. #pragma omp single
  4. printf("Beginning work1.\n");
  5. printf("work on 1 parallelly.%d\n", omp_get_thread_num());
  6. #pragma omp single
  7. printf("Finishing work1.\n");
  8. #pragma omp single nowait
  9. printf("Beginning work2.\n");
  10. printf("work on 2 parallelly.%d\n", omp_get_thread_num());
  11. }
Beginning work1.

work on 1 parallelly.2

Finishing work1.

work on 1 parallelly.1

work on 1 parallelly.0

work on 1 parallelly.3

Beginning work2.

work on 2 parallelly.1

work on 2 parallelly.0

work on 2 parallelly.2

work on 2 parallelly.3

另一种需要使用single制导指令的情况是为了减少并行域创建和撤销的开销,而将多个临界的parallel并行域合并时。经过合并后,原来并行域之间的串行代码也将被并行执行,违反了代码原来的目的,因此这部分代码可以用single指令加以约束只用一个线程来完成。

2.3 同步:在正确产生并行域并用for、sections等语句进行任务分担后,还须考虑的是这些并发线程的同步互斥需求。在OpenMP应用程序中,由于是多线程执行,所以必须有线程互斥机制以保证程序在出现数据竞争的时候能够得出正确的结果,并且能够控制线程执行的先后制约关系,以保证执行结果的正确性。OpenMP支持两种不同类型的线程同步机制,一种是互斥锁的机制,可以用来保护一块共享的存储空间,使任何时候访问这块共享内存空间的线程最多只有一个,从而保证了数据的完整性;另外一种同步机制是事件同步机制,这种机制保证了多个线程之间的执行顺序。互斥的操作针对需要保护的数据而言,在产生了数据竞争的内存区域加入互斥,可以使用包括critical、atomic等制导指令以及API中的互斥函数。而事件机制则控制线程执行顺序,包括barrier同步路障、ordered定序区段、master主线程执行等。

2.3.1 critical临界区:在可能产生内存数据访问竞争的地方,都需要插入相应的临界区制导指令,格式:#pragam omp critical[(name)] critical语句不允许互相嵌套。

  1. int i;
  2. int max_num_x=max_num_y=-1;
  3. #pragma omp parallel for
  4. for (i=0; i<n; ++i)
  5. {
  6. #pragma omp critical(max_arx);
  7. if (arx[i] >max_num_x)
  8. {
  9. max_num_x = arx[i];
  10. }
  11. #pragma omp critical(max_ary)
  12. if (ary[i]>max_num_y)
  13. {
  14. max_num_y = ary[i];
  15. }
  16. }
在一个并行域内的for任务分担域中,各个线程逐个进入到critical保护的区域内,比较当前元素和最大值的关系并可能进行最大值的更替,从而避免了数据竞争的情况。

2.3.2 atomic原子操作:critical临界区操作能够作用在任意大小的代码块上,而原子操作只能作用在单条赋值语句中。能够使用原子语句的前提条件是相应的语句能够转化成一条机器指令,使得相应的功能能够一次执行完毕而不会被打断。C/C++中可用的原子操作:“+、-、*、/、&、^、<<、>>”。值得注意的是,当对一个数据进行原子操作保护的时候,就不能对数据进行临界区的保护,OpenMP运行时并不能在这两种保护机制之间建立配合机制。用户在针对同一个内存单元使用原子操作的时候,需要在程序所有涉及到该变量并行赋值的部位都加入原子操作的保护。

  1. int counter = 0;
  2. #pragma omp parallel
  3. {
  4. for (int i=0; i<10000; ++i)
  5. {
  6. #pragma omp atomic//atomic operation
  7. counter++;
  8. }
  9. }
  10. printf("counter=%d\n", counter);

counter=4000

由于使用atomic语句,则避免了可能出现的数据访问竞争情况,最后的执行结果都是一致的。而将atomic这一行语句从源程序中删除时,由于有了数据访问的竞争情况,所以最后的执行结果是不确定的。

2.3.3 barrier同步路障:路障(barrier)是OpenMP线程的一种同步方法。线程遇到路障时必须等待,直到并行区域内的所有线程都到达了同一点,才能继续执行下面的代码。在每一个并行域和任务分担域的结束处都会有一个隐含的同步路障,执行此并行域/任务分担域的线程组在执行完毕本区域代码之前,都需要同步并行域的所有线程。也就是说在parallel、for、sections和single构造的最后,会有一个隐式的路障。在有些情况下,隐含的同步路障并不能提供有效的同步措施。这时,需要程序员插入明确的同步路障语句#pragma omp barrier。此时,在并行区域的执行过程中,所有的执行线程都会在同步路障语句上进行同步。

  1. #pragma omp parallel
  2. {
  3. Initialization();
  4. #pragma omp barrier
  5. Process();
  6. }

只有等所有的线程都完成Initialization()初始化操作以后,才能够进行下一步的处理动作,因此,在此处插入一个明确的同步路障操作以实现线程之间的同步。

2.3.4 nowait:为了避免在循环过程中不必要的同步路障并加快运行速度,可以使用nowait子句除去这个隐式的路障。

  1. int i, j;
  2. #pragma omp parallel num_threads(4)
  3. {
  4. #pragma omp for nowait
  5. for (int i=0; i<8; ++i)
  6. {
  7. printf("+\n");
  8. }
  9. #pragma omp for
  10. for (j=0; j<8; ++j)
  11. {
  12. printf("-\n");
  13. }
  14. }
+

+

+

+

-

+

+

-

-

-

-

-

+

+

-
-
此时,线程在完成第一个for循环子任务后,并不需要同步等待,而是直接执行后面的任务,因此出现“-”在“+”前面的情况。nowait子句消除了不必要的同步开销,加快了计算速度,但是也引入了实现上的困难。

2.3.5 master主线程执行:用于指定一段代码由主线程执行。master制导指令和single制导指令类似,区别在于,master制导指令包含的代码段只由主线程执行,而single制导指令包含的代码段可由任一线程执行,并且master制导指令在结束处没有隐式同步,也不能指定nowait从句。

  1. int a[5], i;
  2. #pragma omp parallel
  3. {
  4. #pragma omp for
  5. for (i=0; i<5; ++i)
  6. {
  7. a[i] = i * i;
  8. }
  9. #pragma omp master
  10. for (i=0; i<5; ++i)
  11. {
  12. printf("a[%d]=%d\n", i, a[i]);
  13. }
  14. }
a[0]=0

a[1]=1

a[2]=4

a[3]=9

a[4]=16

只有一个线程将逐个元素打印出来。

2.3.6 ordered顺序制导指令:对于循环代码的任务分担中,某些代码的执行需要按规定的顺序执行。典型的情况如下:在一次循环的过程中大部分的工作是可以并行执行的,而特定部分代码的工作需要等到前面的工作全部完成之后才能够执行。这时,可以使用ordered子句使特定的代码按照串行循环的次序来执行。

  1. #pragma omp parallel
  2. {
  3. #pragma omp for
  4. for (i=0; i<100; ++i)
  5. {
  6. //一些无数据相关、可并行乱序执行的操作
  7. //do someting
  8. #pragma omp ordered
  9. //一些有数据相关、只能顺序执行的操作
  10. //do someting
  11. }
  12. }

虽然在ordered子句之前的工作是并行执行的,但是在遇到ordered子句的时候,只有前面的循环都执行完毕之后,

才能够进行下一步执行。这样一来,有些任务在并行执行,对于部分必须串行执行的部分才启用ordered保护。

2.3.7 互斥锁函数:除了atomic和critical编译制导指令,OpenMP还可以通过库函数支持实现互斥操作,方便用户实现特定的同步需求。编译制导指令的互斥支持只能放置在一段代码之前,作用在这段代码之上。而OpenMP API所提供的互斥函数可放在任意需要的位置。程序员必须自己保证在调用相应锁操作之后释放相应的锁,否则就可能造成多线程程序的死锁。互斥锁函数中只有omp_test_lock函数是带有返回值的,该函数可以看作是omp_set_lock的非阻塞版本。

  1. static omp_lock_t lock;
  2. int i;
  3. omp_init_lock(&lock);
  4. #pragma omp parallel for
  5. for (i=0; i<5; ++i)
  6. {
  7. omp_set_lock(&lock);
  8. printf("%d +\n", omp_get_thread_num());
  9. printf("%d -\n", omp_get_thread_num());
  10. omp_unset_lock(&lock);
  11. }
  12. omp_destroy_lock(&lock);
0 +

0 -

0 +

0 -

1 +

1 -

3 +

3 -

2 +

2 -

示例对for循环中的所有内容进行加锁保护,同时只能有一个线程执行for循环中的内容。

2.4 数据环境控制:通常来说,OpenMP是建立在共享存储结构的计算机之上,使用操作系统提供的线程作为并发执行的基础,所以线程间的全局变量和静态变量是共享的,而局部变量、自动变量是私有的。但是对OpenMP编程而言,缺省变量往往是共享变量,而不管它是不是全局静态变量还是局部自动变量。也就是说OpenMP各个线程的变量是共享还是私有,是依据OpenMP自身的规则和相关的数据子句而定,而不是依据操作系统线程或进程上的变量特性而定。OpenMP的数据处理子句包括private、firstprivate、lastprivate、shared、default、reduction copyin和copyprivate.它与编译制导指令parallel、for和sections相结合用来控制变量的作用范围。它们控制数据变量,比如,哪些串行部分中的数据变量被传递到程序的并行部分以及如何传送,哪些变量对所有并行部分的线程是可见的,哪些变量对所有并行部分的线程是私有的,等等。

2.4.1 共享与私有化

2.4.1.1 shared子句:用来声明一个或多个变量是共享变量。需要注意的是,在并行域内使用共享变量时,如果存在写操作,必须对共享变量加以保护,否则不要轻易使用共享变量,尽量将共享变量的访问转化为私有变量的访问。循环迭代变量在循环构造的任务分担域里是私有的。声明在任务分担域内的自动变量都是私有的。

2.4.1.2 default子句:用来允许用户控制并行区域中变量的共享属性。使用shared时,缺省情况下,传入并行区域内的同名变量被当作共享变量来处理,不会产生线程私有副本,除非使用private等子句来指定某些变量为私有的才会产生副本。如果使用none作为参数,除了那些由明确定义的除外,线程中用到的变量都必须显式指定为是共享的还是私有的。

2.4.1.3 private子句:用来将一个或多个变量声明成线程私有的变量,变量声明成私有变量后,指定每个线程都有它自己的变量私有副本,其他线程无法访问私有副本。即使在并行域外有同名的共享变量,共享变量在并行域内不起任何作用,并且并行域内不会操作到外面的共享变量。出现在reduction子句中的变量不能出现在private子句中。

  1. int k = 100;
  2. #pragma omp parallel for private(k)
  3. for (k=0; k<8; ++k)
  4. {
  5. printf("k=%d\n", k);
  6. }
  7. printf("last k=%d\n", k);
k=0

k=1

k=4

k=5

k=2

k=3

k=6

k=7

last k=100

for循环前的变量k和循环区域内的变量k其实是两个不同的变量。用private子句声明的私有变量的初始值在并行域的入口处是未定义的,它并不会继承同名共享变量的值。

2.4.1.4 firstprivate子句:私有变量的初始化和终结操作,OpenMP编译制导指令需要对这种需求给予支持,即使用firstprivate和lastprivate来满足这两种需求。使得并行域或任务分担域开始执行时,私有变量通过主线程中的变量初始化,也可以在并行域或任务分担结束时,将最后一次一个线程上的私有变量赋值给主线程的同名变量。private声明的私有变量不会继承同名变量的值,于是OpenMP提供了firstprivate子句来实现这个功能。firstprivate子句是private子句的超集,即不仅包含了private子句的功能,而且还要对变量做进行初始化。

  1. int i, k=100;
  2. #pragma omp parallel for firstprivate(k)
  3. for (i=0; i<4; ++i)
  4. {
  5. k += i;
  6. printf("k=%d\n", k);
  7. }
  8. printf("last k=%d\n", k);
k=100

k=103

k=101

k=102

last k=100

并行域内的私有变量k继承了外面共享变量k的值100作为初始值,并且在退出并行区域后,共享变量k的值保持为100未变。

2.4.1.5 lastprivate子句:有时要将任务分担域内私有变量的值经过计算后,在退出时,将它的值赋给同名的共享变量(private和firstprivate子句在退出并行域时都没有将私有变量的最后取值赋给对应的共享变量),lastprivate子句就是用来实现在退出并行域时将私有变量的值赋给共享变量。lastprivate子句也是private子句的超集,即不仅包含了private子句的功能,而且还要将变量从for、sections的任务分担域中最后的线程中复制给外部同名变量。由于在并行域内是多个线程并行执行的,最后到底是将哪个线程的最终计算结果赋给了对应的共享变量呢?OpenMP规范中指出,如果是for循环迭代,那么是将最后一次循环迭代中的值赋给对应的共享变量;如果是sections构造,那么是代码中排在最后的section语句中的值赋给对应的共享变量。注意这里说的最后一个section是指程序语法上的最后一个,而不是实际运行时的最后一个运行完的。

  1. int i, k=100;
  2. #pragma omp parallel for firstprivate(k), lastprivate(k)
  3. for (i=0; i<4; ++i)
  4. {
  5. k += i;
  6. printf("k=%d\n", k);
  7. }
  8. printf("last k=%d\n", k);
k=101

k=100

k=102

k=103

last k=103
退出for循环的并行区域后,共享变量k的值变成了103,而不是保持原来的100不变。

2.4.1.6 flush:OpenMP的flush制导指令主要与多个线程之间的共享变量的一致性问题。用法:flush[(list)],该指令将列表中的变量执行flush操作,直到所有变量都已完成相关操作后才返回,保证了后续变量访问的一致性。

2.4.2 线程专有数据:它和私有数据不太相同,threadprivate子句用来指定全局的对象被各个线程各自复制了一个私有的拷贝,即各个线程具有各自私有、线程范围内的全局对象。private变量在退出并行域后则失效,而threadprivate线程专有变量可以在前后多个并行域之间保持连续性。

2.4.2.1 threadprivate子句:用法:#pragma omp threadprivate(list) new-line。用作threadprivate的变量的地址不能是常数。对于C++的类(class)类型变量,用作threadprivate的参数时有些限制,当定义时带有外部初始化则必须具有明确的拷贝构造函数。对于windows系统,threadprivate不能用于动态装载(使用LoadLibrary装载)的DLL中,可以用于静态装载的DLL中。

  1. int counter = 0;
  2. #pragma omp threadprivate(counter)
  3. int increment_counter()
  4. {
  5. counter++;
  6. return (counter);
  7. }
实现一个线程私有的计数器,各个线程使用同一个函数来实现自己的计数。

如果是静态变量也同样可以使用threadprivate声明成线程私有的。

2.4.2.2 copyin子句:用来将主线程中threadprivate变量的值复制到执行并行域的各个线程的threadprivate变量中,便于所有线程访问主线程中的变量值。copyin中的参数必须被声明成threadprivate的,对于class类型的变量,必须带有明确的拷贝赋值操作符。

  1. int counter = 0;
  2. #pragma omp threadprivate(counter)
  3. int increment_counter()
  4. {
  5. counter++;
  6. return (counter);
  7. }
  8. int _tmain(int argc, _TCHAR* argv[])
  9. {
  10. int iterator;
  11. #pragma omp parallel sections copyin(counter)
  12. {
  13. #pragma omp section
  14. {
  15. int count1;
  16. for (iterator=0; iterator<100; ++iterator)
  17. {
  18. count1 = increment_counter();
  19. }
  20. printf("count1=%ld\n", count1);
  21. }
  22. #pragma omp section
  23. {
  24. int count2;
  25. for (iterator=0; iterator<200; ++iterator)
  26. {
  27. count2 = increment_counter();
  28. }
  29. printf("count2=%ld\n", count2);
  30. }
  31. }
  32. printf("counter=%ld\n", counter);
  1. return 0;
  2. }
count1=100

count2=200

counter=0
threadprivate中的计数器函数,如果多个线程使用时,各个线程都需要对全局变量counter的副本进行初始化。

2.4.2.3 copyprivate子句:提供了一种机制,即将一个线程私有变量的值广播到执行同一并行域的其他线程。copyprivate子句可以关联single构造,在single构造的barrier到达之前就完成了广播工作。copyprivate可以对private和threadprivate子句中的变量进行操作,但是当使用single构造时,copyprivate的变量不能用于private和firstprivate子句中。

  1. int counter = 0;
  2. #pragma omp threadprivate(counter)
  3. int increment_counter()
  4. {
  5. counter++;
  6. return (counter);
  7. }
  8. int _tmain(int argc, _TCHAR* argv[])
  9. {
  10. #pragma omp parallel
  11. {
  12. int count;
  13. #pragma omp single copyprivate(counter)
  14. {
  15. counter = 50;
  16. }
  17. count = increment_counter();
  18. printf("Threadid:%ld, count=%ld\n", omp_get_thread_num(), count);
  19. }
  20. return 0;
  21. }
Threadid:0, count=51

Threadid:1, count=51

Threadid:3, count=51

Threadid:2, count=51

使用copyprivate子句后,single构造内给counter赋的值被广播到了其它线程里,但没有使用copyprivate子句时,只有一个线程获得了single构造内的赋值,其它线程没有获取single构造内的赋值。

2.4.3 归约操作:reduction子句主要用来对一个或多个参数条目指定一个操作符,每个线程将创建参数条目的一个私有拷贝,在并行域或任务分担域的结束处,将用私有拷贝的值通过指定的运行符运算,原始的参数条目被运算结果的值更新。列出了可以用于reduction子句的一些操作符以及对应私有拷贝变量缺省的初始值,私有拷贝变量的实际初始值依赖于reduction变量的数据类型:+(0)、-(0)、*(1)、&(~0)、|(0)、^(0)、&&(1)、||(0)。如果在并行域内不加锁保护就直接对共享变量进行写操作,存在数据竞争问题,会导致不可预测的异常结果。如果共享数据作为private、firstprivate、lastprivate、threadprivate、reduction子句的参数进入并行域后,就变成线程私有了,不需要加锁保护了。

  1. int i, sum = 100;
  2. #pragma omp parallel for reduction(+:sum)
  3. for (i=0; i<1000; ++i)
  4. {
  5. sum += i;
  6. }
  7. printf("sum=%ld\n", sum);
sum=499600

OpenMP编译器结构:它和所有编译器一样具有相似的结构,即典型的八个部件构成,它们分别是词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、信息表管理和错误处理。(1)、词法分析程序:词法分析(Lexical analysis)或扫描(Scanning)是编译器最前端的输入模块,它将源代码文件中的一个长长的字符串,逐个识别为有意义的词素(Lexeme)或单词符号,并转变为便于内部处理的格式来保存。通常词法扫码器的工作任务有:识别出源程序中的每个基本语法单位(通常称为单词或语法符号);删除无用的空白字符、回车字符以及其他与语言无直接关系的非实质性字符;删除注释行;进行词法检查并报告所发现的错误。(2)、语法分析程序:语法分析(Syntax Analysis)或解析(Parsing)程序需要借助于词法分析,将词法分析输出的内部编码格式表示的单词序列尝试构建出一个符合语法规则的完整语法树。如果无法成功建立起一个合法的语法树,则由错误处理模块输出相应的语法错误信息。(3)、语义分析程序(Semantic Analysis):语义特征表征的是各个语法成分的含义和功能,包括这些语法元素的属性或执行时应进行的运算或操作。(4)、中间代码生成:指的是编译器未输出目标代码之前在内部使用的一种源代码的等价表示。(5)、代码优化程序:代码优化是为了提供更高质量目标代码,该工作常常在中间代码生成和目标代码输出之间插入一个代码优化处理的阶段来实现。根据目标代码的目标期望不同,优化方法也相应不同,有的是以运行时间为标准越快越好,有的是以存储空间开销为标准占用内存越少越好。(6)、目标代码生成:是以语义分析(也可能加上优化处理)产生的中间代码作为输入的,它将中间代码翻译为最终形式的目标代码。(7)、信息表管理程序:在编译过程中总是需要收集、记录或查询源程序中出现的各种量的有关属性信息,因此编译程序需要建立和维护多个不同用途的表格(例如常数表、变量名、循环层次等等),这些表格统称为符号表。在编译过程中,造表和查表工作由一系列程序(或函数)来完成,它们并不是独立的存在而是安插在编译程序的相关功能代码中。(8)、错误处理:由于编程人员不可避免的会写出有错误的代码,一个可用的编译器必须能够发现大多数常见错误,并能准确地报告出错误在源代码中的位置,否则就没有使用价值。
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/花生_TL007/article/detail/267876
推荐阅读
相关标签
  

闽ICP备14008679号