`
javayestome
  • 浏览: 1006806 次
  • 性别: Icon_minigender_2
  • 来自: 北京
文章分类
社区版块
存档分类
最新评论

OpenMP与C++:事半功倍地获得多线程的好处(下)

阅读更多

声明:本文并未获得翻译授权,本人翻译这篇文章仅用于学习和研究之用,任何人不得在未经授权之前将原文和译文用以商业用途.
因版权原因,暂不建议转载本文.
本文发表于http://blog.csdn.net/lanphaday
请保留本文完整
本文发表于2005年第10期的<MSDN Mag>,英文版本地址:
http://msdn.microsoft.com/msdnmag/issues/05/10/OpenMP/default.aspx

OpenMP与C++:事半功倍地获得多线程的好处(下)
Kang Su Gatlin & Pete Isensee 著
赖勇浩 译

用以同步的编译器指令

在多个线程并发的时候,某一线程常常会需要同步其它线程。OpenMP支持多种类型的同步,以在不同的情境下解决问题。

其中之一就是暗含的barrier同步。在每一个并行区域都有一个暗含的barrier,用以同步并行区域中的所有线程。一个barrier同步要求所有线程执行到此,然后才能往下执行。

#pragma omp for#pragma omp single#pragma omp sections程序块都有暗含的barrier同步。从上述三种工作共享的程序块中去除暗含的barrier同步的方法是增加nowait子句:

#pragma omp parallel

{

#pragma omp for nowait

for(int i = 1; i < size; ++i)

x[i] = (y[i-1] + y[i+1])/2;

}

如你所见,工作共享指令中的nowait子句指明线程不需要在for循环结束时同步,尽管线程将在并行区域结束处同步。

另一种是明确声明barrier同步,在一些情境下你可能需要在并行区域出口之外放置barrier同步,这时你可以在代码里加一个#pragma omp barrier指令。

临界区能够像barrier那样使用,在Win32 API中通过EnterCriticalSectionExitCriticleSection来进出临界区。OpenMP通过#pragma omp critical [name]指令给予程序员同样的能力。这与Win32临界区有同样的语义,并且隐藏了EnterCriticleSection的调用。你可以使用命名的临界区,这种情况下代码段仅与同名临界区互斥。如果没有指定临界区名字,则映射到用户未定义的名字。这些未命名的临界区与区域相关的每一临界区互斥。

在一个并行区域里,经常限制同时只有一条线程能够访问一段代码,例如在并行区域的中间写文件。大多数这种情况下,并不关心哪一条线程执行这段代码,只要只有一条线程执行这段代码即可,OpenMP#pragma omp single指令来完成这个工作。

有此时候用single指令声明必须由单一线程执行并行区域中的一段代码并不满足需要。有些情况下你希望确保主线程来执行这段代码——例如主线程是GUI线程并且你希望GUI线程完成一些工作。#pragma omp master指令可以做到这一点。不像single,在进出一个master代码块的时候并没有暗含的barrier

内存界定(Memory Fence)可用#pragma omp flush实现,这条指令在程序中生成内存界定,它的本质上等效于_ReadWriteBarrier

切记OpenMP指令同时影响线程组里的所有线程。因此下面的代码片段是非法的并且有未定义的运行时行为(崩溃或者在特别情况下被挂起):

#pragma omp parallel

{

if(omp_get_thread_num() > 3)

{

#pragma omp single // May not be accessed by all threads

x++;

}

}

执行环境例程

除了前文讨论的编译器指令OpenMP也包含一系列极为有用的运行时例程,用以编写OpenMP应用程序。有三大类型的例程可用:执行环境例程,锁/同步例程和定时例程(定时例程不在本文讨论)。所有的OpenMP例程都在omp.h头文件中定义并皆以omp_开头。

运行时环境例程提供允许你查询和设置OpenMP环境的各个方面的功能。以omp_set_开头的函数只能在并行区域外调用,其它函数可在并行和非并行区域使用。

可以用omp_get_num_threadsomp_set_num_threads来读取或者设置线程组的线程数量。omp_get_num_threads返回当前线程组的线程数目。如果调用此函数的线程不在并行区域,返回1omp_set_num_threads用以设置当前线程执行下一个并行区域的线程数。

但这并非设置线程数目的全部,并行区域的线程数目同样依赖于OpenMP的另两方面的配置环境:动态线程和嵌套。

动态线程是一个默认为不使能的布尔属性。当线程将执行一块并行区域的时候如果这个属性为不使能,那么OpenMP就生线程数量为omp_get_max_threads返回值的线程组。omp_get_max_threads默认为计算机的硬件线程数或者环境变量OMP_NUM_THREADS的值。如果使能动态线程OpenMP将生成一个线程数量可变的线程组,但这个数量不会超过omp_get_max_threads的返回值。

嵌套是另一个默认为不使能的布尔属性。并行区域嵌套出现在当线程已经运行在并行区域又遇到另一个并行区域的时候。如果嵌套被使能,那么就按前文关于动态线程的规则生成一个新的线程组。相反地,线程组就只有单独一个线程。

可以通过omp_set_dynamicomp_get_dynamicomp_set_nested omp_get_nested来设置或者查询动态线程和嵌套的使能状态。每一条线程都可以查询它所处的环境。线程可以通过调用omp_get_thread_num来获得它所处的线程组的线程数目——一个比调用omp_get_num_threads的返回值少0或者1的值。

omp_in_parallel用以查询本线程是否正在并行区域执行。omp_get_num_proc用以获知计算机有多少个CPU

#include <stdio.h>

#include <omp.h>

int main()

{

omp_set_dynamic(1);

omp_set_num_threads(10);

#pragma omp parallel // parallel region 1

{

#pragma omp single

printf("Num threads in dynamic region is = %d\n",

omp_get_num_threads());

}

printf("\n");

omp_set_dynamic(0);

omp_set_num_threads(10);

#pragma omp parallel // parallel region 2

{

#pragma omp single

printf("Num threads in non-dynamic region is = %d\n",

omp_get_num_threads());

}

printf("\n");

omp_set_dynamic(1);

omp_set_num_threads(10);

#pragma omp parallel // parallel region 3

{

#pragma omp parallel

{

#pragma omp single

printf("Num threads in nesting disabled region is = %d\n",

omp_get_num_threads());

}

}

printf("\n");

omp_set_nested(1);

#pragma omp parallel // parallel region 4

{

#pragma omp parallel

{

#pragma omp single

printf("Num threads in nested region is = %d\n",

omp_get_num_threads());

}

}

}

(图6)使用OpenMP例程

6可以帮助你更清晰地理解这些不同的互相作用的环境例程。在这个例子中有4个截然不同的并行区域,包括两个嵌套并行区域。

Visual Studio 2005编译之后在双处理器的计算机上执行上例,输出如下:

Num threads in dynamic region is = 2

Num threads in non-dynamic region is = 10

Num threads in nesting disabled region is = 1

Num threads in nesting disabled region is = 1

Num threads in nested region is = 2

Num threads in nested region is = 2

在第一个并行区域使能了动态线程并设置线程数为10。从程序的输出可以看到使能了动态线程的OpenMP在运行时仅为线程组分派两条线程——因为计算机只有两个处理器。在第二个并行区域,未使能动态线程的OpenMP为线程组分派了10条线程。

在第三、四个并行区域,你可以看到使能和未使能嵌套的影响。在第三个并行区域,因为没有使能嵌套,所以没有为嵌套的并行区域分派新的线程。因此嵌套和外部并行区域加起来只有两条线程。在使能了嵌套的第四个并行区域中为嵌套并行区域生成了一个拥有两条线程的线程组(故在嵌套并行区域总计有四条线程)。这种为每一个嵌套并行区域加倍增加线程的处理能够一直进行下去,直到用完栈空间。实际上你可以生成几百条线程,但这样做的话开销将会远大于使用多线程获得的性能优势。

可能你已经留意到在第三、四并行区域中是使能了动态线程的,那么下面这段未使能动态线程的代码又会有什么样的执行结果?

omp_set_dynamic(0);

omp_set_nested(1);

omp_set_num_threads(10);

#pragma omp parallel

{

#pragma omp parallel

{

#pragma omp single

printf("Num threads in nested region is = %d\n",

omp_get_num_threads());

}

}

下面你可以看到预期的结果。在第一个并行区域开始处由一个10个线程的线程组执行,后来并发的嵌套并行区域则为10个线程中的每一个线程分派有10个线程的线程组来执行内部并行区域。因此在嵌套并行区域内部总计有100条线程执行。

Num threads in nested region is = 10

Num threads in nested region is = 10

Num threads in nested region is = 10

Num threads in nested region is = 10

Num threads in nested region is = 10

Num threads in nested region is = 10

Num threads in nested region is = 10

Num threads in nested region is = 10

Num threads in nested region is = 10

Num threads in nested region is = 10

同步与锁

OpenMP内含用以帮助代码同步的运行时例程;且内含两种类型的锁——简单的和可嵌套的,每一种都可以有三种状态——未初始化、已上锁和未上锁。

简单锁(omp_lock_t)不可以多次上锁,即使是同一线程也不允许。除了当线程尝试给已经持有的锁上锁时不会阻塞外,可嵌套锁(omp_nest_lock_t)与简单锁没有不同。另外,可嵌套锁使用引用计数并且知道已经被上锁了几次。

同步例程能够作用于锁,每一个例程都有简单锁和可嵌套锁变量。可以对锁实行以下五个操作:initialize(初始化)、set(上锁)、unset(解锁)、test(测试)和destory(销毁)。这些与Win32临界区例程非常相似——事实上OpenMP就是通过在它们上层进行简单封装来实现的。图7展示了OpenMP例程与Win32例程的对应关系。

OpenMP Simple Lock

OpenMP Nested Lock

Win32 Routine

omp_lock_t

omp_nest_lock_t

CRITICAL_SECTION

omp_init_lock

omp_init_nest_lock

InitializeCriticalSection

omp_destroy_lock

omp_destroy_nest_lock

DeleteCriticalSection

omp_set_lock

omp_set_nest_lock

EnterCriticalSection

omp_unset_lock

omp_unset_nest_lock

LeaveCriticalSection

omp_test_lock

omp_test_nest_lock

TryEnterCriticalSection

(图7OpenMPWin32的锁例程对比

开发人员能够在同步例程和同步编译器指令之间任选其一。编译器指令的优势是它们非常结构化,这让它们变得易懂并且容易从程序上测定你的同步区域的入口与出口。

同步例程的优势是它们的伸缩性。你能把锁通过参数传递给函数,在函数中这个锁可以被上锁或者解锁。这是编译器指令无法做到的。通常情况下应该选择使用编译器指令,除非你需要只有使用运行时例程才能够得到的伸缩性。

数据结构遍历并行化

8展示了两个并行执行迭代次数未知的for循环的例子,第一个例子是遍历一个STLstd::vector窗口,另一个是标准链表。

#pragma omp parallel

{

// Traversing an STL vector in parallel

std::vector<int>::iterator iter;

for(iter = xVect.begin(); iter != xVect.end(); ++iter)

{

#pragma omp single nowait

{

process1(*iter);

}

}

// Walking a standard linked-list in parallel

for(LList *listWalk = listHead; listWalk != NULL;

listWalk = listWalk->next)

{

#pragma omp single nowait

{

process2(listWalk);

}

}

}

(图8)处理可变次数的循环迭代

在例子的STL部分,线程组的每一条线程都执行for循环并且拥有自己的迭代器拷贝。但每一次迭代时都只有一条线程进入循环体里的single代码块(语义上的single)。在运行时OpenMP执行了用以确保single块被且仅被执行一次“魔法”。这种方式的迭代的开销是巨大的,因此只有处理函数要做很多工作的时候才值得使用这种方式。链表的例子也是一样的逻辑,就不多言了。

值得一提的是STLstd::vector那个例子里我们可以在需要进入循环之前用std::vector::size测定迭代次数,这样我们就可以重写代码为OpenMP规范的for循环形式,如下面的代码所示:

#pragma omp parallel for

for(int i = 0; i < xVect.size(); ++i)

process(xVect[i]);

因为这种方式的运行时开销要小得多,所以我们建议在数组、vector和其它任何能够使用OpenMP规范的for循环遍历的时候使用这一方式。

高级调度算法

默认情况下,OpenMP在并行化for循环的时候使用一个名为静态调度的线程调度算法,这一算法使得线程组的每一条线程获得同样多的迭代次数;如果有n次迭代和T条线程,那每一条线程就得到n/T次迭代(OpenMP可以正确处理n不被T整除的情况)。但是OpenMP也提供了一些其它的调度机制以适应不同的需要:动态调度、运行时调度和导向(guided)调度。

指定其它调度的方法是使用#pragma omp for或者#pragma omp parallel指令的schedule子句。这个子句的格式如下:

schedule(schedule-algorithm[, chunk-size])

下面是一些例子:

#pragma omp parallel for schedule(dynamic, 15)

for(int i = 0; i < 100; ++i)

{ ...

#pragma omp parallel

#pragma omp for schedule(guided)

动态调度让每一条线程执行通过块大小(chunk-size)(默认为1)指定数量的迭代。当线程执行完交给它的迭代,它就请求再次执行chunk-size次迭代,直到所有迭代结束。显而易见,最后一次迭代可能少于chunk-size次。

导向调度是让每一条线程执行的迭代次数与线程数成比例:

iterations_to_do = max(iterations_not_assigned/omp_get_num_threads(), chunk-size)

当线程执行完交给它的迭代任务,它请求基于iterations_to_do这一公式的数量的迭代。因此交给线程的迭代次数递减,最后一次迭代调度的次数可能少于tierations_to_do函数定义的值。

下面是使用#pragma omp for schedule(dynamic, 15)指令调度4条线程处理100次迭代的过程:

Thread 0 gets iterations 1-15

Thread 1 gets iterations 16-30

Thread 2 gets iterations 31-45

Thread 3 gets iterations 46-60

Thread 2 finishes

Thread 2 gets iterations 61-75

Thread 3 finishes

Thread 3 gets iterations 76-90

Thread 0 finishes

Thread 0 gets iterations 91-100

接下来是使用#pragma omp for schedule(guided, 15)指令调度4条线程处理100次迭代的过程:

Thread 0 gets iterations 1-25

Thread 1 gets iterations 26-44

Thread 2 gets iterations 45-59

Thread 3 gets iterations 60-64

Thread 2 finishes

Thread 2 gets iterations 65-79

Thread 3 finishes

Thread 3 gets iterations 80-94

Thread 2 finishes

Thread 2 gets iterations 95-100

动态调度和导向调度是当每一次迭代的工作量不尽相同时或者处理器的速度快慢不一时完美的调度机制。使用静态调度是无法达到这样的迭代负载平衡的。动态和导向调度通过它们非常自然的工作自动地平衡迭代负载。特别地,导向调度由于更少的调度开销而比动态调度有更好性能。

最后要讨论的是运行时调度,确切来说它并不是调度算法,但有时是

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics