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

一無是處的研究僧發表於2023-01-28

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 常見的動態庫函式使用(下)——深入剖析鎖?原理與實現

#pragma omp critical(name)

如果我們使用命令的 critical 的話,那麼呼叫的庫函式和前面是不一樣的,具體來說是呼叫下面兩個庫函式:

void GOMP_critical_name_end (void **pptr);
void GOMP_critical_name_start (void **pptr);

其中 pptr 是指向一個指向鎖的指標,在前面的文章 OpenMP Runtime Library : Openmp 常見的動態庫函式使用(下)——深入剖析鎖?原理與實現 當中我們仔細討論過這個鎖其實就是一個 int 型別的變數。這個變數在編譯期間就會在 bss 節分配空間,在程式啟動的時候將其初始化為 0 ,表示沒上鎖的狀態,關於這一點在上面談到的文章當中有仔細的討論。

這裡可能需要區分一下 data 節和 bss 節,.data 節是用來存放程式中定義的全域性變數和靜態變數的初始值的記憶體區域。這些變數的值在程式開始執行前就已經確定。.bss 節是用來存放程式中定義的全域性變數和靜態變數的未初始化的記憶體區域。這些變數在程式開始執行前並沒有初始化的值。在程式開始執行時,這些變數會被系統自動初始化為0。總的來說,.data 存放已初始化資料,.bss存放未初始化資料。

我們現在來分析一個命名的 critical 子句他的彙編程式:

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

int main()
{
  int data = 0;
#pragma omp parallel num_threads(4) default(none) shared(data)
  {
#pragma omp critical(A)
    {
      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:       bf 58 40 40 00          mov    $0x404058,%edi
  4011c8:       e8 a3 fe ff ff          callq  401070 <GOMP_critical_name_start@plt>
  4011cd:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  4011d1:       8b 00                   mov    (%rax),%eax
  4011d3:       8d 50 01                lea    0x1(%rax),%edx
  4011d6:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  4011da:       89 10                   mov    %edx,(%rax)
  4011dc:       bf 58 40 40 00          mov    $0x404058,%edi
  4011e1:       e8 4a fe ff ff          callq  401030 <GOMP_critical_name_end@plt>
  4011e6:       c9                      leaveq 
  4011e7:       c3                      retq   
  4011e8:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)

從上面的結果我們可以看到在呼叫函式 GOMP_critical_name_start 時,傳遞的引數的值為 0x404058 (顯然這個就是在編譯的時候就確定的),我們現在來看一下 0x404058 位置在哪一個節。

根據 x86 的呼叫規約,rdi/edi 暫存器儲存的就是呼叫函式的第一個引數,而在函式 GOMP_critical_name_start 被呼叫之前我們可以看到 edi 暫存器的值是 0x404058 ,(mov $0x404058,%edi) 因此 pptr 指標的值就是 0x404058 。

為了確定指標指向的資料的位置我們可以檢視節頭表當中各個節在可執行程式當中的位置,判斷 0x404058 在哪個節當中,上面的程式的節頭表如下所示:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         00000000004002a8  000002a8
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.gnu.build-i NOTE             00000000004002c4  000002c4
       0000000000000024  0000000000000000   A       0     0     4
  [ 3] .note.ABI-tag     NOTE             00000000004002e8  000002e8
       0000000000000020  0000000000000000   A       0     0     4
  [ 4] .gnu.hash         GNU_HASH         0000000000400308  00000308
       0000000000000060  0000000000000000   A       5     0     8
  [ 5] .dynsym           DYNSYM           0000000000400368  00000368
       00000000000001e0  0000000000000018   A       6     1     8
  [ 6] .dynstr           STRTAB           0000000000400548  00000548
       0000000000000111  0000000000000000   A       0     0     1
  [ 7] .gnu.version      VERSYM           000000000040065a  0000065a
       0000000000000028  0000000000000002   A       5     0     2
  [ 8] .gnu.version_r    VERNEED          0000000000400688  00000688
       0000000000000050  0000000000000000   A       6     2     8
  [ 9] .rela.dyn         RELA             00000000004006d8  000006d8
       0000000000000018  0000000000000018   A       5     0     8
  [10] .rela.plt         RELA             00000000004006f0  000006f0
       0000000000000090  0000000000000018  AI       5    22     8
  [11] .init             PROGBITS         0000000000401000  00001000
       000000000000001a  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         0000000000401020  00001020
       0000000000000070  0000000000000010  AX       0     0     16
  [13] .text             PROGBITS         0000000000401090  00001090
       00000000000001d2  0000000000000000  AX       0     0     16
  [14] .fini             PROGBITS         0000000000401264  00001264
       0000000000000009  0000000000000000  AX       0     0     4
  [15] .rodata           PROGBITS         0000000000402000  00002000
       000000000000001b  0000000000000000   A       0     0     8
  [16] .eh_frame_hdr     PROGBITS         000000000040201c  0000201c
       000000000000003c  0000000000000000   A       0     0     4
  [17] .eh_frame         PROGBITS         0000000000402058  00002058
       0000000000000110  0000000000000000   A       0     0     8
  [18] .init_array       INIT_ARRAY       0000000000403df8  00002df8
       0000000000000008  0000000000000008  WA       0     0     8
  [19] .fini_array       FINI_ARRAY       0000000000403e00  00002e00
       0000000000000008  0000000000000008  WA       0     0     8
  [20] .dynamic          DYNAMIC          0000000000403e08  00002e08
       00000000000001f0  0000000000000010  WA       6     0     8
  [21] .got              PROGBITS         0000000000403ff8  00002ff8
       0000000000000008  0000000000000008  WA       0     0     8
  [22] .got.plt          PROGBITS         0000000000404000  00003000
       0000000000000048  0000000000000008  WA       0     0     8
  [23] .data             PROGBITS         0000000000404048  00003048
       0000000000000004  0000000000000000  WA       0     0     1
  [24] .bss              NOBITS           0000000000404050  0000304c
       0000000000000010  0000000000000000  WA       0     0     8
  [25] .comment          PROGBITS         0000000000000000  0000304c
       000000000000005b  0000000000000001  MS       0     0     1
  [26] .debug_aranges    PROGBITS         0000000000000000  000030a7
       0000000000000030  0000000000000000           0     0     1
  [27] .debug_info       PROGBITS         0000000000000000  000030d7
       0000000000000115  0000000000000000           0     0     1
  [28] .debug_abbrev     PROGBITS         0000000000000000  000031ec
       00000000000000d7  0000000000000000           0     0     1
  [29] .debug_line       PROGBITS         0000000000000000  000032c3
       00000000000000a7  0000000000000000           0     0     1
  [30] .debug_str        PROGBITS         0000000000000000  0000336a
       0000000000000122  0000000000000001  MS       0     0     1
  [31] .symtab           SYMTAB           0000000000000000  00003490
       00000000000003c0  0000000000000018          32    21     8
  [32] .strtab           STRTAB           0000000000000000  00003850
       000000000000023c  0000000000000000           0     0     1
  [33] .shstrtab         STRTAB           0000000000000000  00003a8c
       0000000000000143  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

從上面的節頭表我們可以看到第 24 個小節 bss 他的起始地址為 0000000000404050 一共站 16 個位元組,也就是說 0x404058 指向的資料在 bss 節的資料範圍,也就是說鎖對應的 int 型別(4 個位元組)的資料在 bss 節,程式執行的時候會將 bss 節當中的資料初始化為 0, 0 表示無鎖狀態。

我們現在來看一下函式 GOMP_critical_name_start 原始碼(為了方便檢視刪除了部分程式碼):

void
GOMP_critical_name_start (void **pptr)
{
  gomp_mutex_t *plock;

  /* If a mutex fits within the space for a pointer, and is zero initialized,
     then use the pointer space directly.  */
  if (GOMP_MUTEX_INIT_0
      && sizeof (gomp_mutex_t) <= sizeof (void *)
      && __alignof (gomp_mutex_t) <= sizeof (void *))
    plock = (gomp_mutex_t *)pptr; // gomp_mutex_t 就是 int 型別

  gomp_mutex_lock (plock);
}

從語句 plock = (gomp_mutex_t *)pptr 可以知道將傳遞的引數作為一個 int 型別的指標使用,這個指標指向的就是 bss 節的資料,然後對這個資料進行加鎖操作(gomp_mutex_lock (plock)),關於函式 gomp_mutex_lock ,在文章 OpenMP Runtime Library : Openmp 常見的動態庫函式使用(下)——深入剖析鎖?原理與實現 當中有詳細的講解 。

我們在來看一下 GOMP_critical_name_end 的原始碼:

void
GOMP_critical_name_end (void **pptr)
{
  gomp_mutex_t *plock;

  /* If a mutex fits within the space for a pointer, and is zero initialized,
     then use the pointer space directly.  */
  if (GOMP_MUTEX_INIT_0
      && sizeof (gomp_mutex_t) <= sizeof (void *)
      && __alignof (gomp_mutex_t) <= sizeof (void *))
    plock = (gomp_mutex_t *)pptr;
  else
    plock = *pptr;

  gomp_mutex_unlock (plock);
}

同樣的還是使用 bss 節的資料進行解鎖操作,關於加鎖解鎖操作的細節可以閱讀這篇文章 OpenMP Runtime Library : Openmp 常見的動態庫函式使用(下)——深入剖析鎖?原理與實現

總結

在本篇文章當中主要給大家介紹了 flush, master 和 critical 指令的實現細節和他的呼叫的庫函式,並且深入分析了這幾個 construct 當中設計的庫函式的原始碼,希望大家有所收穫。


更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。

相關文章