詳解OpenMP的線程同步機制
前言
在本篇文章當中主要給大家介紹 OpenMP 當中線程的同步和互斥機制,在 OpenMP 當中主要有三種不同的線程之間的互斥方式:
- 使用 critical 子句,使用這個子句主要是用于創(chuàng)建臨界區(qū)和 OpenMP 提供的運行時庫函數(shù)的作用是一致的,只不過這種方法是直接通過編譯指導語句實現(xiàn)的,更加方便一點,加鎖和解鎖的過程編譯器會幫我們實現(xiàn)。
- 使用 atomic 指令,這個主要是通過原子指令,主要是有處理器提供的一些原子指令實現(xiàn)的。
- OpenMP 給我們提供了 omp_lock_t 和 omp_nest_lock_t 兩種數(shù)據(jù)結構實現(xiàn)簡單鎖和可重入鎖。
在本篇文章當中主要討論 OpenMP 當中的互斥操作,在下一篇文章當中主要討論 OpenMP 當中原子操作的實現(xiàn)原理,并且查看程序編譯之后的匯編指令。
自定義線程之間的同步 barrier
在實際的寫程序的過程當中我們可能會有一種需求就是需要等待所有的線程都執(zhí)行完成之才能夠進行后面的操作,這個時候我們就可以自己使用 barrier 來實現(xiàn)這個需求了。
比如我們要實現(xiàn)下面的一個計算式:
現(xiàn)在我們計算 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 讓所有的線程到達這個同步點之后才繼續(xù)完成后執(zhí)行,這樣就保證了在進行后面的任務的時候所有線程計算階乘的任務已經(jīng)完成。
定義臨界區(qū) critical
在并發(fā)程序當中我們經(jīng)常會有這樣的需求,比如不同的線程需要對同一個數(shù)據(jù)進行求和操作,當然這個操作我們也可以通過 atomic constuct 來完成,但是在本篇文章當中我們使用臨界區(qū)來完成,在下一篇完成當中我們將仔細分析 OpenMP 當中的原子操作。
比如我們現(xiàn)在有一個數(shù)據(jù) data,然后每個線程需要對這個數(shù)據(jù)進行加操作。
#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 構造當中我們執(zhí)行了 data ++ 這條語句,如果我們不使用 critical construct 的話,那么就可能兩個線程同時操作 data++ 這條語句,那么就會造成結果的不正確性,因為如果兩個線程同時讀取 data 的值等于 0,然后兩個線程同時進行++操作讓 data 的值都變成 1,再寫回,那么 data 的最終結果將會是 1,但是我們期望的結果是兩個線程進行相加操作之后值變成 2,這就不對了,因此我們需要使用 critical construct 保證同一時刻只能夠有一個線程進行 data++ 操作。
我們知道臨界區(qū)的實現(xiàn)是使用鎖實現(xiàn)的,當我們使用 #pragma omp critical 的時候,我們默認是使用的 OpenMP 內部的默認鎖實現(xiàn)的,如果你在其他地方也使用 #pragma omp critical 的話使用的也是同一把鎖,因此即使你用 #pragma omp critical 創(chuàng)建多個臨界區(qū)你使用的也是同一把鎖,也就是說這多個臨界區(qū)在同一時刻也只會有一個線程在一個臨界區(qū)執(zhí)行,其余的臨界區(qū)是沒有線程在執(zhí)行的,因為所有的臨界區(qū)使用同一把鎖,而一個時刻只能夠有一個線程獲得鎖。
為了解決上面所談到的問題,在 OpenMP 當中使用 critical 構造代碼塊的時候我們可以指定一個名字,以此用不同的鎖在不同的臨界區(qū)。
我們現(xiàn)在對上面的情況進行驗證,在下面的程序當中一共有 4 個 section ,首先我們需要知道的是不同的 section 同一個時刻可以被不同的線程執(zhí)行的,每一個線程只會被執(zhí)行一次,如果有線程執(zhí)行過了,那么它將不會再被執(zhí)行。
#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 秒,這就說明了,所有的打印都是在等之前的線程執(zhí)行完成之后才執(zhí)行的,這也就從側面說明了,同一個時刻只能夠有一個線程獲取到鎖,因為使用的是 #pragma omp critical 所有的臨界區(qū)都是用同一個鎖——默認鎖。
現(xiàn)在我們修改上面的程序,每一個 critical construct 都使用一個名字進行修飾,讓每一個臨界區(qū)使用的鎖不同:
#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 語句被執(zhí)行。也就是說這些臨界區(qū)之間并不互斥,這也就說名了不同的臨界區(qū)使用的鎖是不同的。
深入理解 barrier
在上一小節(jié)當中我們提到了 critical 可以使用一個名字進行命名,那么就可以使得不同的臨界區(qū)使用不同的鎖,這樣可以提高程序的執(zhí)行效率。那么在 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
根據(jù)上面的程序輸出結果我們可以知道,首先 omp_set_nested(1) 啟動并行嵌套,外部并行域有兩個線程,這兩個線程回分別創(chuàng)建兩個新的并行域,每個并行域里面都會有一個新的線程組,每個線程組都會有屬于自己的 barrier 變量,也就是說和其他的線程組中的 barrier 是無關的,因此當并行域2中的兩個線程都到達 barrier 之后就會立馬執(zhí)行最后一個 printf 語句,而不需要等待并行域3中的線程 sleep 完成,而上面的程序的輸出結果也印證了這一點。在上面的代碼當中并行域2中的線程只需要 sleep 1 秒,并行域3中的線程需要 sleep 2 秒,因此并行域2中的線程會先打印,并行域3中的線程會后打印。
根據(jù)上面的分析和圖解大致說明了上面的關于 barrier 代碼的執(zhí)行流程,更多關于 barrier 的實現(xiàn)細節(jié)我們在后面進行 OpenMP 源碼分析的時候再進行分析。
master construct
在 OpenMP 當中還有一個比較實用的指令 master 這個指令的含義主要是代碼塊只有 master 線程才會執(zhí)行,其余線程都不會執(zhí)行。所謂 master 線程就是一個線程組當中線程號等于 0 的線程。
你可能會覺得這個和 single 比較相似,但是和 single 不同的是這個指令最后并沒有一個同步點,而 single 會有一個隱藏的同步點,只有所有的線程到同步點之后線程才會繼續(xù)往后執(zhí)行,我們分析下面的代碼。
#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 線程執(zhí)行,這也就印證了我們所談到的內容。
single construct
在使用 OpenMP 的時候,可能會有一部分代碼我們只需要一個線程去執(zhí)行,這個時候我們可以時候 single 指令,single 代碼塊只會有一個線程執(zhí)行,并且在 single 代碼塊最后會有一個同步點,只有 single 代碼塊執(zhí)行完成之后,所有的線程才會繼續(xù)往后執(zhí)行。我們現(xiàn)在來分析一下下面的程序:
#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 秒中。雖然只有一個線程執(zhí)行 single 代碼塊,但是我們可以看到所有的線程都話費了 5 秒鐘,這正是因為在 single 代碼塊之后會有一個隱藏的同步點,只有并行域中所有的代碼到達同步點之后,線程才能夠繼續(xù)往后執(zhí)行。
ordered construct
odered 指令主要是用于 for 循環(huán)當中的代碼塊必須按照循環(huán)的迭代次序來執(zhí)行。因為在循環(huán)當中有些區(qū)域是可以并行處理的,但是我們的業(yè)務需要在某些代碼串行執(zhí)行(這里所談到的串行執(zhí)行的意思是按照循環(huán)的迭代次序,比如說 for(int i = 0; i < 10; ++i) 這個次序就是必須按照 i 從 0 到 9 的次序執(zhí)行代碼),這樣才能夠保證邏輯上的正確性。
比如下面的例子:
#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
上面的程序的輸出結果一定是上面的樣子,絕對不會發(fā)生任何順序上的變化,這正是因為 ordered 的效果,他保證了線程的執(zhí)行順序必須按照循環(huán)迭代次序來。
OpenMP 中的線程同步機制
在這一小節(jié)當中主要分析 OpenMP 當中的一些構造語句中的同步關系—— single, sections, for ,并且消除這些指令造成的線程之間的同步。
Sections 使用 nowait
在 OpenMP 當中 sections 主要是使不同的線程同時執(zhí)行不同的代碼塊,但是在每個 #pragma omp sections 區(qū)域之后有一個隱藏的同步代碼塊,也就是說只有所有的 section 被執(zhí)行完成之后,并且所有的線程都到達同步點,線程才能夠繼續(xù)執(zhí)行,比如在下面的代碼當中,printf("tid = %d finish sections\n", omp_get_thread_num()) 語句只有前面的 sections 塊全部被執(zhí)行完成,所有的線程才會開始執(zhí)行這條語句,根據(jù)這一點在上面的 printf 語句執(zhí)行之前所有的 section 當中的語句都會被執(zhí)行。
#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 塊之后又一個隱藏的同步點,只有所有的線程達到同步點之后程序才會繼續(xù)往后執(zhí)行。
從上面的分析來看,很多時候我們是不需要一個線程執(zhí)行完成之后等待其他線程的,也就是說如果一個線程的 section 執(zhí)行完成之后而且沒有其他的 section 沒有被執(zhí)行,那么我們就不必讓這個線程掛起繼續(xù)執(zhí)行后面的任務,在這種情況下我們就可以使用 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 代碼執(zhí)行完成之后,這個線程就立即執(zhí)行最后的 printf 語句了,也就是說執(zhí)行完成之后并沒有等待其他的線程,這就是我們想要的效果。
Single 使用 nowait
在 OpenMP 當中使用 single 指令表示只有一個線程執(zhí)行 single 當中的代碼,但是需要了解的是在 single 代碼塊最后 OpenMP 也會幫我們生成一個隱藏的同步點,只有執(zhí)行 single 代碼塊的線程執(zhí)行完成之后,所有的線程才能夠繼續(xù)往后執(zhí)行。比如下面的示例程序:
#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,因此雖然之后一個線程執(zhí)行 sleep(5),但是因為其他的線程需要等待這個線程執(zhí)行完成,因此所有的線程都需要等待 5 秒。因此可以判斷上面的代碼輸出就是每個線程輸出的時間差都是 5 秒左右。具體的上面的代碼執(zhí)行結果如下所示:
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 秒左右。
現(xiàn)在我們使用 nowait 那么當一個線程執(zhí)行 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; }
上面的代碼執(zhí)行結果如下所示:
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 執(zhí)行了 single 代碼塊但是其他的線程并沒有執(zhí)行,而我們也使用了 nowait 因此每個線程花費的時間會非常少,這也是符合我們的預期。
For 使用 nowait
for 的原理其實和上面兩個使用方式也是一樣的,都是不需要在同步點進行同步,然后直接執(zhí)行后面的代碼。話不多說直接看代碼
#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 循環(huán),有四個線程去執(zhí)行這個循環(huán),按照默認的調度方式第 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
現(xiàn)在如果我們使用 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
總結
在本篇文章當中主要給大家介紹了一些經(jīng)常使用的 OpenMP 用于線程之間同步的指令,并且用實際例子分析它內部的工作機制,以及我們改如何使用 nowait 優(yōu)化程序的性能
以上就是詳解OpenMP的線程同步機制的詳細內容,更多關于OpenMP線程同步機制的資料請關注腳本之家其它相關文章!
相關文章
淺析C++中memset,memcpy,strcpy的區(qū)別
本篇文章是對C++中memset,memcpy,strcpy的區(qū)別進行了詳細的分析介紹,需要的朋友參考下2013-07-07