OpenMP中的“MP”代表多处理,是一个与共享内存并行编程同义的术语。
OpenMP:系统中的每个线程或进程都有可能访问所有的内存区域。
Pthreads要求程序员显式地明确每个线程的行为。相反,OpenMP有时允许程序员只需要简单地声明一块代码应该并行执行,而由编译器和运行时系统决定哪个线程具体执行哪个任务。
Pthreads与MPI一样,是一个能够被链接到C程序的函数库,因此只要系统有Pthreads库,Pthreads程序就能够被任意C编译器使用。相反,OpenMP要求编译器支持某些操作,所以完全有可能你是用的编译器无法把OpenMP程序编译成并行程序。
共享内存为什么会有两个标准API:Pthreads更底层,并且提供了抽象地编写任何可知线程行为的能力。代价则是:每个线程行为的细节都得由我们来定义。相反,OpenMP允许编译器和运行时系统来决定线程行为的一些细节,因此,使用OpenMP来编写一些行为是更容易,代价是很难对底层的线程进行交互编程。
事实上,OpenMP明确地被设计成可以用来对已有的串行代码进行增量式并行化,这对于MPI不可能,对于Pthreads也是相当困难的。
利用OpenMP最强大的功能中的一个:只需要对源代码进行少量改动就可以并行化许多串行的for循环。以及OpenMP的一些其他特征:任务并行化和显式线程同步。也会看到共享内存编程的标准问题:缓存对共享内存的影响,以及串行代码被一个共享内存程序使用时遇到的问题。
5.1 预备知识
OpenMP提供“基于指令”的共享内存API。在C和C++中,有一些特殊的预处理器指令#pragma开头。pragma的默认长度是一行,如果一行放不下,那么新行需要被”转义”---前面加一个反斜杠\。
第一个例子:hello
#include <stdio.h>
#include <stdlib.h>
#include <omp.h>
void Hello(void);
int main()
{
int thread_count = omp_get_num_procs();
#pragma omp parallel num_threads(thread_count)
Hello();
return 0;
}
void Hello(void)
{
int my_rank = omp_get_thread_num();
int thread_count = omp_get_num_procs();
printf("Hello from thread: %2d of %d\n", my_rank, thread_count);
}
5.1.1 编译和运行OpenMP程序
gcc -g -Wall -fopenmp -o omp_hello omp_hello.cpp
5.1.2 程序
OpenMP的pragma总是以#pragma omp开头。
使用parallel是用来表明之后的结构化代码块(structured block,也可以称为基本块)应该被多个线程并行执行。一般而言只有一个入口和一个出口,但在这个代码块中允许调用exit函数。这个定义简单地禁止分支语句进入或离开结构化代码块。
线程(thread)是执行线程(thread of execution)的简写。这个名字表示被一个程序执行的一系列语句。典型的线程被同一个进程派生(fork),这些线程共享进程的大部分资源,但每个线程有它自己的栈和程序计数器。当一个线程完成了执行,它就又合并(join)到启动它的进程中。
运行结构化代码块的线程将由运行时系统决定。
在OpenMP中,子句只是一些用来修改指令的文本。
#pragma omp parallel num_thread(thread_count)
执行并行块的线程集合(原始的线程和新的线程)称为线程组(team),原始的线程称为主线程master,额外的线程称为从线程slave。每个线程组中的线程都执行parallel指令后的代码块。
当代码块执行完时有一个隐式路障,意味着完成代码块的线程将等待线程组中的所有其他线程完成代码块。
5.2 梯形积分法
第一个OpenMP版本
计算sum_global += my_result时。存在一个竞争性条件(race condition)。多个线程试图访问一个共享资源,并且至少其中一个访问是更新该共享资源,这可能会导致错误。
引起竞争条件的代码sum_global += my_result,称为临界区。临界区是一个被多个更新共享资源的线程执行的代码,并且共享资源一次只能被一个线程更新。
在OpenMP中,提供了critical指令。该指令告诉编译器需要安排线程对下列的代码块进行互斥访问。即一次只有一个线程能够执行下面的结构化代码。
#include <stdio.h>
#include <stdlib.h>
#include <omp.h>
float function(float);
float Trap(float, float, int);
float TrapParallelFor(float, float, int);
int main()
{
int left = 2, right = 100, n = 100000;
float sum_global = 0;
int thread_count = omp_get_num_procs();
// critical标识临界区
#pragma omp parallel num_threads(thread_count)
{
float my_sum = Trap(left, right, n);
#pragma omp critical
sum_global += my_sum;
}
// sum_global = TrapParallelFor(left, right, n);
printf("%.2f", sum_global);
return 0;
}
float function(float x)
{
return x * x;
}
float Trap(float left_end, float right_end, int n)
{
float left_local = 0.0, right_local = 0.0, x = 0.0;
float height = (right_end - left_end) / n;
// 获取线程号
int my_rank = omp_get_thread_num();
int thread_count = omp_get_num_threads();
// 划分区间
int n_local = (n + thread_count - 1) / thread_count;
left_local = left_end + my_rank * n_local * height;
right_local = left_local + n_local * height;
float sum_local = (function(left_local) + function(right_local)) / 2;
// 将共享变量线程化为私有变量,线程内求和,减少访问共享变量次数
for (int i = 0; i < n_local; ++i)
{
x = left_local + i * height;
if (x < right_end)
sum_local += function(x);
}
sum_local *= height;
return sum_local;
}
5.3 变量的作用域
在OpenMP中,变量的作用域涉及在parallel块中能够访问该变量的线程的集合。一个能够被线程组中所有的线程访问的变量拥有共享作用域,而一个只能被单个线程访问的变量拥有私有作用域。
在parallel块之前被声明的变量的默认作用域是共享的。而在块中声明的变量拥有私有作用域。在parallel块开始处的共享变量的值,与该变量在parallel块之前的值一样。在parallel块完成之后,该变量的值是块结束的值。
OpenMP提供了改变缺省作用域的子句。
5.4 归约(reduction)子句
OpenMP还提供归约子句。将sum_global定义为一个归约变量。归约操作符(reduction operator)是一个二元操作,例如,加法减法。归约就是将相同的归约操作符重复地应用到操作数序列来得到一个结果的计算。另外,所有操作的中间结果都存储在同一个变量:归约变量(reduction variable)。
在OpenMP中,可以指定一个归约变量来表示归约的结果。需要在parallel指令中添加一个reduction子句。其语法是:
reduction(<operator>:<variable list>)
operator可以是+, *, -, &, |, &&, ||中的任意一个。
当一个变量被包含在reduction子句中,变量本身是共享的。然而,线程组中的每个线程都创建自己的私有变量。在parallel块里,每当一个线程执行涉及到这个变量的语句时,它使用的其实是私有变量。当parallel块结束后,私有变量中的值被整合到一个共享变量中。
// 规约操作
#pragma omp parallel num_threads(thread_count) reduction(+: sum_global)
sum_global += Trap(left, right, n); //将函数返回类型改写成return sum_local
线程私有化变量初始为0。一般来说,根据不同的操作符,reduction子句创建的私有变量初始化为相同的值。例如,如果是乘法,则是1.
5.5 parallel for指令
OpenMP提供的parallel for指令。方式是直接在for循环前放置一条指令。在parallel for指令之后的结构化代码块必须是for循环。系统会通过在线程间划分循环迭代来并行化for循环。
在一个已经被parallel for指令并行化的for循环中,线程间的缺省划分方式是系统决定的。大部分系统会粗略地使用块划分。
5.5.1 警告
OpenMP只能并行化确定迭代次数的for循环。无限循环、break不行。
1. 由for语句本身来确定;
2. 在循环之前确定;
float TrapParallelFor(float left_end, float right_end, int n)
{
float left_local = 0.0, right_local = 0.0, x = 0.0;
float height = (right_end - left_end) / n;
// 获取线程号
int my_rank = omp_get_thread_num();
int thread_count = omp_get_num_threads();
// 划分区间
int n_local = (n + thread_count - 1) / thread_count;
left_local = left_end + my_rank * n_local * height;
right_local = left_local + n_local * height;
float sum_local = (function(left_local) + function(right_local)) / 2;
// 将共享变量线程化为私有变量,线程内求和,减少访问共享变量次数
float sum_global = 0;
#pragma omp parallel for num_threads(thread_count) reduction(+: sum_global)
for (int i = 0; i < n_local; ++i)
{
x = left_local + i * height;
if (x < right_end)
sum_local += function(x);
}
sum_local *= height;
sum_global += sum_local;
return sum_global;
}
事实上,OpenMP只能并行化具有典型结构的for循环。
5.5.2 数据依赖性
一个更隐匿的问题在循环中,迭代中的计算依赖于一个或更多个之前的迭代结果。例如前n个斐波那契数列数:
fibo[0] = fibo[1] = 1;
for (int i = 2; i < n; ++i)
fibo[i] = fibo[i - 1] + fibo[i - 2];
有两个要点:
1. OpenMP编译器不检查被Parallel for指令并行化的循环所包含的迭代间的依赖关系,而是由程序员来识别这些依赖关系。
2. 一个或更多迭代结果依赖于其他迭代的循环,一般不能被OpenMP正确地并行化。
Fibo[6]和Fibo[5]计算间的依赖关系称为数据依赖(data dependence)。由于Fibo[5]的值在一个迭代中计算,其结果也在之后的迭代中使用,该依赖关系有时称为循环依赖(loop-carried dependence)。
5.5.3 寻找循环依赖
当我们试图使用parallel for指令时,首先应该注意的是:要小心发现循环依赖。我们一般不需要担心一般的数据依赖。例如,
for (int i = 0; i < n; ++i)
{
x[i] = a + i * h;
y[i] = exp(x[i]);
}
y与x之间存在数据依赖,但是这个数据依赖,不影响并行化。
for (int i = 0; i < n; ++i)
{
x[i] = a + i * h;
y[i] = exp(x[i - 1]);
}
但如果改一下,以下的数据依赖就不是那么容易并行化了。
5.5.4 pi值估计
sum += factor / (2 * k + 1);
factor = -factor;
上述代码存在循环依赖,但改成下面,就能消除循环依赖。
factor = (k % 2 == 0) ? 1.0 : -1.0;
sum += factor / (2 * k + 1);
parallel for指令并行化的块中,缺省情况下任何循环前声明的变量在线程间都是共享的(循环变量除外)。因此,在消除factor的循环之后,还需要保证每个线程都有factor的私有副本。也就是要保证factor的作用域。可以通过添加一个rpivate子句到parallel指令中来实现这一目标。
#pragma ... private(factor)
要记住的一点是,一个有私有作用域的变量的值在parallel语句块的开始处是未指定值的。
5.5.5 关于作用域的更多问题
OpenMP提供了一个子句default,该子句显式地要求我们决定每个变量的作用域。那么编译器将要求我们明确在这个块中使用的每个变量和已经在块之外声明的变量的作用域。
#pragma ... default(none)
sum也是一个归约变量,factor和k应该有私有作用域,从未在parallel块中更新的变量,如n,应该有共享作用域。最后的pi求和代码如下:
#include "omp.h"
#include<stdio.h>
#include <stdlib.h>
#include <time.h>
void omp_sum_pi(int n)
{
double factor = 1.0; //会被初始化为0
double sum = 0.0;
int k;
int thread_count = omp_get_num_procs();
#pragma omp parallel for num_threads(thread_count) \
default(none) reduction(+: sum) private(k, factor) \
shared(n)
for (k = 0; k < n; ++k)
{
factor = (k % 2 == 0) ? 1.0 : -1.0;
sum += factor / (2 * k + 1);
}
printf("%f", 4 * sum);
}
int main()
{
const int n = 1e4;
omp_sum_pi(n);
return 0;
}
5.6 更多关于OpenMP的循环:排序
5.6.1 冒泡排序
for (list_length = n; list_length < 2; --list_length)
for (int i = 0; i < list_length -1; ++i)
if (a[i] > a[i + 1])
{
temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
}
显然,在外部循环中有一个循环依赖,在外部循环的任何一次迭代中,当前列表的内容依赖于外部循环的前一次迭代。内部循环也容易发现。在第i此迭代中,被比较的元素依赖于第i-1次迭代。
假设我们不清楚怎样在不完全重写算法的情况下移除任何一个循环依赖。记住,即使我们能发现循环依赖,但有可能不能移除它。
5.6.2 奇偶变换排序
for (phase = 0; phase < n; ++phase)
if (phase % 2 == 0)
{
for (i = 1; i < n; i += 2)
{
if (arr[i - 1] > arr[i])
{
swap(a[i -1], a[i])
}
}
}
else
{
for (i = 1; i < n - 1; i += 2)
{
if (arr[i] > arr[i + 1])
{
swap(a[i], a[i + 1])
}
}
}
不难看出外部循环有一个循环依赖。但是,内部循环并没有任何循环依赖。在尝试并行化奇偶变化排序,会遇见一些问题。首先,尽管任何一个偶阶段迭代并不依赖这个阶段的其他迭代,但是还需要注意,对p阶段和p+1阶段却不是这样的。因此需要p阶段完成后,才能进行p+1阶段。所幸parallel for指令在循环结束后会有一个隐形的路障。
其次,是创建和合并线程的开销。所以我们希望每次执行内部循环时,使用同样数量的线程。因此只创建一次线程,并在每次内部循环时重用它们。OpenMP的做法是,用parallel指令在外部循环前创建thread_count个线程的集合。然后,我们不在每次内部循环执行时创建一组新的线程,而是使用一个for指令,告诉OpenMP用已有的线程组来并行化for循环。
与parallel for指令不同的是,for指令并不创建任何线程。它使用已经在paralllel块中创建的线程。在循环的末尾有一个隐式的路障。
#include "omp.h"
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
void swap(float&a , float&b )
{
temp = a;
a = b;
b = temp;
}
void omp_odd_even_sort(float* arr, const int n)
{
// 此时求得的sizeof(arr) / sizeof(arr[0]) 由于在pragma指令前面,会被初始化为0,失效了
int phase = 0, i = 0;
float temp = 0.0;
int thread_count = omp_get_num_procs();
#pragma omp parallel num_threads(thread_count) default(none) \
shared(arr, n) private(i, temp, phase)
for (phase = 0; phase < n; ++phase)
{
if (phase % 2 == 0)
{
#pragma omp for
for (i = 1; i < n; i += 2)
{
if (arr[i - 1] > arr[i])
{
swap(a[i -1], a[i])
}
}
}
else
{
#pragma omp for
for (i = 1; i < n - 1; i += 2)
{
if (arr[i] > arr[i + 1])
{
swap(a[i], a[i + 1])
}
}
}
}
}
void omp_odd_even_sort_test(void)
{
const int n = 50;
float arr[n];
srand(time(0));
for (int i = 0; i < n; ++i)
arr[i] = static_cast<float>(rand()) / RAND_MAX;
for (int i = 0; i < n; ++i)
printf("%f\n", arr[i]);
printf("sorted!\n");
omp_odd_even_sort(arr, n);
for (int i = 0; i < n; ++i)
printf("%f\n", arr[i]);
}
int main()
{
omp_odd_even_sort_test();
return 0;
}
5.7 循环调度
不难想象的是,块分配不是万能的。假设某一个调用函数f是与参数i的大小成正比,那么分配块分配就失效了。可以推测到,一个好的迭代分配能够对性能有很大的影响。在OpenMP中,将循环分配给线程称为调度,Schedule子句用在prallel for或者for指令中进行迭代分配。
5.7.1 schedule子句
字句形式如下:
schedule(<type> [, <chunksize>])
type可以是下列任意一个:
1.static:迭代能够在循环执行前分配给线程。
2.dynamic或guided:迭代在循环执行时被分配给线程,因此在一个线程完成了它的当前迭代集合后,它能从运行时系统请求更多。
3.auto:编译器和运行时系统决定调度方式。
4.runtime:调度在运行时决定。
chunksize是一个正整数。
5.7.2 static调度类型
#pragma ... schedule(static, 4)
对于static调度,系统以轮转的方式分配chunksize块个迭代给每个线程。如果chunksize省略了,那么chunksize近似等于total_iterations / thread_count;
5.7.3 dynamic调度和gudied调度
在dynamic调度中,迭代也被分成chunksize个连续迭代的块。每个线程执行一个块,并且当一个线程完成一个块时,它将从运行时系统请求另一块,直到所有的迭代完成。chunksize被忽略时,其值为1。
在guided调度中,每个线程也执行一个块,并且当一个线程完成一个块时,将请求另一个块。然而,在guided调度中,当块完成后,新块的大小会变小。块的大小近似等于剩下的迭代数除以线程数。
5.7.4 runtime调度类型
环境变量是能够被运行时系统所访问的命名值,即它们在程序的环境中是可得的。当Schedule(runtime)指定时,系统使用环境变量OMP_SCHEDULE会呈现任何被static、dynamic或guided调度所使用的值。那么如果使用bash shell,就能通过以下命令将一个循环分配所得到的迭代分配给线程:就如同使用schedule(static,1)一样。
$ export OMP_SCHEDULE = 'static,1'
5.7.5 调度选择
实际上,每种schedule子句有不同的系统开销。gudied > dynamic>static。
我们可能会发现,最优的调度方式是由线程的个数和迭代次数共同决定的。
在某些情况下,应该优先考虑某些调度:
1.如果循环的每次迭代需要几乎相同的计算量,那么可能默认的调度方式能提供最好的性能。
2.如果伴随着循环的进行,迭代的计算量线性递增或递减,那么采用比较小chunksize的static调度可能会提供最好的性能。
3.如果每次迭代的开销事先不能确定,那么可能尝试多种不同的调度策略。此时应当使用schedule(runtime)子句,通过赋予环境变量OMP_SCHEDULE不同的值来比较不同调度策略下程序的性能。
5.8 生产者和消费者问题
5.8.1 队列
队列是一种抽象的数据结构,插入元素时将元素插入到队列的尾部,而读取元素时,队列的头部元素被返回并从队列中移除。
5.8.2 消息传递
生产者和消费者问题模型的另一个应用就是在共享内存系统上实现消息传递。
一个案例是,每个线程随机产生整数”消息“和消息的目标线程。当创建一条消息后,线程将消息加入到合适的消息队列中。当发送消息之后,该线程检查自己的消息队列以获知它是否收到了消息,如果它收到了消息,它将队首的消息出队并打印该消息。伪代码如下:
//
for (sent_msgs = 0; sent_msgs < send_max; sent_msgs++)
{
Send_msg();
Try_receive();
}
while(!Done())
Try_receive();
5.8.3 发送消息
需要注意的是,访问消息队列并将消息入队,可能是一个临界区。可能需要一个变量来跟踪队列的尾部。当一条新消息入队时,需要检查和更新这个队尾指针。如果两个线程试图同时进行操作,那么可能会丢失一条已经有其中一个线程入队的消息。因此,入队操作形成了临界区,伪代码如下:
mesg = random();
dest = random() % thread_count;
#pragma omp critical
Enqueue(queue, dest, my_rank, mesg);
5.8.4 接收信息
如果消息队列中至少有两条消息,那么只要每次只出队一条消息,那么出队操作和入队操作就不可能冲突。因此如果队列中至少有两条消息,通过跟踪对的大小可以避免任何同步。使用两个变量记录即可。
queue_size = enqueued - dequeued;
唯一能更新dequeued的线程是消息队列的拥有者。可以按照以下方式实现Try_receive;
queue_size = enqueued - dequeued;
if (queue_size == 0)
return;
else if (queue_size == 1)
#pragma omp critical
Dequeue(queue, &src, &meg);
else
Dequeue(queue, &src, &meg);
Print_message(src, mesg);
5.8.5 终止检测
探讨实现Done函数,一个隐藏问题的实现如下:其中问题是线程u执行这段代码时,计算出queue_sIze = 0后将终止,线程v向线程u发送的消息将永远不会被收到。
queue_size = enqueued - dequeued;
if (queue_size == 0)
return TRUE;
else
return false;
增加一个计数器done_sending,每个线程在for循环后将该计数器加1,修改之后如下:
queue_size = enqueued - dequeued;
if (queue_size == 0 && done_sending == thread_count)
return TRUE;
else
return false;
5.8.6 启动
消息队列至少存储:
1.消息列表;
2.队尾指针或索引;
3.队首指针或索引;
4.入队消息的数目;
5.出队消息的数目;
另外,我们还必须保证任何一个线程都必须在所有现场都完成了队列分配后才开始发送消息。OpenMP提供了显式路障。当遇见路障时,它将被阻塞,直到所有的线程都到达了这个路障。
#pragma omp barrier
#include "omp.h"
#include <stdio.h>
#include <stdlib.h>
#include <queue>
using std::queue;
const int send_max = 100;
const int thread_count = omp_get_num_threads();
struct MesgQueue
{
int* mesg;
int enqueued, dequeued;
static queue<int> queue_msg;
omp_lock_t front_mutex, back_mutex;
};
MesgQueue* Msg = new MesgQueue[thread_count];
void init(MesgQueue* MQ)
{
MQ->mesg = new int[send_max];
MQ->dequeued = 0;
MQ->enqueued = 0;
omp_init_lock(&MQ->front_mutex);
omp_init_lock(&MQ->back_mutex);
}
void Enqueue(const int dest, const int message)
{
omp_set_lock(&Msg[dest].back_mutex);
Msg[dest].queue_msg.push(message);
Msg[dest].enqueued++;
omp_unset_lock(&Msg[dest].back_mutex);
}
void Dequeue(const int cur_p)
{
omp_set_lock(&Msg[cur_p].front_mutex);
*(Msg[cur_p].mesg) = Msg[cur_p].queue_msg.front();
Msg[cur_p].queue_msg.pop();
Msg[cur_p].dequeued++;
omp_set_lock(&Msg[cur_p].front_mutex);
}
void Send_msg()
{
int mesg = rand();
int dest = rand() % thread_count;
Enqueue(dest, mesg);
}
void Receive_msg()
{
int cur_p = omp_get_thread_num();
int queue_size = Msg[cur_p].enqueued - Msg[cur_p].dequeued;
if (queue_size == 0)
return;
else if (queue_size == 1)
#pragma omp critical
Dequeue(cur_p);
else
Dequeue(cur_p);
}
int Done()
{
int cur_p = omp_get_thread_num();
int queue_size = Msg[cur_p].enqueued - Msg[cur_p].dequeued;
if (queue_size == 0 && done_sending == thread_count)
return true;
else
return false;
}
5.8.7 atomic指令
对于done_sending的增量是临界区,可以通过critical指令来保护它。然而,OpenMP提供了另一种可能更加高效的指令:atomic指令;
#pragma omp atomic
与critical指令不同,它只能保护由一条C语言赋值语句所形成的临界区。此外,此语句必须是以下几种形式之一:
x <op> = <expression>
x++;
++x;
x--;
--x;
<op>可以是任意二元操作符:
+ - * / & ^ | << >>
需要注意的是,只有x的加载和存储可以受保护的。另一个操作数并没有受到保护。
5.8.8 临界区和锁
强制线程间的互斥使程序的执行串行化。OpenMP默认的做法是将所有的临界区代码作为复合临界区的一部分,这可能非常不利于程序性能。OpenMP提供了向critical指令添加名字的选项:
#pragma omp critical(name)
采用命名方式,两个用不同名字的critical指令保护的代码块就可以同时执行。
当我们想让访问不同队列的线程可以同时访问相同的代码块时,被命名为critical指令就不能满足需求了。
解决方案是使用锁lock。锁是由一个数据结构和定义在这个数据结构上的函数组成,这些函数使得程序员可以显式地强制对临界区进行互斥访问。
初始化锁;
上锁;
临界区;
释放锁;
销毁锁;
OpenMP有两种锁:简单(simple)锁和嵌套(nested)锁。简单锁在被释放前只能获得一次,二嵌套锁在被释放前可以被同一个线程获得多次。
void omp_init_lock(omp_lock_t* lock_p);
void omp_set_lock(omp_lock_t* lock_p);
void omp_unset_lock(omp_lock_t* lock_p);
void omp_destory_lock(omp_lock_t* lock_p);
5.8.9 在消息传递程序中使用锁
我们想要确保对每个消息队列进行互斥访问,而不是对于一个特定的代码块。锁可以实现需求。将omp_lock_t类型的数据包含在队列结构中,可以通过简单地调用omp_set_lock()函数来确保对消息队列的互斥访问。
// 发送消息
/* q_p = msg_queues[dest] */
omp_set_lock(&q_p->lock);
Enqueue(q_p, my_rank, mesg);
omp_unset_lock(&q_p->lock);
// 接受信息
/* q_p = msg_queues[my_rank] */
omp_set_lock(&q_p->lock);
Dnqueue(q_p, &src, &mesg);
omp_unset_lock(&q_p->lock);
5.8.10 cirtical指令、atomic指令、锁的比较
此三种机制可以实现对临界区的访问。
一般而言,atomic指令是实现互斥访问最快的方法。因此,如果临界区是由特殊的赋值语句组成,则使用atomic指令至少不比其他方法慢。
如果程序中有多个不同由atomic指令保护的临界区,则应当使用命名的critical指令或者锁。
使用critical和使用锁保护临界区在性能没有太大区别。
锁机制适用于需要互斥的访问某个数据结构而不是代码块时。
如果三种机制都能使用,使用优先级为atomic > critical > lock。
5.8.11 经验
(1) 对同一临界区不应当混合使用不同的互斥机制。
#pragma omp atomic #prgma omp critical
x+= f(y); x = g(x);
(2) 互斥不保证公平性,也就是说可能某个线程会被一直阻塞以等待对某个临界区的执行。
while (1)
{
#pragma omp critical
x = g(my_rank);
}
(3) 嵌套互斥结构可能会产生意料不到的结果。这段代码肯定会产生死锁。当一个线程试图进入第二个临界区时,它将会被永远阻塞。
#pragma omp critical
y = f(x);
...
double f(double x){
#pragma omp critical
z = g(x); // z是共享变量
}
5.11 小结
OpenMP最重要的特色之一就是他的设计是程序员逐步并行化已有的串行程序,而不是从零开始编写程序。
OpenMP程序使用多线程而不是多进程。线程比进程更轻量级,除了拥有自己的栈和程序计数器外,同一个进程的线程可以共享该进程几乎所有的资源。
使用OpenMP需包含头文件omp.h。
#pragma omp parallel指令告诉运行时系统并行执行下面的结构块,即派生或启动多个线程来执行该结构快。结构化块是只有一个入口和一个出口的代码块,除了exit出口。
执行并行化结构块的所有线程称为线程组。组中有一个线程在parallel指令之前执行,叫做主线程,其余被parallel指令启动的线程,叫做从线程。
OpenMP指令可以被子句(clauses)修改。
OpenMP提供多种机制实现对临界区访问:
1. critical指令确保一次只有一个线程执行结构化代码块。程序中可以使用命名的critical指令,名字不同的临界区能被同时执行。
2. atomic指令只能在x<op> = <expression>、前缀后缀递增递减的临界区。运行速度要比其它两种要快。
3. 简单锁是最通用的互斥访问某数据结构的方式。
for指令可以将for循环中的迭代在线程间进行划分。这个指令并不开启线程,而是利用已有线程组内的线程。此时的for循环必须没有任何形式的循环依赖。
OpenMP提供调度子句:
1. staitic: 迭代在循环开始前就已经分配给线程。
2. dynamic: 迭代在执行过程中分配给线程,执行完迭代块后请求下一个迭代块。开销大。
3. guided: 迭代在执行过程中分配给线程,迭代块会变化。
4. runtime: 由环境变量OMP_SCHEDULE的值来决定。
5. auto: 由操作系统自己决定。
在OpenMP中,变量的作用域是可以访问该变量的线程的集合。在parallel指令之前的变量在parallel指令的内部构造是共享作用域。在parallel指令的内部构造声明的变量是私有作用域。作为一个经验法则,显式地赋予变量作用域是一个很好的注意。作用域子句default(none)。private,shared()。
唯一的例外是归约变量。parallel内是线程私有的,parallel结束后希望私有变量结合到一个单独的,公有的变量中。
OpenMP提供了归约子句reduction(<op>:variable_name)。
缓存一致性、伪共享和线程安全性。