atomic原子編程

 
 

一、背景

  • 背景:
在多核編程中,我們使用內核對象【如:事件對象(Event)、互斥量對象(Mutex,或互斥體對象)、信號量對象(Semaphore)等】來避免多個線程修改同一個數據時產生的競爭條件。基於內核對象的同步,會帶來昂貴的上下文切換(用戶態切換到內核態,佔用1000個以上的cpu週期)。
如何在多核的場景下,對共享對象進行同步,又能避免進行上下文切換,就需要使用另一種方法 —— 原子指令。
 
  • 技術定位:需要要一定的編程基礎知識
  • 目標羣體:研發工程師
  • 技術應用場景:多線程/進程
  • 開發語言:C++
 
縮略語
CAS: Compare And Swap
TAS: Test And Swap
 

二、簡述

支持std::atomic條件:
  • C++ 11標準
  • CPU支持這種指令操作。
 
僅靠原子技術實現不了對資源的訪問控制,即使簡單計數操作,看上去正確的代碼也可能會crash。這裏的關鍵在於編譯器和cpu實施的重排指令導致了讀寫順序的變化。只要沒有依賴,代碼中在後面的指令就可能跑到前面去,編譯器和CPU都會這麼做。PowerPC和ARM等弱排序cpu會進行指令重排(依賴內存柵欄指令);而Intel x86, x86-64強排序cpu,總能保證按順序執行,遵從數據依賴順序。
 
所以標準庫裏面提供了memory order,讓工程師可以在排序與性能上做好取捨。
通常對於atomic的變量大多數情況下,主要使用了bool和uint64_t, 一個是遞增,一個是狀態管理。甚至使用一個uint64_t就可以解決大多數共享數據同步的問題。
 
 
注1:單線程代碼不需要關心亂序的問題。因爲亂序至少要保證這一原則:不能改變單線程程序的執行行爲
注2:內核對象多線程編程在設計的時候都阻止了它們調用點中的亂序(已經隱式包含memory barrier),不需要考慮亂序的問題。
注3:使用用戶模式下的線程同步時,亂序的效果纔會顯露無疑。
 

2.1 Member function

atomic的成員函數,還有其他一些函數,這裏列舉了常用的一些API
原子指令(x爲std::atomic類型) 說明
x.load() 讀操作返回x的值
x.store(n) 寫操作把x設爲n,什麼都不返回
x.exchange(n) 把x設爲n,返回設定之前的值
x.fetch_add(n) 原子地做x += n,返回修改之前的值
x.fetch_sub(n) 原子地做x-= n,返回修改之前的值
x.compare_exchange_strong(expect, desired) 原子地進行比較做替換操作,成功返回true,失敗返回false
x.compare_exchange_weak(expect, desired) 原子地進行比較做替換操作,成功返回true,失敗返回false
strong與weak的區別:
  • strong版本當expect與x相等時,肯定返回true。
  • weak 版本當expect與x相等時,存在返回false的情況。
  • x86平臺兩個版本都一樣,沒有差異。
  • 存在差異的平臺下,weak的性能優於strong。
  • 爲了跨平臺性以及高性能,使用weak版本是個好的辦法。
  • 如果不清楚平臺以及流程,使用strong版本是一個比較維穩的辦法。
 
typedef struct NODE{
T data;
NODE *next;
};
 
std::atomic <NODE *> head;
void push(T const& data)
{
NODE *new_node = new NODE;
new_node->data = data;
new_node->next = head.load();
/* head.store(new_node); */
while (!head.compare_exchange_weak(new_node->next, new_node));
}
 
 

2.2 Memory order

atomic提供了6種memory order,來在編程語言層面對編譯器和cpu實施的重排指令行爲進行控制
memory order 作用
memory_order_relaxed 無fencing作用,cpu和編譯器可以重排指令
memory_order_consume 後面依賴此原子變量的訪存指令勿重排至此條指令之前注:性能比memory_order_acquire高
memory_order_acquire 後面訪存指令勿重排至此條指令之前
memory_order_release 前面訪存指令勿重排到此條指令之後
memory_order_acq_rel acquare + release
memory_order_seq_cst acq_rel + 所有使用seq_cst的指令有嚴格的全序關係
默認情況下,std::atomic使用的是memory_order_seq_cst,即Sequentially-consistent ordering(最嚴格的同步模型)。在某些場景下,合理使用其它3種ordering,可以讓編譯器優化生成的代碼,從而提高性能。

2.3 sample code

 
  • Relaxed ordering
在這種模型下,std::atomic的load()和store()都要帶上memory_order_relaxed參數。Relaxed ordering僅僅保證load()和store()是原子操作,除此之外,不提供任何跨線程的同步。
#include <cassert>
#include <vector>
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> cnt = {0};
 
void f()
{
for (int n = 0; n < 1000; ++n) {
cnt.fetch_add(1, std::memory_order_relaxed);
}
}
 
int main()
{
std::vector<std::thread> v;
for (int n = 0; n < 10; ++n) {
v.emplace_back(f);
}
 
for (auto& t : v) {
t.join();
}
 
assert(cnt == 10000);
return 0;
}
執行完上面的程序,可能出現r1 == r2 == 42。理解這一點並不難,因爲編譯器允許調整 C 和 D 的執行順序。
如果程序的執行順序是 D -> A -> B -> C,那麼就會出現r1 == r2 == 42。
 
  • Release-Acquire ordering
在這種模型下,store()使用memory_order_release,而load()使用memory_order_acquire。這種模型有兩種效果,第一種是可以限制 CPU 指令的重排:
(1)在store()之前的所有讀寫操作,不允許被移動到這個store()的後面。 // write-release語義
(2)在load()之後的所有讀寫操作,不允許被移動到這個load()的前面。 // read-acquire語義
該模型可以保證:如果Thread-1的store()的那個值,成功被 Thread-2的load()到了,那麼 Thread-1在store()之前對內存的所有寫入操作,此時對 Thread-2 來說,都是可見的。
下面的例子闡述了這種模型的原理:
#include <thread>
#include <atomic>
#include <cassert>
#include <string>
std::atomic<bool> ready{ false };
int data = 0;
 
void producer()
{
data = 100; // A
ready.store(true, std::memory_order_release); // B
}
 
void consumer()
{
while (!ready.load(std::memory_order_acquire)); // C
 
 
assert(data == 100); // never failed // D
}
 
int main()
{
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
 
  • Spinlock
① 對std::atomic_flag的操作具有原子性,保證了同一時間,只有一個線程能夠lock成功,其餘線程全部在while循環
② 使用了acquire內存屏障, 所以lock具有獲取語義
③ 使用了release內存屏障, 所以unlock具有釋放語義
#include <atomic>
class simple_spin_lock
{
public:
simple_spin_lock() = default;
void lock()
{
while (flag.test_and_set(std::memory_order_acquire))
continue;
}
void unlock()
{
flag.clear(std::memory_order_release);
}
private:
simple_spin_lock(const simple_spin_lock&) = delete;
simple_spin_lock& operator =(const simple_spin_lock&) = delete;
std::atomic_flag flag = ATOMIC_FLAG_INIT;
};
 

三、總結

在多線程或進程中編程中
  • 使用atomic可以減少對互斥量的使用,
  • 使用atomic變量還可以避免CPU做上下文切換操作
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章