计算机 / 读书笔记 · 2021年12月19日 0

OpenMP多线程读书笔记

八、OpenMP多线程编程及性能优化

OpenMP是一种面向共享内存以及分布式共享内存的多处理器多线程并行编程语言。OpenMP是一种能够被用于显示制导多线程、共享内存并行的应用程序编程接口(API)。

OpenMP的规范由SGI发起… OpenMP最初是为共享内存的多处理器系统设计的并行编程方法,这与通过消息传递进行并行编程的模型有很大的不同。

OpenMP工作原理和工作方式

OpenMP的执行模式采用Fork-Join的形式。

Fork-Join执行模式在开始执行的时候,只有一个叫做主线程的运行线程在。主线程在运行过程中,当遇到需要进行并行计算的时候,派生出(Fork,创建新线程或者从线程池中唤醒已有线程)线程来执行并行任务。在并行执行的时候,主线程和派生线程共同工作。在并行代码结束执行后,派生线程退出或者挂起,不再工作,控制流程回到单独的主线程中(Join,即多线程的汇合)。

主线程在运行的过程中,遇到并行编译制导语句,根据环境变量、实际需要(比如循环的迭代次数)派生出若干个线程。此时,此线程与主线程同时运行。在运行的过程中,某一个派生线程遇到了另外一个并行编译制导语句,派生出了另外一组线程。新的线程组共同完成一项任务,但对于原有的线程来说,新线程组的工作类似于一块串行程序,对原有的线程不会产生影响。新线程组在通过一个隐含的同步屏障后(barrier),汇合(join)成原有的线程。最后,原有的线程组汇合成主线程,执行完最后的程序代码并退出。

OpenMP程序同时结合了两种并行编程的方式。通过编译制导语句,我们可以将串行的程序逐步地改造成一个并行程序,达到增量更新程序的目的,减少程序编写人员一定的负担。同时,这种方式也能够将串行程序和并行程序保持在同一个源代码文件当中,减少了维护的负担。但是,由于是编译制导语句,其优势体现在编译的阶段,对于运行阶段则支持较少。因此,OpenMP也提供了运行时库函数来支持运行时对并行环境的改变和优化,给编程人员足够的灵活性来控制运行时的程序运行状况。。但这种方式打破了源代码在串行和并行之间的一致性。

简言之,OpenMP应用程序由3个部分组成:编译制导语句,运行时库函数以及环境变量。其中编译制导语句是OpenMP组成中最重要的部分,也是编写OpenMP程序的关键。

编译制导语句

在C/C++程序中,OpenMP的所有编译制导语句以#pragma omp开始,后面跟具体的功能指令。即具有如下的形式:

#pragma omp <directive> [clause[[,]clause]...]

directive部分包含了具体的编译制导语句,包括:

  1. parallel
  2. for
  3. parallel for
  4. section
  5. sections
  6. single
  7. master
  8. critical
  9. flush
  10. ordered
  11. atomic

5个编译制导语句(master,critical,flush,ordered,atomic)不能跟相应的子句。

使用举例

在我的Ubuntu18.04上用gcc7.3.0测试时,编译时需要带上-fopenmp参数,编译器才能生成相应的并行代码,否则编译制导语句不会起作用。编译出来的程序运行结果和没有这些编译制导语句差不多。

1.并行区域编译制导语句

对于下面的helloworld.c程序,程序在执行到#pragma omp parallel时,会派生出多个子线程来执行该代码块(包含原来的所谓主线程),在执行完该程序块后再通过barrier同步、Join到主线程。

#include <stdio.h>

int main(int argc, char **argv)
{

#pragma omp parallel
    for (int i = 0; i < 5; i++) {
        printf("hello world i=%d\n", i);
    }


    return 0;
}

程序在执行到#pragma omp parallel时,总共会有多少个子线程来执行这个代码块呢?OpenMP按照以下顺序决定线程的个数:

  1. 系统缺省值;
  2. 环境变量OMP_NUM_THREADS
  3. 程序中使用OpenMP的API设置;

上面程序在设置OMP_NUM_THREADS为2时,其输出为:

hello world i=0 hello world i=1 hello world i=2 hello world i=3 hello world i=4 hello world i=0 hello world i=1 hello world i=2 hello world i=3 hello world i=4

可以看到,OpenMP是以复制到OMP_NUM_THREADS个线程的方式来执行跟在#pragma omp parallel后面的代码块。

而对于下面的循环并行化代码,OpenMP则是用任务分配的方式执行的,将循环所需要的所有工作量按照一定的方式分配到各个执行线程中,所有线程执行工作的总和是原先串行执行所完成的工作量。

#include <stdio.h>

int main(int argc, char **argv)
{

#pragma omp parallel for
    for (int i = 0; i < 5; i++) {
        printf("hello world i=%d\n", i);
    }


    return 0;
}


其输出为:

hello world i=0 hello world i=1 hello world i=2 hello world i=3 hello world i=4

2.控制变量在线程间的共享属性

#include <stdio.h>

int counter = 100;

#pragma omp threadprivate(counter)

void inc_counter()
{
    counter++;
}

int main(int argc, char **argv)
{
    #pragma omp parallel
    {
        for (int i = 0; i < 10000; i++)
            inc_counter();
        printf("counter=%d\n", counter);
    }

    return 0;
}

上面的代码通过threadprivate子句声明全局变量counter是线程私有数据,在程序运行的过程中不能够被其他线程访问到(和ThreadLocal类似)。上面代码的输出为:

counter=10100 counter=10100

上面的代码初始化counter变量是在main函数之前初始化的,所以OpenMP派生的子线程的私有counter变量也被初始化为相应的值,但是如果是在main函数内部进行初始化,那么就需要copyin指令来显示将主线程该变量的值拷贝到其他线程:

#include <stdio.h>
#include <omp.h>

int global = 0;

#pragma omp threadprivate(global)

int main(int argc, char **argv)
{
    global = 1000;
    
    #pragma omp parallel copyin(global)
    {
        printf("global=%d\n", global);
        global = omp_get_thread_num();
    }

    printf("global=%d\n", global);
    printf("parallel again\n");

    #pragma omp parallel
    printf("global=%d\n", global);

    return 0;
}

可以看出,通过copyin的操作,确实将线程的私有化变量初始化为主线程中相应的全局变量的值。在并行区域执行完毕退出后,主线程与子线程中的相应的全局变量继续有效,并且在再一次进入并行区域时,使用上一次退出时所赋的值。

3.工作分区编码sections

#include <stdio.h>
#include <omp.h>

int main(int argc, char **argv)
{
    #pragma omp parallel sections
    {
        #pragma omp section
            printf("section 1 thread=%d\n", omp_get_thread_num());
        #pragma omp section
            printf("section 2 thread=%d\n", omp_get_thread_num());
        #pragma omp section
            printf("section 3 thread=%d\n", omp_get_thread_num());
    }

    return 0;
}

上面的代码中,section标注的几段代码分别在不同的线程中并行执行。

4.线程同步

OpenMP中,提供了3种不同的互斥锁机制用来对一块内存进行保护,分别是临界区、原子操作和库函数。

  1. 临界区
#pragma omp critical [(name)]
block

使用临界区的格式如上,可以为不同的临界区取不同的名字,这样只有多个线程在访问同一个临界区时才会产生互斥。

  1. 原子操作
#pragma omp atomic
x <binop>= expr
或者:
#pragma omp atomic
x++/x--/++x/--x;

原子操作只能对builtin的基本数据类型有效(这样才可以转化成一条相应的机器指令),可以实现原子操作的运算有:

+ * - / & ^ | << >>

需要值得注意的一点是当对一个数据进行原子操作保护的时候,就不能对数据进行临界区的保护,这是两种不同的保护机制,OpenMP运行时并不能在这两种保护机制之间建立配合机制。