C++内存模型和原子类型操作
C++內存模型和原子類型操作
std::memory_order初探
動態內存模型可以理解為存儲一致性模型,主要是從行為上來看多個線程對同一個對象讀寫操作時所做的約束,動態內存理解起來會有少許復雜,涉及到內存、Cache、CPU的各個層次的交互。
如下有兩個線程,分別對a、R1、b、R2進行賦值,根據線程執行的順序可能有以下幾種情況
在不對線程進行任何限制,線程內部指令不進行重排的情況下。可以有4!/(2!*2!)=6中情況
在不考慮優化和指令重排的情況下,多線程有如下兩種情況:
當然,現在的編譯器都支持指令重排,上述的現象也只是理想情況下的執行情況,因為順序一致性代價太大不利于程序的優化。但是有時候你需要對編譯器的優化行為做出一定的約束,才能保證你的程序行為和你預期的執行結果保持一致,那么這個約束就是內存模型。
C++程序員想要寫出高性能的多線程程序必須理解內存模型,編譯器會給你的程序做靜態優化,CPU為了提升性能也有動態亂序執行的行為。總之,實際編程中程序不會完全按照你原始代碼的順序來執行,因此內存模型就是程序員、編譯器、CPU之間的契約。編程、編譯、執行都會在遵守這個契約的情況下進行,在這樣的規則之上各自做自己的優化,從而提升程序的性能。
Memory Order
內存的順序描述了計算機CPU獲取內存的順序,內存的排序可能靜態也可能動態的發生:
- 靜態內存排序:編譯器期間,編譯器對內存重排
- 動態內存排序:運行期間,CPU亂序執行
靜態內存排序是為了提高代碼的利用率和性能,編譯器對代碼進行了重新排序;同樣為了優化性能CPU也會進行對指令進行重新排序、延緩執行、各種緩存等等,以便達到更好的執行效果。雖然經過排序確實會導致很多執行順序和源碼中不一致,但是你沒有必要為這些事情感到棘手足無措。任何的內存排序都不會違背代碼本身所要表達的意義,并且在單線程的情況下通常不會有任何的問題。
但是在多線程場景中,無鎖的數據結構設計中,指令的亂序執行會造成無法預測的行為。所以我們通常引入內存柵欄這一概念來解決可能存在的并發問題。
Memory Barrier
內存柵欄是一個令 CPU 或編譯器在內存操作上限制內存操作順序的指令,通常意味著在 barrier 之前的指令一定在 barrier 之后的指令之前執行。
在 C11/C++11 中,引入了六種不同的 memory order,可以讓程序員在并發編程中根據自己需求盡可能降低同步的粒度,以獲得更好的程序性能。這六種 order 分別是:
relaxed, acquire, release, consume, acq_rel, seq_cstC++11中規定了如下6種訪問次序(Memory Oreder)
enum memory_order {memory_order_relaxed,memory_order_consume,memory_order_acquire,memory_order_release,memory_order_acq_rel,memory_order_seq_cst };上述6種訪問次序可以分為3類
以上三類可以組合成如下種類的模型:
自由序列模型(寬松的內存序列化模型):
在單個線程內,所有原子操作時順序進行的。按照什么順序?按照代碼順序,這也就是該模型的唯一限制。兩個來自不同線程的原子操作順序是任意的
獲取釋放語義模型
Release – acquire模型,在自由序列模型中來自兩個線程之間的原子操作時順序是不一定的,那么就需要把兩個線程進行一下同步(synchronize-with)。同步什么?同步對一個變量的讀寫操作。線程A原子性的把值寫入X(release),然后線程B原子性地讀取X的值(acquire)。這樣線程B保證讀取到X的新值。需要注意的是release有個牛逼的副作用-線程A中所有發生在releaseX之前的寫操作,對線程BacqueireX之后的任何讀操作都可見!本來A,B之間的讀寫操作順序不定。這么已同步在X這個點前后,A,B線程之間有了個順序關系,稱作inter-thread happens-before
release-consume模型:不會吧,只是想要同步一個x的讀寫操作,結果把release之前的所有寫操作都順帶同步了!!!這對于不需要所有都同步的操作來說開銷也太大了。有沒有辦法能夠有效降低開銷,又能同步X呢?用release-consume唄,同步還是一樣的同步。這一會的副作用弱了點:在線程B acqueire X之后的讀操作中,有一些是依賴X的值的讀操作。管這些依賴X的讀操作叫做-賴B讀。同理在線程A里面,release X也有一些它所依賴的其他寫操作,這些操作自然發生在release X之前了,管這些操作叫做-賴A寫。副作用總結來說就是,只有賴B讀能看見賴A寫
那么寫到這里了,什么叫做賴,賴就是依賴的意思,release-acquire模型是大把抓的形式,把所有release之前的寫和acquire之后的讀都進行同步。而release-consume控制的更加精細,只有有依賴關系的才進行同步,依賴關系可以按照如下理解:
數據依賴:
S1. c = a + b; S2. e = c + d;數據S2是依賴S1的因為在計算S2的時候需要依賴S1中計算出來的c值。
順序一致性模型
Sequential consistency:前面的模型理解了,順序一致性也就好理解了,release-acquire就同步了一個x,順序一致就是對所有的變量的所有原子操作都進行同步。那么這樣一來所有的原子操作就跟由一個線程順序執行似的
6種訪問次序的說明
memory_order_relaxed
memory_order_relaxed: 只保證當前操作的原子性,不考慮線程間的同步,其他線程可能讀到新值,也可能讀到舊值。也就是說,原子操作標記 memory_order_relaxed不是同步操作;它們不會在并發內存訪問之間強加順序。它們只保證原子性和修改順序的一致性。
Relaxed operation: there are no synchronization or ordering constraints imposed on other reads or writes, only this operation’s atomicity is guaranteed(see Relaxed ordering below)
例如:
// Thread 1: r1 = y.load(std::memory_order_relaxed); // A x.store(r1, std::memory_order_relaxed); // B // Thread 2: r2 = x.load(std::memory_order_relaxed); // C y.store(42, std::memory_order_relaxed); // D上述代碼會產生r1==r2, ==42的情況,盡管A在線程1中序列在B的前面,C的序列在線程2中在D的前面,沒有什么能夠阻止D排序到A的前面,也沒有什么能夠阻止B排到A的前面。唯一可以預見的是線程1中x的存儲A對線程2中的C可見。也就是使用memory_order_relaxed除了保證當前原子變量的可見性和原子性。
即使是memory_order_relaxed也不能出現循環依賴的情況
// Thread 1: r1 = y.load(std::memory_order_relaxed); if (r1 == 42) x.store(r1, std::memory_order_relaxed); // Thread 2: r2 = x.load(std::memory_order_relaxed); if (r2 == 42) y.store(42, std::memory_order_relaxed);上述代碼不可能出現r1== r2 , == 42, 的情況,因為r1== 42, 依賴r2存儲為42,而, r2 ==42依賴r1存儲為42。上述代碼直到C++14才被規則上允許,但是還是不建議使用上述方式給用戶實現since C++14
relaxed的通常使用方式是用來計數,例如 std::shared_ptr中的引用計數,因為這只需要原子性,并不需要進行排序或者同步
#include <vector> #include <iostream> #include <thread> #include <atomic>std::atomic<int> cnt = {0};// memory_order_relaxed保證當前變量在不同線程中的原子性 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();}std::cout << "Final counter value is " << cnt << '\n'; }Output:
Final counter value is 10000memory_order_release
A store operation with this memory order performs the release operation: no reads or writes in the current thread can be reordered after this store. All writes in the current thread are visible in other threads that acquire the same atomic variable (see Release-Acquire ordering below) and writes that carry a dependency into the atomic variable become visible in other threads that consume the same atomic (see Release-Consume ordering below).
memory_order_release:(可以理解為 mutex 的 unlock 操作)
- 對寫入施加 release 語義(store),在代碼中這條語句前面的所有讀寫操作都無法被重排到這個操作之后,即 store-store 不能重排為 store-store, load-store 也無法重排為 store-load
- 當前線程內的所有寫操作,對于其他對這個原子變量進行 acquire 的線程可見
- 當前線程內的與這塊內存有關的所有寫操作,對于其他對這個原子變量進行 consume 的線程可見
memory_order_acquire
memory_order_acquire: (可以理解為 mutex 的 lock 操作)
A load operation with this memory order performs the acquire operation on the affected memory location: no reads or writes in the current thread can be reordered before this load. All writes in other threads that release the same atomic variable are visible in the current thread (see Release-Acquire ordering below)
對讀取施加 acquire 語義(load),在代碼中這條語句前面所有讀寫操作都無法重排到這個操作之前,即 load-store 不能重排為 store-load, load-load 也無法重排為 load-load
這里一定要理解原子操作、內存屏障的作用,在如下代碼你可以想象運行期間絕對能保證 c.store(3, memory_order_release)與c.load(memory_order_acquire)是原子的,那么在acquire之前的不能進行重新排,就保證了a,b的值是自己想要的,記住是跨線程的原子性哦。
在這個原子變量上施加 release 語義的操作發生之后,acquire 可以保證讀到所有在 release 前發生的寫入,舉個例子:
c = 0; thread 1:{ a = 1; b.store(2, memory_order_relaxed); c.store(3, memory_order_release); } thread 2:{ while (c.load(memory_order_acquire) != 3) ; // 以下 assert 永遠不會失敗 assert(a == 1 && b == 2);assert(b.load(memory_order_relaxed) == 2); }如果你個原子變量在線程A存儲時被打上了memory_order_release標簽,在線程B加載的時候被打上了memory_order_acquire標簽。A線程中所有在原子變量存儲之前的寫操作(包括非原子變量和relaxed標記的原子變量)都會變得在B線程可見。也就是只要線程B中完成了load操作那么就可以保證在線程A中寫入的所有數據在線程B中可見。
同步的效果只有在對同一個原子變量releasing和acquiring的線程間有效果,對于其他的線程無效
Mutual exclusion locks, such as std::mutex or atomic spinlock, are an example of release-acquire synchronization: when the lock is released by thread A and acquired by thread B, everything that took place in the critical section (before the release) in the context of thread A has to be visible to thread B (after the acquire) which is executing the same critical section.
#include <thread> #include <atomic> #include <cassert> #include <string>std::atomic<std::string*> ptr; int data;void producer() {std::string* p = new std::string("Hello");data = 42;// 在調用release之后,其上的所有寫操作對于acquire的線程可見ptr.store(p, std::memory_order_release); }void consumer() {std::string* p2;// load 調用acquire之后,其下的所有可讀數據都在release之后while (!(p2 = ptr.load(std::memory_order_acquire)));assert(*p2 == "Hello"); // never firesassert(data == 42); // never fires }int main() {std::thread t1(producer);std::thread t2(consumer);t1.join(); t2.join(); }如下代碼通過原子變量實現了三個線程之間的同步
#include <thread> #include <atomic> #include <cassert> #include <vector> #include <iostream>std::vector<int> data; std::atomic<int> flag = {0};void thread_1() {data.push_back(42);flag.store(1, std::memory_order_release); }void thread_2() {int expected=1;// 執行順序先acq 再relwhile (!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel)) {expected = 1;} }void thread_3() {std::this_thread::sleep_for(std::chrono::milliseconds (10000));// 只有flag的值不小于2的時候才退出while (flag.load(std::memory_order_acquire) < 2);assert(data.at(0) == 42); // will never fire }/** 經過上述操作執行順序是,先線程1再線程2然后是線程3,通過原子變量實現了線程間的同步* */ int main(int argc, char*argv[]) {std::thread a(thread_1);std::thread b(thread_2);std::thread c(thread_3);a.join(); b.join(); c.join();return 0; }memory_order_consume
對當前要讀取的內存施加 release 語義(store),在代碼中這條語句后面所有與這塊內存有關的讀寫操作都無法被重排到這個操作之前。也就是說當一個原子變量在線程A中被標記為memory_order_release,同樣 原子變量線程B被使用memory_order_consume標記進行加載的時候,所有發生在A線程中原子變量store之前的寫操作,對于那些 依賴關系的元素都將在B線程中可見
#include <thread> #include <atomic> #include <cassert> #include <string>std::atomic<std::string*> ptr; int data;void producer() {auto * p = new std::string("Hello");data = 42;ptr.store(p, std::memory_order_release); }void consumer() {std::string* p2;while (!(p2 = ptr.load(std::memory_order_consume)));// 因為p2一定依賴原子變量ptr所有這里一定成功assert(*p2 == "Hello"); // never fires: *p2 carries dependency from ptrassert(data == 42); // may or may not fire: data does not carry dependency from ptr }int main() {std::thread t1(producer);std::thread t2(consumer);t1.join(); t2.join(); }The synchronization is established only between the threads releasing and consuming the same atomic variable. Other threads can see different order of memory accesses than either or both of the synchronized threads.
使用場景
Typical use cases for this ordering involve read access to rarely written concurrent data structures (routing tables, configuration, security policies, firewall rules, etc) and publisher-subscriber situations with pointer-mediated publication, that is, when the producer publishes a pointer through which the consumer can access information: there is no need to make everything else the producer wrote to memory visible to the consumer (which may be an expensive operation on weakly-ordered architectures). An example of such scenario is rcu_dereference.
A load operation with this memory order performs a consume operation on the affected memory location: no reads or writes in the current thread dependent on the value currently loaded can be reordered before this load. Writes to data-dependent variables in other threads that release the same atomic variable are visible in the current thread. On most platforms, this affects compiler optimizations only (see Release-Consume ordering below)
在這個原子變量上施加 release 語義的操作發生之后,acquire 可以保證讀到所有在 release 前發生的并且與這塊內存有關的寫入,舉個例子:
a = 0; c = 0; thread 1:{a = 1; c.store(3, memory_order_release); } thread 2:{ while (c.load(memory_order_consume) != 3) ; assert(a == 1); // assert 可能失敗也可能不失敗 }memory_order_acq_rel
A read-modify-write operation with this memory order is both an acquire operation and a release operation. No memory reads or writes in the current thread can be reordered before or after this store. All writes in other threads that release the same atomic variable are visible before the modification and the modification is visible in other threads that acquire the same atomic variable.
對讀取和寫入施加 acquire-release 語義,無法被重排
可以看見其他線程施加 release 語義的所有寫入,同時自己的 release 結束后所有寫入對其他施加 acquire 語義的線程可見
memory_order_seq_cst(順序一致性)
A load operation with this memory order performs an acquire operation, a store performs a release operation, and read-modify-write performs both an acquire operation and a release operation, plus a single total order exists in which all threads observe all modifications in the same order (see Sequentially-consistent ordering below)
通常情況下,默認使用 memory_order_seq_cst,所以你如果不確定怎么這些 memory order,就用這個。
擴展
評論里有很多關于x86內存模型的指正,放在這里:
Loads are not reordered with other loads.Stores are not reordered with other stores.Stores are not reordered with older loads.
然后最重要的:
Loads may be reordered with older stores to different locations.
因為 store-load 可以被重排,所以x86不是順序一致。但是因為其他三種讀寫順序不能被重排,所以x86是 acquire/release 語義。
aquire語義:load 之后的讀寫操作無法被重排至 load 之前。即 load-load, load-store 不能被重排。
release語義:store 之前的讀寫操作無法被重排至 store 之后。即 load-store, store-store 不能被重排。
參考:
詳細的信息一直在飛書文檔上進行實時更新:C++內存一致性和原子操作
https://ny5odfilnr.feishu.cn/docs/doccnnvMCH7nPKidScKSnJmiJie
本文由博客一文多發平臺 OpenWrite 發布!
總結
以上是生活随笔為你收集整理的C++内存模型和原子类型操作的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【软件工程】用例间的关系
- 下一篇: 编译原理——实验壹预习——TINY语言的