OpenMP 執行緒同步 Construct 實現原理以及原始碼分析(上)

2023-01-28 06:00:48

OpenMP 執行緒同步 Construct 實現原理以及原始碼分析(上)

前言

在本篇文章當中主要給大家介紹在 OpenMP 當中使用的一些同步的 construct 的實現原理,如 master, single, critical 等等!並且會結合對應的組合程式進行仔細的分析。(本篇文章的組合程式分析基於 x86_86 平臺)

Flush Construct

首先先了解一下 flush construct 的語法:

#pragma omp flush(變數列表)

這個構造比較簡單,其實就是增加一個記憶體屏障,保證多執行緒環境下面的資料的可見性,簡單來說一個執行緒對某個資料進行修改之後,修改之後的結果對其他執行緒來說是可見的。

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

int main()
{
  int data = 100;
#pragma omp parallel num_threads(4) default(none) shared(data)
  {
#pragma omp flush(data)
  }
  return 0;
}

上面是一個非常簡單的 OpenMP 的程式,根據前面的文章 OpenMp Parallel Construct 實現原理與原始碼分析 我們可以知道會講並行域編譯成一個函數,我們現在來看一下這個編譯後的組合程式是怎麼樣的!

gcc-4 編譯之後的結果

00000000004005f6 <main._omp_fn.0>:
  4005f6:       55                      push   %rbp
  4005f7:       48 89 e5                mov    %rsp,%rbp
  4005fa:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  4005fe:       0f ae f0                mfence 
  400601:       5d                      pop    %rbp
  400602:       c3                      retq   
  400603:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  40060a:       00 00 00 
  40060d:       0f 1f 00                nopl   (%rax)

從上面的結果我們可以看到最終的一條指令是 mfence 這是一條 full 的記憶體屏障,用於保障資料的可見性,主要是 cache line 中資料的可見性。

gcc-11 編譯之後的結果

0000000000401165 <main._omp_fn.0>:
  401165:       55                      push   %rbp
  401166:       48 89 e5                mov    %rsp,%rbp
  401169:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40116d:       f0 48 83 0c 24 00       lock orq $0x0,(%rsp)
  401173:       5d                      pop    %rbp
  401174:       c3                      retq   
  401175:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  40117c:       00 00 00 
  40117f:       90                      nop

從編譯之後的結果來看,這個組合程式主要是使用 lock 指令實現可見性,我們知道 lock 指令是用來保證原子性的,但是事實上這同樣也可以保證可見性,試想一下如果不保證可見性是不能夠保證原子性的!因為如果這個執行緒看到的資料都不是最新修改的資料的話,那麼即使操作是原子的那麼也達不到我們想要的效果。

上面兩種方式的編譯結果的主要區別就是一個使用 lock 指令,一個使用 mfence 指令,實際上 lock 的效率比 mfence 效率更高因此在很多場景下,現在都是使用 lock 指令進行實現。

在我的機器上下面的程式碼分別使用 gcc-11 和 gcc-4 編譯之後執行的結果差異很大,gcc-11 大約使用了 11 秒,而 gcc-4 編譯出來的結果執行了 20 秒,這其中主要的區別就是 lock 指令和 mfence 指令的差異。

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

int main()
{
  double start = omp_get_wtime();
  for(long i = 0; i < 1000000000L; ++i)
  {
    __sync_synchronize();
  }
  printf("time = %lf\n", omp_get_wtime() - start);
  return 0;
}

Master Construct

master construct 的使用方法如下所示:

#pragma omp master

事實上編譯器會將上面的編譯指導語句編譯成與下面的程式碼等價的組合程式:

if (omp_get_thread_num () == 0)
  block // master 的程式碼塊

我們現在來分析一個實際的例子,看看程式編譯之後的結果是什麼:

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

int main()
{
#pragma omp parallel num_threads(4) default(none)
  {
#pragma omp master
    {
      printf("I am master and my tid = %d\n", omp_get_thread_num());
    }
  }
  return 0;
}

上面的程式編譯之後的結果如下所示(組合程式的大致分析如下):

000000000040117a <main._omp_fn.0>:
  40117a:       55                      push   %rbp
  40117b:       48 89 e5                mov    %rsp,%rbp
  40117e:       48 83 ec 10             sub    $0x10,%rsp
  401182:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  401186:       e8 a5 fe ff ff          callq  401030 <omp_get_thread_num@plt> # 得到執行緒的 id 並儲存到 eax 暫存器當中
  40118b:       85 c0                   test   %eax,%eax # 看看暫存器 eax 是不是等於 0
  40118d:       75 16                   jne     4011a5  <main._omp_fn.0+0x2b> # 如果不等於 0 則跳轉到 4011a5 的位置 也就是直接退出程式了 如果是那麼就繼續執行後面的 printf 語句
  40118f:       e8 9c fe ff ff          callq  401030 <omp_get_thread_num@plt>
  401194:       89 c6                   mov    %eax,%esi
  401196:       bf 10 20 40 00          mov    $0x402010,%edi
  40119b:       b8 00 00 00 00          mov    $0x0,%eax
  4011a0:       e8 9b fe ff ff          callq  401040 <printf@plt>
  4011a5:       90                      nop
  4011a6:       c9                      leaveq 
  4011a7:       c3                      retq   
  4011a8:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
  4011af:       00 

這裡我們只需要瞭解一下 test 指令就能夠理解上面的組合程式了,"test %eax, %eax" 是 x86 組合語言中的一條指令,它的含義是對暫存器 EAX 和 EAX 進行邏輯與運算,並將結果儲存在狀態暫存器中,但是不改變 EAX 的值。這條指令會影響標誌位(如 ZF、SF、OF),可用於判斷 EAX 是否等於零。

從上面的組合程式分析我們也可以知道,master construct 就是一條 if 語句,但是後面我們將要談到的 single 不一樣他還需要進行同步。

Critical Construct

#pragma omp critical

首先我們需要了解的是 critical 的兩種使用方法,在 OpenMP 當中 critical 子句有以下兩種使用方法:

#pragma omp critical
#pragma omp critical(name)

需要了解的是在 OpenMP 當中每一個 critical 子句的背後都會使用到一個鎖,不同的 name 對應不同的鎖,如果你使用第一種 critical 的話,那麼就是使用 OpenMP 預設的全域性鎖,需要知道的是同一個時刻只能夠有一個執行緒獲得鎖,如果你在你的程式碼當中使用全域性的 critical 的話,那麼需要注意他的效率,因為在一個時刻只能夠有一個執行緒獲取鎖。

首先我們先分析第一種使用方式下,編譯器會生成什麼樣的程式碼,如果我們使用 #pragma omp critical 那麼在實際的組合程式當中會使用下面兩個動態庫函數,GOMP_critical_start 在剛進入臨界區的時候呼叫,GOMP_critical_end 在離開臨界區的時候呼叫。

void GOMP_critical_start (void);
void GOMP_critical_end (void);

我們使用下面的程式進行說明:

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

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

根據我們前面的一些文章的分析,並行域在經過編譯之後會被編譯成一個函數,上面的程式在進行編譯之後我們得到如下的結果:

00000000004011b7 <main._omp_fn.0>:
  4011b7:       55                      push   %rbp
  4011b8:       48 89 e5                mov    %rsp,%rbp
  4011bb:       48 83 ec 10             sub    $0x10,%rsp
  4011bf:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  4011c3:       e8 b8 fe ff ff          callq  401080 <GOMP_critical_start@plt>
  4011c8:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  4011cc:       8b 00                   mov    (%rax),%eax
  4011ce:       8d 50 01                lea    0x1(%rax),%edx
  4011d1:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  4011d5:       89 10                   mov    %edx,(%rax)
  4011d7:       e8 54 fe ff ff          callq  401030 <GOMP_critical_end@plt>
  4011dc:       c9                      leaveq 
  4011dd:       c3                      retq   
  4011de:       66 90                   xchg   %ax,%ax

從上面的反組合結果來看確實呼叫了 GOMP_critical_start 和 GOMP_critical_end 兩個函數,並且分別是在進入臨界區之前和離開臨界區之前呼叫的。在 GOMP_critical_start 函數中會進行加鎖操作,在函數 GOMP_critical_end 當中會進行解鎖操作,在前面我們已經提到過,這個加鎖和解鎖操作使用的是 OpenMP 內部的預設的全域性鎖。

我們看一下這兩個函數的源程式:

void
GOMP_critical_start (void)
{
  /* There is an implicit flush on entry to a critical region. */
  __atomic_thread_fence (MEMMODEL_RELEASE);
  gomp_mutex_lock (&default_lock); // default_lock 是一個 OpenMP 內部的鎖
}

void
GOMP_critical_end (void)
{
  gomp_mutex_unlock (&default_lock);
}

從上面的程式碼來看主要是呼叫 gomp_mutex_lock 進行加鎖操作,呼叫 gomp_mutex_unlock 進行解鎖操作,這兩個函數的內部實現原理我們在前面的文章當中已經進行了詳細的解釋說明和分析,如果大家感興趣,可以參考這篇文章 OpenMP Runtime Library : Openmp 常見的動態庫函數使用(下)——深入剖析鎖