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、計算機系統基礎、演演算法與資料結構)知識。