关于openmp:深入理解-OpenMP-线程同步机制

60次阅读

共计 12213 个字符,预计需要花费 31 分钟才能阅读完成。

深刻了解 OpenMP 线程同步机制

前言

在本篇文章当中次要给大家介绍 OpenMP 当中线程的同步和互斥机制,在 OpenMP 当中次要有三种不同的线程之间的互斥形式:

  • 应用 critical 子句,应用这个子句次要是用于创立临界区和 OpenMP 提供的运行时库函数的作用是统一的,只不过这种办法是间接通过编译领导语句实现的,更加不便一点,加锁和解锁的过程编译器会帮咱们实现。
  • 应用 atomic 指令,这个次要是通过原子指令,次要是有处理器提供的一些原子指令实现的。
  • OpenMP 给咱们提供了 omp_lock_t 和 omp_nest_lock_t 两种数据结构实现简略锁和可重入锁。

在本篇文章当中次要探讨 OpenMP 当中的互斥操作,在下一篇文章当中次要探讨 OpenMP 当中原子操作的实现原理,并且查看程序编译之后的汇编指令。

自定义线程之间的同步 barrier

在理论的写程序的过程当中咱们可能会有一种需要就是须要期待所有的线程都执行实现之才可能进行前面的操作,这个时候咱们就能够本人应用 barrier 来实现这个需要了。

比方咱们要实现上面的一个计算式:

$$
data = \frac{1! + 2! + … + n!}{n}
$$

当初咱们计算 n = 16 的时候下面的表达式的值:

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

int factorial(int n)
{
   int s = 1;
   for(int i = 1; i <= n; ++i)
   {s *= i;}
   return s;
}

int main()
{int data[16];
#pragma omp parallel num_threads(16) default(none) shared(data)
   {int id = omp_get_thread_num();
      data[id] = factorial(id + 1);
      // 期待下面所有的线程都实现的阶乘的计算
#pragma omp barrier
      long sum = 0;
#pragma omp single
      {for(int i = 0; i < 16; ++i)
         {sum += data[i];
         }
         printf("final value = %lf\n", (double) sum / 16);
      }
   }
   return 0;
}

在下面的代码当中咱们首先让 16 个线程都计算实现对应的阶乘后果之后而后在求和进行除法操作,因而在进行除法操作之前就须要将所有的阶乘计算实现,在这里咱们就能够应用 #pragma omp barrier 让所有的线程达到这个同步点之后才持续实现后执行,这样就保障了在进行前面的工作的时候所有线程计算阶乘的工作曾经实现。

定义临界区 critical

在并发程序当中咱们常常会有这样的需要,比方不同的线程须要对同一个数据进行求和操作,当然这个操作咱们也能够通过 atomic constuct 来实现,然而在本篇文章当中咱们应用临界区来实现,在下一篇实现当中咱们将仔细分析 OpenMP 当中的原子操作。

比方咱们当初有一个数据 data,而后每个线程须要对这个数据进行加操作。

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

int main() {
   int data = 0;
#pragma omp parallel num_threads(10) shared(data) default(none)
   {
#pragma omp critical
      {data++;}
   }
   printf("data = %d\n", data);
   return 0;
}

在下面的 critical 结构当中咱们执行了 data ++ 这条语句,如果咱们不应用 critical construct 的话,那么就可能两个线程同时操作 data++ 这条语句,那么就会造成后果的不正确性,因为如果两个线程同时读取 data 的值等于 0,而后两个线程同时进行 ++ 操作让 data 的值都变成 1,再写回,那么 data 的最终后果将会是 1,然而咱们冀望的后果是两个线程进行相加操作之后值变成 2,这就不对了,因而咱们须要应用 critical construct 保障同一时刻只可能有一个线程进行 data++ 操作。

咱们晓得临界区的实现是应用锁实现的,当咱们应用 #pragma omp critical 的时候,咱们默认是应用的 OpenMP 外部的默认锁实现的,如果你在其余中央也应用 #pragma omp critical 的话应用的也是同一把锁,因而即便你用 #pragma omp critical 创立多个临界区你应用的也是同一把锁,也就是说这多个临界区在同一时刻也只会有一个线程在一个临界区执行,其余的临界区是没有线程在执行的,因为所有的临界区应用同一把锁,而一个时刻只可能有一个线程取得锁。

为了解决下面所谈到的问题,在 OpenMP 当中应用 critical 结构代码块的时候咱们能够指定一个名字,以此用不同的锁在不同的临界区。

咱们当初对下面的状况进行验证,在上面的程序当中一共有 4 个 section,首先咱们须要晓得的是不同的 section 同一个时刻能够被不同的线程执行的,每一个线程只会被执行一次,如果有线程执行过了,那么它将不会再被执行。

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

int main()
{#pragma omp parallel num_threads(4) default(none)
   {
#pragma omp sections
      {
#pragma omp section
         {
#pragma omp critical
            {printf("tid = %d time stamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
               sleep(2);
            }
         }

#pragma omp section
         {
#pragma omp critical
            {printf("tid = %d time stamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
               sleep(2);
            }
         }

#pragma omp section
         {
#pragma omp critical
            {printf("tid = %d time stamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
               sleep(2);
            }
         }

#pragma omp section
         {
#pragma omp critical
            {printf("tid = %d time stamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
               sleep(2);
            }
         }
      }
   }
   return 0;
}

下面的程序输入后果如下所示:

tid = 3 time stamp = 22875738.972305
tid = 0 time stamp = 22875740.972508
tid = 2 time stamp = 22875742.974888
tid = 1 time stamp = 22875744.975045

从下面程序的输入后果咱们能够晓得,每一次程序的输入都距离了 2 秒,这就阐明了,所有的打印都是在等之前的线程执行实现之后才执行的,这也就从侧面阐明了,同一个时刻只可能有一个线程获取到锁,因为应用的是 #pragma omp critical 所有的临界区都是用同一个锁——默认锁。

当初咱们批改下面的程序,每一个 critical construct 都应用一个名字进行润饰,让每一个临界区应用的锁不同:

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

int main()
{#pragma omp parallel num_threads(4) default(none)
   {
#pragma omp sections
      {
#pragma omp section
         {#pragma omp critical(A)
            {printf("tid = %d time stamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
               sleep(2);
            }
         }
#pragma omp section
         {#pragma omp critical(B)
            {printf("tid = %d time stamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
               sleep(2);
            }
         }

#pragma omp section
         {#pragma omp critical(C)
            {printf("tid = %d time stamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
               sleep(2);
            }
         }

#pragma omp section
         {#pragma omp critical(D)
            {printf("tid = %d time stamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
               sleep(2);
            }
         }
      }
   }
   return 0;
}

下面的程序的输入后果如下所示:

tid = 1 time stamp = 22876121.253737
tid = 3 time stamp = 22876121.253737
tid = 0 time stamp = 22876121.253737
tid = 2 time stamp = 22876121.253754

从下面程序的输入后果来看,简直在同一个时刻所有的 printf 语句被执行。也就是说这些临界区之间并不互斥,这也就说名了不同的临界区应用的锁是不同的。

深刻了解 barrier

在上一大节当中咱们提到了 critical 能够应用一个名字进行命名,那么就能够使得不同的临界区应用不同的锁,这样能够进步程序的执行效率。那么在 OpenMP 当中是否共享 barrier,咱们在后面介绍了 #pragma omp barrier 是否是全局所有的线程共享应用的呢?答案是不共享,因而 barrier 不须要指定名字,咱们在应用 barrier 的时候每个并行域的线程组都有一个本人的 barrier。咱们能够通过上面的程序进行剖析。

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

int main()
{omp_set_nested(1);
#pragma omp parallel num_threads(2) default(none)
   {int parent_id = omp_get_thread_num();
      printf("tid = %d\n", parent_id);
      sleep(1);
#pragma omp barrier
#pragma omp parallel num_threads(2) shared(parent_id) default(none)
      {sleep(parent_id + 1);
         printf("parent_id = %d tid = %d\n", parent_id, omp_get_thread_num());
#pragma omp barrier
         printf("after barrier : parent_id = %d tid = %d\n", parent_id, omp_get_thread_num());
      }
   }
   return 0;
}

下面的程序其中的一个输入如下所示:

tid = 0
tid = 1
parent_id = 0 tid = 0
parent_id = 0 tid = 1
after barrier : parent_id = 0 tid = 0
after barrier : parent_id = 0 tid = 1
parent_id = 1 tid = 0
parent_id = 1 tid = 1
after barrier : parent_id = 1 tid = 0
after barrier : parent_id = 1 tid = 1

依据下面的程序输入后果咱们能够晓得,首先 omp_set_nested(1) 启动并行嵌套,内部并行域有两个线程,这两个线程回别离创立两个新的并行域,每个并行域外面都会有一个新的线程组,每个线程组都会有属于本人的 barrier 变量,也就是说和其余的线程组中的 barrier 是无关的,因而当并行域 2 中的两个线程都达到 barrier 之后就会立马执行最初一个 printf 语句,而不须要期待并行域 3 中的线程 sleep 实现,而下面的程序的输入后果也印证了这一点。在下面的代码当中并行域 2 中的线程只须要 sleep 1 秒,并行域 3 中的线程须要 sleep 2 秒,因而并行域 2 中的线程会先打印,并行域 3 中的线程会后打印。

依据下面的剖析和图解大抵阐明了下面的对于 barrier 代码的执行流程,更多对于 barrier 的实现细节咱们在前面进行 OpenMP 源码剖析的时候再进行剖析。

master construct

在 OpenMP 当中还有一个比拟实用的指令 master 这个指令的含意次要是代码块只有 master 线程才会执行,其余线程都不会执行。所谓 master 线程就是一个线程组当中线程号等于 0 的线程。

你可能会感觉这个和 single 比拟类似,然而和 single 不同的是这个指令最初并没有一个同步点,而 single 会有一个暗藏的同步点,只有所有的线程到同步点之后线程才会持续往后执行,咱们剖析上面的代码。

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

int main()
{#pragma omp parallel num_threads(4) default(none)
  {
#pragma omp master
    {sleep(1);
      printf("In master construct tid = %d timestamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
    }
    printf("Out master construct tid = %d timestamp = %lf\n", omp_get_thread_num(), omp_get_wtime());
  }
  return 0;
}

下面的程序的输入后果如下所示:

Out master construct tid = 3 timestamp = 22892756.871450
Out master construct tid = 2 timestamp = 22892756.871457
Out master construct tid = 1 timestamp = 22892756.871494
In master construct tid = 0 timestamp = 22892757.871576
Out master construct tid = 0 timestamp = 22892757.871614

从下面的输入后果咱们能够看到,非 master 线程的工夫戳简直是一样的也就是说他们简直是同时运行的,而 master 线程则是 sleep 1 秒之后才进行输入的,而且 master 中的语句只有 master 线程执行,这也就印证了咱们所谈到的内容。

single construct

在应用 OpenMP 的时候,可能会有一部分代码咱们只须要一个线程去执行,这个时候咱们能够时候 single 指令,single 代码块只会有一个线程执行,并且在 single 代码块最初会有一个同步点,只有 single 代码块执行实现之后,所有的线程才会持续往后执行。咱们当初来剖析一下上面的程序:

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

int main()
{#pragma omp parallel num_threads(4) default(none)
  {double start = omp_get_wtime();
#pragma omp single
    {printf("In single tid = %d", omp_get_thread_num());
      sleep(5);
      printf("cost time = %lf\n", omp_get_wtime() - start);
    }

    printf("Out single tid = %d cost time = %lf\n", omp_get_thread_num(), omp_get_wtime() - start);
  }
  return 0;
}

下面的程序的输入后果如下所示:

In single tid = 3 cost time = 5.000174
Out single tid = 3 cost time = 5.000229
Out single tid = 0 cost time = 5.000223
Out single tid = 2 cost time = 5.002116
Out single tid = 1 cost time = 5.002282

从下面的程序的输入后果咱们能够看到,所有的打印语句输入的时候和 start 都相差了差不多 5 秒钟的工夫,这次要是因为在 single 代码块当中线程 sleep 了 5 秒中。尽管只有一个线程执行 single 代码块,然而咱们能够看到所有的线程都话费了 5 秒钟,这正是因为在 single 代码块之后会有一个暗藏的同步点,只有并行域中所有的代码达到同步点之后,线程才可能持续往后执行。

ordered construct

odered 指令次要是用于 for 循环当中的代码块必须依照循环的迭代秩序来执行。因为在循环当中有些区域是能够并行处理的,然而咱们的业务须要在某些代码串行执行(这里所谈到的串行执行的意思是依照循环的迭代秩序,比如说 for(int i = 0; i < 10; ++i) 这个秩序就是必须依照 i 从 0 到 9 的秩序执行代码),这样才可能保障逻辑上的正确性。

比方上面的例子:

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

int main()
{#pragma omp parallel num_threads(4) default(none)
  {
#pragma omp for ordered
    for(int i = 0; i < 8; ++i)
    {
#pragma omp ordered
      printf("i = %d", i);
    }
  }
  return 0;
}

下面的程序的输入后果如下所示:

i = 0 i = 1 i = 2 i = 3 i = 4 i = 5 i = 6 i = 7 

下面的程序的输入后果肯定是下面的样子,相对不会产生任何程序上的变动,这正是因为 ordered 的成果,他保障了线程的执行程序必须依照循环迭代秩序来。

OpenMP 中的线程同步机制

在这一大节当中次要剖析 OpenMP 当中的一些结构语句中的同步关系—— single, sections, for,并且打消这些指令造成的线程之间的同步。

Sections 应用 nowait

在 OpenMP 当中 sections 次要是使不同的线程同时执行不同的代码块,然而在每个 #pragma omp sections 区域之后有一个暗藏的同步代码块,也就是说只有所有的 section 被执行实现之后,并且所有的线程都达到同步点,线程才可能继续执行,比方在上面的代码当中,printf("tid = %d finish sections\n", omp_get_thread_num()) 语句只有后面的 sections 块全副被执行实现,所有的线程才会开始执行这条语句,依据这一点在下面的 printf 语句执行之前所有的 section 当中的语句都会被执行。

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

int main()
{#pragma omp parallel num_threads(4) default(none)
   {
#pragma omp sections
      {
#pragma omp section
         {int s = omp_get_thread_num() + 1;
            sleep(s);
            printf("tid = %d sleep %d seconds\n", s, s);
         }
#pragma omp section
         {int s = omp_get_thread_num() + 1;
            sleep(s);
            printf("tid = %d sleep %d seconds\n", s, s);
         }
#pragma omp section
         {int s = omp_get_thread_num() + 1;
            sleep(s);
            printf("tid = %d sleep %d seconds\n", s, s);
         }
#pragma omp section
         {int s = omp_get_thread_num() + 1;
            sleep(s);
            printf("tid = %d sleep %d seconds\n", s, s);
         }
      }

      printf("tid = %d finish sections\n", omp_get_thread_num());
   }
   return 0;
}

下面的代码其中的一种输入后果如下所示:

tid = 1 sleep 1 seconds
tid = 2 sleep 2 seconds
tid = 3 sleep 3 seconds
tid = 4 sleep 4 seconds
tid = 3 finish sections
tid = 2 finish sections
tid = 0 finish sections
tid = 1 finish sections

下面的输入后果是合乎咱们的预期的,所有的 section 中的 printf 语句打印在最初一个 printf 后面,这是因为 sections 块之后又一个暗藏的同步点,只有所有的线程达到同步点之后程序才会持续往后执行。

从下面的剖析来看,很多时候咱们是不须要一个线程执行实现之后期待其余线程的,也就是说如果一个线程的 section 执行实现之后而且没有其余的 section 没有被执行,那么咱们就不用让这个线程挂起继续执行前面的工作,在这种状况下咱们就能够应用 nowait,应用的编译领导语句是 #pragma omp sections nowait,具体的代码如下所示:

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

int main()
{#pragma omp parallel num_threads(4) default(none)
   {
#pragma omp sections nowait
      {
#pragma omp section
         {int s = omp_get_thread_num() + 1;
            sleep(s);
            printf("tid = %d sleep %d seconds\n", s, s);
         }
#pragma omp section
         {int s = omp_get_thread_num() + 1;
            sleep(s);
            printf("tid = %d sleep %d seconds\n", s, s);
         }
#pragma omp section
         {int s = omp_get_thread_num() + 1;
            sleep(s);
            printf("tid = %d sleep %d seconds\n", s, s);
         }
#pragma omp section
         {int s = omp_get_thread_num() + 1;
            sleep(s);
            printf("tid = %d sleep %d seconds\n", s, s);
         }
      }

      printf("tid = %d finish sections\n", omp_get_thread_num());
   }
   return 0;
}

下面的程序的输入后果如下所示:

tid = 1 sleep 1 seconds
tid = 0 finish sections
tid = 2 sleep 2 seconds
tid = 1 finish sections
tid = 3 sleep 3 seconds
tid = 2 finish sections
tid = 4 sleep 4 seconds
tid = 3 finish sections

从下面的输入后果咱们能够看到,当一个线程的 section 代码执行实现之后,这个线程就立刻执行最初的 printf 语句了,也就是说执行实现之后并没有期待其余的线程,这就是咱们想要的成果。

Single 应用 nowait

在 OpenMP 当中应用 single 指令示意只有一个线程执行 single 当中的代码,然而须要理解的是在 single 代码块最初 OpenMP 也会帮咱们生成一个暗藏的同步点,只有执行 single 代码块的线程执行实现之后,所有的线程才可能持续往后执行。比方上面的示例程序:

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

int main()
{double start = omp_get_wtime();
#pragma omp parallel num_threads(4) default(none) shared(start)
   {
#pragma omp single
      sleep(5);
      printf("tid = %d spent %lf s\n", omp_get_thread_num(), omp_get_wtime() - start);
   }
   double end = omp_get_wtime();
   printf("execution time : %lf", end - start);
   return 0;
}

在下面的代码当中启动了 4 个线程,在 single 的代码块当中须要 sleep 5 秒钟,因为下面的代码不带 nowait,因而尽管之后一个线程执行 sleep(5),然而因为其余的线程须要期待这个线程执行实现,因而所有的线程都须要期待 5 秒。因而能够判断下面的代码输入就是每个线程输入的时间差都是 5 秒左右。具体的下面的代码执行后果如下所示:

tid = 2 spent 5.002628 s
tid = 3 spent 5.002631 s
tid = 0 spent 5.002628 s
tid = 1 spent 5.005032 s
execution time : 5.005076

从下面的输入后果来看正合乎咱们的预期,每个线程破费的工夫都是 5 秒左右。

当初咱们应用 nowait 那么当一个线程执行 single 代码块的时候,其余线程就不须要进行期待了,那么每个线程破费的工夫就非常少。咱们看上面的应用 nowait 的程序的输入后果:

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

int main()
{double start = omp_get_wtime();
#pragma omp parallel num_threads(4) default(none) shared(start)
   {
#pragma omp single nowait
      sleep(5);
      printf("tid = %d spent %lf s\n", omp_get_thread_num(), omp_get_wtime() - start);
   }
   double end = omp_get_wtime();
   printf("execution time : %lf", end - start);
   return 0;
}

下面的代码执行后果如下所示:

tid = 2 spent 0.002375 s
tid = 0 spent 0.003188 s
tid = 1 spent 0.003202 s
tid = 3 spent 5.002462 s
execution time : 5.002538

能够看到的是线程 3 执行了 single 代码块然而其余的线程并没有执行,而咱们也应用了 nowait 因而每个线程破费的工夫会非常少,这也是合乎咱们的预期。

For 应用 nowait

for 的原理其实和下面两个应用形式也是一样的,都是不须要在同步点进行同步,而后间接执行前面的代码。话不多说间接看代码

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

int main()
{double start = omp_get_wtime();
#pragma omp parallel num_threads(4) default(none) shared(start)
   {
#pragma omp for
      for(int i = 0; i < 4; ++i)
      {sleep(i);
      }
      printf("tid = %d spent %lf s\n", omp_get_thread_num(), omp_get_wtime() - start);
   }
   double end = omp_get_wtime();
   printf("execution time : %lf", end - start);
   return 0;
}

在下面的程序当中启动的一个 for 循环,有四个线程去执行这个循环,依照默认的调度形式第 i 个线程对应的 i 的值就是等于 i 也就是说,最长的一个线程 sleep 的工夫为 3 秒,然而 sleep 1 秒或者 2 秒和 3 秒的线程须要进行期待,因而下面的程序的输入后果大略都是 3 秒左右。具体的后果如下图所示:

tid = 0 spent 3.003546 s
tid = 1 spent 3.003549 s
tid = 2 spent 3.003558 s
tid = 3 spent 3.003584 s
execution time : 3.005994

当初如果咱们应用 nowait 那么线程不须要进行期待,那么线程的话费工夫大略是 0 秒 1 秒 2 秒 3 秒。

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

int main()
{double start = omp_get_wtime();
#pragma omp parallel num_threads(4) default(none) shared(start)
   {
#pragma omp for nowait
      for(int i = 0; i < 4; ++i)
      {sleep(i);
      }
      printf("tid = %d spent %lf s\n", omp_get_thread_num(), omp_get_wtime() - start);
   }
   double end = omp_get_wtime();
   printf("execution time : %lf", end - start);
   return 0;
}

查看上面的后果,也是符号咱们的预期的,因为线程之间不须要进行期待了。

tid = 0 spent 0.002358 s
tid = 1 spent 1.004497 s
tid = 2 spent 2.002433 s
tid = 3 spent 3.002427 s
execution time : 3.002494

总结

在本篇文章当中次要给大家介绍了一些常常应用的 OpenMP 用于线程之间同步的指令,并且用理论例子剖析它外部的工作机制,以及咱们改如何应用 nowait 优化程序的性能,以上就是本篇文章的所有内容心愿大家有所播种!


更多精彩内容合集可拜访我的项目:https://github.com/Chang-LeHu…

关注公众号:一无是处的钻研僧,理解更多计算机(Java、Python、计算机系统根底、算法与数据结构)常识。

正文完
 0