OpenMP 原子指令設計與實現
前言
在本篇文章當中主要與大家分享一下 openmp 當中的原子指令 atomic,分析 #pragma omp atomic
在背後究竟做了什麼,編譯器是如何處理這條指令的。
為什麼需要原子指令
加入現在有兩個執行緒分別執行在 CPU0 和 CPU1,如果這兩個執行緒都要對同一個共享變數進行更新操作,就會產生競爭條件。如果沒有保護機制來避免這種競爭,可能會導致結果錯誤或者程式崩潰。原子指令就是解決這個問題的一種解決方案,它能夠保證操作的原子性,即操作不會被打斷或者更改。這樣就能保證在多執行緒環境下更新共享變數的正確性。
比如在下面的圖當中,兩個執行緒分別在 CPU0 和 CPU1 執行 data++ 語句,如果目前主存當中的 data = 1 ,然後按照圖中的順序去執行,那麼主存當中的 data 的最終值等於 2 ,但是這並不是我們想要的結果,因為有兩次加法操作我們希望最終在記憶體當中的 data 的值等於 3 ,那麼有什麼方法能夠保證一個執行緒在執行 data++ 操作的時候下面的三步操作是原子的嘛(不可以分割):
- Load data : 從主存當中將 data 載入到 cpu 的快取。
- data++ : 執行 data + 1 操作。
- Store data : 將 data 的值寫回主存。
事實上硬體就給我們提供了這種機制,比如 x86 的 lock 指令,在這裡我們先不去討論這一點,我們將在後文當中對此進行仔細的分析。
OpenMP 原子指令
在 openmp 當中 #pragma omp atomic
的表示式格式如下所示:
#pragma omp atomic
表示式;
其中表示式可以是一下幾種形式:
x binop = 表示式;
x++;
x--;
++x;
--x;
二元運算子 binop 為++, --, +, -, *, /, &, ^, | , >>, <<或 || ,x 是基本資料型別 int,short,long,float 等資料型別。
我們現在來使用一個例子熟悉一下上面鎖談到的語法:
#include <stdio.h>
#include <omp.h>
int main()
{
int data = 1;
#pragma omp parallel num_threads(4) shared(data) default(none)
{
#pragma omp atomic
data += data * 2;
}
printf("data = %d\n", data);
return 0;
}
上面的程式最終的輸出結果如下:
data = 81
上面的 data += data * 2 ,相當於每次操作將 data 的值擴大三倍,因此最終的結果就是 81 。
原子操作和鎖的區別
OpenMP 中的 atomic 指令允許執行無鎖操作,而不會影響其他執行緒的並行執行。這是透過在硬體層面上實現原子性完成的。鎖則是透過軟體來實現的,它阻塞了其他執行緒對共享資源的訪問。
在選擇使用 atomic 或鎖時,應該考慮操作的複雜性和頻率。對於簡單的操作和高頻率的操作,atomic 更加高效,因為它不會影響其他執行緒的並行執行。但是,對於複雜的操作或者需要多個操作來完成一個任務,鎖可能更加合適。
原子操作只能夠進行一些簡單的操作,如果操作複雜的是沒有原子指令進行操作的,這一點我們在後文當中詳細談到,如果你想要原子性的是一個程式碼塊的只能夠使用鎖,而使用不了原子指令。
深入剖析原子指令——從彙編角度
加法和減法原子操作
我們現在來仔細分析一下下面的程式碼的彙編指令,看看編譯器在背後為我們做了些什麼:
#include <stdio.h>
#include <omp.h>
int main()
{
int data = 0;
#pragma omp parallel num_threads(4) shared(data) default(none)
{
#pragma omp atomic
data += 1;
}
printf("data = %d\n", data);
return 0;
}
首先我們需要了解一點編譯器會將並行域的程式碼編譯成一個函式,我們現在看看上面的 parallel 並行域的對應的函式的的彙編程式:
0000000000401193 <main._omp_fn.0>:
401193: 55 push %rbp
401194: 48 89 e5 mov %rsp,%rbp
401197: 48 89 7d f8 mov %rdi,-0x8(%rbp)
40119b: 48 8b 45 f8 mov -0x8(%rbp),%rax
40119f: 48 8b 00 mov (%rax),%rax
4011a2: f0 83 00 01 lock addl $0x1,(%rax) # 這就是編譯出來的原子指令——對應x86平臺
4011a6: 5d pop %rbp
4011a7: c3 retq
4011a8: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
4011af: 00
在上面的彙編程式碼當中最終的一條指令就是 lock addl $0x1,(%rax)
,這條指令便是編譯器在編譯 #pragma omp atomic
的時候將 data += 1
轉化成硬體的對應的指令。我們可以注意到和普通的加法指令的區別就是這條指令前面有一個 lock ,這是告訴硬體在指令 lock 後面的指令的時候需要保證指令的原子性。
以上就是在 x86 平臺下加法操作對應的原子指令。我們現在將上面的 data += 1,改成 data -= 1,在來看一下它對應的彙編程式:
0000000000401193 <main._omp_fn.0>:
401193: 55 push %rbp
401194: 48 89 e5 mov %rsp,%rbp
401197: 48 89 7d f8 mov %rdi,-0x8(%rbp)
40119b: 48 8b 45 f8 mov -0x8(%rbp),%rax
40119f: 48 8b 00 mov (%rax),%rax
4011a2: f0 83 28 01 lock subl $0x1,(%rax)
4011a6: 5d pop %rbp
4011a7: c3 retq
4011a8: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
4011af: 00
可以看到它和加法指令的主要區別就是 addl 和 subl,其他的程式是一樣的。
乘法和除法原子操作
我們現在將下面的程式進行編譯:
#include <stdio.h>
#include <omp.h>
int main()
{
int data = 1;
#pragma omp parallel num_threads(4) shared(data) default(none)
{
#pragma omp atomic
data *= 2;
}
printf("data = %d\n", data);
return 0;
}
上面程式碼的並行域被編譯之後的彙編程式如下所示:
0000000000401193 <main._omp_fn.0>:
401193: 55 push %rbp
401194: 48 89 e5 mov %rsp,%rbp
401197: 48 89 7d f8 mov %rdi,-0x8(%rbp)
40119b: 48 8b 45 f8 mov -0x8(%rbp),%rax
40119f: 48 8b 08 mov (%rax),%rcx
4011a2: 8b 01 mov (%rcx),%eax
4011a4: 89 c2 mov %eax,%edx
4011a6: 8d 34 12 lea (%rdx,%rdx,1),%esi # 這條語句的含義為 data *= 2
4011a9: 89 d0 mov %edx,%eax
4011ab: f0 0f b1 31 lock cmpxchg %esi,(%rcx)
4011af: 89 d6 mov %edx,%esi
4011b1: 89 c2 mov %eax,%edx
4011b3: 39 f0 cmp %esi,%eax
4011b5: 75 ef jne 4011a6 <main._omp_fn.0+0x13>
4011b7: 5d pop %rbp
4011b8: c3 retq
4011b9: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
我們先不仔細去分析上面的彙編程式,我們先來看一下上面程式的行為:
- 首先載入 data 的值,儲存為 temp,這個 temp 的值儲存在暫存器當中。
- 然後將 temp 的值乘以 2 儲存在暫存器當中。
- 最後比較 temp 的值是否等於 data,如果等於那麼就將 data 的值變成 temp ,如果不相等(也就是說有其他執行緒更改了 data 的值,此時不能賦值給 data)回到第一步,這個操作主要是基於指令
cmpxchg
。
上面的三個步驟當中第三步是一個原子操作對應上面的彙編指令 lock cmpxchg %esi,(%rcx)
,cmpxchg 指令前面加了 lock 主要是儲存這條 cmpxchg 指令的原子性。
如果我們將上面的彙編程式使用 C 語言重寫的話,那麼就是下面的程式那樣:
#include <stdio.h>
#include <stdbool.h>
#include <stdatomic.h>
// 這個函式對應上面的彙編程式
void atomic_multiply(int* data)
{
int oldval = *data;
int write = oldval * 2;
// __atomic_compare_exchange_n 這個函式的作用就是
// 將 data 指向的值和 old 的值進行比較,如果相等就將 write 的值寫入 data
// 指向的記憶體地址 如果操作成功返回 true 否則返回 false
while (!__atomic_compare_exchange_n (data, &oldval, write, false,
__ATOMIC_ACQUIRE, __ATOMIC_RELAXED))
{
oldval = *data;
write = oldval * 2;
}
}
int main()
{
int data = 2;
atomic_multiply(&data);
printf("data = %d\n", data);
return 0;
}
現在我們在來仔細分析一下上面的彙編程式,首先我們需要仔細瞭解一下 cmpxchg 指令,這個指令在上面的彙編程式當中的作用是比較 eax 暫存器和 rcx 暫存器指向的記憶體地址的資料,如果相等就將 esi 暫存器的值寫入到 rcx 指向的記憶體地址,如果不想等就將 rcx 暫存器指向的記憶體的值寫入到 eax 暫存器。
透過理解上面的指令,在 cmpxchg 指令之後的就是檢視是否 esi 暫存器的值寫入到了 rcx 暫存器指向的記憶體地址,如果是則不執行跳轉語句,否則指令回到位置 4011a6 重新執行,這就是一個 while 迴圈。
我們在來看一下將乘法變成除法之後的彙編指令:
0000000000401193 <main._omp_fn.0>:
401193: 55 push %rbp
401194: 48 89 e5 mov %rsp,%rbp
401197: 48 89 7d f8 mov %rdi,-0x8(%rbp)
40119b: 48 8b 45 f8 mov -0x8(%rbp),%rax
40119f: 48 8b 08 mov (%rax),%rcx
4011a2: 8b 01 mov (%rcx),%eax
4011a4: 89 c2 mov %eax,%edx
4011a6: 89 d0 mov %edx,%eax
4011a8: c1 e8 1f shr $0x1f,%eax
4011ab: 01 d0 add %edx,%eax
4011ad: d1 f8 sar %eax
4011af: 89 c6 mov %eax,%esi
4011b1: 89 d0 mov %edx,%eax
4011b3: f0 0f b1 31 lock cmpxchg %esi,(%rcx)
4011b7: 89 d6 mov %edx,%esi
4011b9: 89 c2 mov %eax,%edx
4011bb: 39 f0 cmp %esi,%eax
4011bd: 75 e7 jne 4011a6 <main._omp_fn.0+0x13>
4011bf: 5d pop %rbp
4011c0: c3 retq
4011c1: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
4011c8: 00 00 00
4011cb: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
從上面的彙編程式碼當中的 cmpxchg 和 jne 指令可以看出除法操作使用的還是比較並交換指令(CAS) cmpxchg,並且也是使用 while 迴圈。
其實複雜的表示式都是使用這個方式實現的:while 迴圈 + cmpxchg 指令,我們就不一一的將其他的使用方式也拿出來一一解釋了。簡單的表示式可以直接使用 lock + 具體的指令實現。
總結
在本篇文章當中主要是深入剖析了 OpenMP 當中各種原子指令的實現原理以及分析了他們對應的彙編程式,OpenMP 在處理 #pragma omp atomic 的時候如果能夠使用原子指令完成需求那就直接使用原子指令,否則的話就使用 CAS cmpxchg 指令和 while 迴圈完成對應的需求。
更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore
關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。