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-LeHu...
關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、算法與數據結構)知識。