日本免费高清视频-国产福利视频导航-黄色在线播放国产-天天操天天操天天操天天操|www.shdianci.com

學無先后,達者為師

網站首頁 編程語言 正文

詳解OpenMP的線程同步機制_C 語言

作者:一無是處的研究僧 ? 更新時間: 2023-03-19 編程語言

前言

在本篇文章當中主要給大家介紹 OpenMP 當中線程的同步和互斥機制,在 OpenMP 當中主要有三種不同的線程之間的互斥方式:

  • 使用 critical 子句,使用這個子句主要是用于創建臨界區和 OpenMP 提供的運行時庫函數的作用是一致的,只不過這種方法是直接通過編譯指導語句實現的,更加方便一點,加鎖和解鎖的過程編譯器會幫我們實現。
  • 使用 atomic 指令,這個主要是通過原子指令,主要是有處理器提供的一些原子指令實現的。
  • OpenMP 給我們提供了 omp_lock_t 和 omp_nest_lock_t 兩種數據結構實現簡單鎖和可重入鎖。

在本篇文章當中主要討論 OpenMP 當中的互斥操作,在下一篇文章當中主要討論 OpenMP 當中原子操作的實現原理,并且查看程序編譯之后的匯編指令。

自定義線程之間的同步 barrier

在實際的寫程序的過程當中我們可能會有一種需求就是需要等待所有的線程都執行完成之才能夠進行后面的操作,這個時候我們就可以自己使用 barrier 來實現這個需求了。

比如我們要實現下面的一個計算式:

現在我們計算 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://www.cnblogs.com/Chang-LeHung/p/17063442.html

欄目分類
最近更新