詳解C++中的內(nèi)存同步模式(memory order)
內(nèi)存模型中的同步模式(memory model synchronization modes)
原子變量同步是內(nèi)存模型中最讓人感到困惑的地方.原子(atomic)變量的主要作用就是同步多線程間的共享內(nèi)存訪問,一般來講,某個線程會創(chuàng)建一些數(shù)據(jù),然后給原子變量設(shè)置標(biāo)志數(shù)值(譯注:此處的原子變量類似于一個flag);其他線程則讀取這個原子變量,當(dāng)發(fā)現(xiàn)其數(shù)值變?yōu)榱藰?biāo)志數(shù)值之后,之前線程中的共享數(shù)據(jù)就應(yīng)該已經(jīng)創(chuàng)建完成并且可以在當(dāng)前線程中進行讀取了.不同的內(nèi)存同步模式標(biāo)識了線程間數(shù)據(jù)共享機制的"強弱"程度,富有經(jīng)驗的程序員可以使用"較弱"的同步模式來提高程序的執(zhí)行效率.
每一個原子類型都有一個 load() 方法(用于加載操作)和一個 store() 方法(用于存儲操作).使用這些方法(而不是普通的讀取操作)可以更清晰的標(biāo)示出代碼中的原子操作.
atomic_var1.store(atomic_var2.load()); // atomic variables vs var1 = var2; // regular variables
這些方法還支持一個可選參數(shù),這個參數(shù)可以用于指定內(nèi)存模型的同步模式.
目前這些用于線程間同步的內(nèi)存模式共有 3 種,我們依此來看下~
順序一致模式(sequentially consistent)
第一種模式是順序一致模式(sequentially consistent),這也是原子操作的默認模式,同時也是限制最嚴(yán)格的一種模式.我們可以通過 std::memory_order_seq_cst 來顯示的指定這種模式.這種模式下,線程間指令重排的限制與在順序性代碼中進行指令重排的限制是一致的.
觀察以下代碼:
-Thread 1- -Thread 2- y = 1 if (x.load() == 2) x.store (2); assert (y == 1)
雖然代碼中的 x 和 y 是沒有關(guān)聯(lián)的兩個變量,但是代碼中指定的內(nèi)存模型(譯注:代碼中沒有顯示指定,則使用默認的內(nèi)存模式,即順序一致模式)保證了線程 2 中的斷言不會失敗.線程 1 中 對 y 的寫入 先發(fā)生于(happens-before) 對 x 的寫入,如果線程 2 讀取到了線程 1 對 x 的寫入(x.load() == 2),那么線程 1 中 對 x 寫入 之前的所有寫入操作都必須對線程 2 可見,即使對于那些和 x 無關(guān)的寫入操作也是如此.這意味著優(yōu)化操作不能重排線程 1 中的兩個寫入操作(y = 1 和 x.store (2)),因為當(dāng)線程 2 讀取到線程 1 對 x 的寫入之后,線程 1 對 y 的寫入也必須對線程 2 可見.
(譯注:編譯器或者 CPU 會因為性能因素而重排代碼指令,這種重排操作對于單線程程序而言是無感知的,但是對于多線程程序而言就不是了,拿上面代碼舉例,如果將 x.store (2) 重排于 y = 1 之前,那么線程 2 中即使讀取發(fā)現(xiàn) x == 2 了,但此時 y 的數(shù)值也不一定是 1)
加載操作也有類似的優(yōu)化限制:
a = 0 y = 0 b = 1 -Thread 1- -Thread 2- x = a.load() while (y.load() != b) y.store (b) ; while (a.load() == x) a.store(1) ;
線程 2 一直循環(huán)到 y 發(fā)生數(shù)值變更,然后對 a 進行賦值;線程 1 則一直在等待 a 發(fā)生數(shù)值變化.
從順序性代碼的角度來看,線程 1 中的代碼 ‘while (a.load() == x)' 似乎是一個無限循環(huán),編譯器編譯這段代碼時也可能會直接將其優(yōu)化為一個無限循環(huán)(譯注:優(yōu)化為 while (true); 之類的指令);但實際上,我們必須保證每次循環(huán)都對 a 執(zhí)行讀取操作(a.load()) 并且將其與 x 進行比較,否則線程 1 和 線程 2 將不能正常工作(譯注:線程 1 將進入無限循環(huán),與正確的執(zhí)行結(jié)果不一致).
從實踐的角度講,所有的原子操作都相當(dāng)于優(yōu)化屏障(譯注:用于阻止優(yōu)化操作的指令).原子操作(load/store)可以類比為副作用未知的函數(shù)調(diào)用,優(yōu)化操作可以在原子操作之間任意的調(diào)整代碼順序,但是不能越過原子操作(譯注:原子操作類似于是優(yōu)化調(diào)整的邊界),當(dāng)然,線程的私有數(shù)據(jù)并不受此影響,因為這些數(shù)據(jù)其他線程并不可見.
順序一致模式也保證了所有線程間(原子變量(使用 memory_order_seq_cst 模式)的修改順序)的一致性.以下代碼中所有的斷言都不會失敗(x 和 y 的初始值為 0):
-Thread 1- -Thread 2- -Thread 3- y.store (20); if (x.load() == 10) { if (y.load() == 10) x.store (10); assert (y.load() == 20) assert (x.load() == 10) y.store (10) }
從順序性代碼的角度來看,似乎這是(所有斷言都不會失敗)理所當(dāng)然的,但是在多線程環(huán)境下,我們必須同步系統(tǒng)總線才能達到這種效果(以使線程 3 與線程 2 觀察到的原子變量(使用 memory_order_seq_cst 模式)變更順序一致),可想而知,這往往需要昂貴的硬件同步.
由于保證順序一致的特性, 順序一致模式成為了原子操作中默認使用的內(nèi)存模式, 當(dāng)程序員使用這種模式時,一般不太可能獲得意外的程序結(jié)果.
寬松模式(relaxed)
與順序一致模式相對的就是 std::memory_order_relaxed 模式,即寬松模式.由于去除了先發(fā)生于(happens-before)這個關(guān)系限制, 寬松模式僅需極少的同步指令即可實現(xiàn).這種模式下,不同于之前的順序一致模式,我們可以對原子變量操作進行各種優(yōu)化了,譬如執(zhí)行死代碼刪除等等.
看一下之前的示例:
-Thread 1- y.store (20, memory_order_relaxed) x.store (10, memory_order_relaxed) -Thread 2- if (x.load (memory_order_relaxed) == 10) { assert (y.load(memory_order_relaxed) == 20) /* assert A */ y.store (10, memory_order_relaxed) } -Thread 3- if (y.load (memory_order_relaxed) == 10) assert (x.load(memory_order_relaxed) == 10) /* assert B */
由于線程間不再需要同步(譯注:由于使用了寬松模式,原子操作之間不再形成同步關(guān)系,這里的不需要同步指的是不需要原子操作間的同步),所以代碼中的任一斷言都可能失敗.
由于沒有了先發(fā)生于(happens-before)的關(guān)系,從單一線程的角度來看,其他線程不再存在對其可見的特定原子變量寫入順序.如果使用時不是非常小心,寬松模式會導(dǎo)致很多非預(yù)期的結(jié)果.這個模式唯一保證的一點就是: 一旦線程 2 觀察到了線程 1 中對某一原子變量的寫入數(shù)值,那么線程 2 就不會再看到線程 1 對該變量更早的寫入數(shù)值.
我們還是來看個示例(假定 x 的初始值為 0):
-Thread 1- x.store (1, memory_order_relaxed) x.store (2, memory_order_relaxed) -Thread 2- y = x.load (memory_order_relaxed) z = x.load (memory_order_relaxed) assert (y <= z)
代碼中的斷言不會失敗.一旦線程 2 讀取到 x 的數(shù)值為 2,那么線程 2 后面對 x 的讀取操作將不可能取得數(shù)值 1(1 較 2 是 x 更早的寫入數(shù)值).這一特性導(dǎo)致了一個結(jié)果:
如果代碼中存在多個對同一變量的寬松模式讀取,但是這些讀取之間存在對其他引用(可能是之前同一變量的別名)的寬松模式讀取,那么我們不能把這多個對同一變量的寬松模式讀取合并(多個讀取并成一個).
這里還有一個假定就是某一線程對于原子變量的寬松寫入將在一段合理的時間內(nèi)對另一線程可見(通過寬松讀取).這意味著,在一些非緩存一致的體系架構(gòu)上, 寬松操作需要主動的去刷新緩存(當(dāng)然,刷新操作可以進行合并,譬如在多個寬松操作之后再進行一次刷新操作).
寬松模式最常用的場景就是當(dāng)我們僅需要一個原子變量,而不需要使用該原子變量同步線程間共享內(nèi)存的時候.(譯注:譬如一個原子計數(shù)器)
獲得/釋放模式(acquire/release)
第三種模式混合了之前的兩種模式.獲得/釋放模式類似于之前的順序一致模式,不同的是該模式只保證依賴變量間產(chǎn)生先發(fā)生于(happens-before)的關(guān)系.這也使得獨立讀取操作和獨立寫入操作之間只需要比較少的同步.
假設(shè) x 和 y 的初始值為 0 :
-Thread 1- y.store (20, memory_order_release); -Thread 2- x.store (10, memory_order_release); -Thread 3- assert (y.load (memory_order_acquire) == 20 && x.load (memory_order_acquire) == 0) -Thread 4- assert (y.load (memory_order_acquire) == 0 && x.load (memory_order_acquire) == 10)
代碼中的兩個斷言可能同時通過,因為線程 1 和線程 2 中的兩個寫入操作并沒有先后順序.
但是如果我們使用順序一致模式來改寫上面的代碼,那么這兩個寫入操作中必然有一個寫入先發(fā)生于(happens-before)另一個寫入(盡管運行時才能確定實際的先后順序),并且這個順序是多線程一致的(通過必要的同步操作),所以代碼中如果一個斷言通過,那么另一個斷言就一定會失敗.
如果我們在代碼中使用非原子變量,那么事情會變的更復(fù)雜一些,但是這些非原子變量的可見性同他們是原子變量時是一致的(譯注:參看下面代碼).任何原子寫入操作(使用釋放模式)之前的寫入對于其他同步的線程(使用獲取模式并且讀取到了之前釋放模式寫入的數(shù)值)都是可見的.
-Thread 1- y = 20; x.store (10, memory_order_release); -Thread 2- if (x.load(memory_order_acquire) == 10) assert (y == 20);
線程 1 中對 y 的寫入(y = 20)先發(fā)生于對 x 的寫入(x.store (10, memory_order_release)),因此線程 2 中的斷言不會失敗(譯注:這里說的有些簡略,擴展來講的話應(yīng)該是線程 1 中 對 y 的寫入 先發(fā)生于 對 x 的寫入, 而線程 1 中 對 x 的寫入 又同步于線程 2 中 對 x 的讀取, 由于線程 2 中 對 x 的讀取 又先發(fā)生于 對 y 的斷言,于是線程 1 中 對 y 的寫入 先發(fā)生于線程 2 中 對 y 的斷言,這個 對 y 的斷言 也就不會失敗了).由于有上述的同步要求,原子操作周圍的共享內(nèi)存(非原子變量)操作一樣有優(yōu)化上的限制(譯注:不能隨意對這些操作進行優(yōu)化,以上面代碼為例,優(yōu)化操作不能將 y = 20 重排于 x.store (10, memory_order_release) 之后).
消費/釋放模式(consume/release)
消費/釋放模式是對獲取/釋放模式進一步的改進,該模式下,非依賴共享變量的先發(fā)生于關(guān)系不再成立.
假設(shè) n 和 m 是兩個一般的共享變量,初始值都為 0,并且假設(shè)線程 2 和 線程 3 都讀取到了線程 1 中對原子變量 p 的寫入(譯注:注意代碼前提).
-Thread 1- n = 1 m = 1 p.store (&n, memory_order_release) -Thread 2- t = p.load (memory_order_acquire); assert( *t == 1 && m == 1 ); -Thread 3- t = p.load (memory_order_consume); assert( *t == 1 && m == 1 );
線程 2 中的斷言不會失敗,因為線程 1 中 對 m 的寫入 先發(fā)生于 對 p 的寫入.
但是線程 3 中的斷言就可能失敗了,因為 p 和 m 沒有依賴關(guān)系,而線程 3 中讀取 p 使用了消費模式,這導(dǎo)致線程 1 中 對 m 的寫入 并不能與線程 3 中的 斷言 形成先發(fā)生于的關(guān)系,該 斷言 自然也就可能失敗了.PowerPC 架構(gòu)和 ARM 架構(gòu)中,指針加載的默認內(nèi)存模式就是消費模式(一些 MIPS 架構(gòu)可能也是如此).
另外的,線程 1 和 線程 2 都能夠正確的讀取到 n 的數(shù)值,因為 n 和 p 存在依賴關(guān)系(譯注: p.store (&n, memory_order_release), p 中寫入了 n 的地址,于是 p 和 n 形成依賴關(guān)系).
內(nèi)存模式的真正區(qū)別其實就是為了同步,硬件需要刷新的狀態(tài)數(shù)量.消費/釋放模式相較獲取/釋放模式而言,執(zhí)行速度上會更快一些,可以用于一些對性能極度敏感的程序之中.
總結(jié)
內(nèi)存模式其實并不像聽起來的那么復(fù)雜,為了加深你的理解,我們來看下這個示例:
-Thread 1- y.store (20); x.store (10); -Thread 2- if (x.load() == 10) { assert (y.load() == 20) y.store (10) } -Thread 3- if (y.load() == 10) assert (x.load() == 10)
當(dāng)使用順序一致模式時,所有的共享變量都會在各線程間進行同步,所以線程 2 和 線程 3 中的兩個斷言都不會失敗.
-Thread 1- y.store (20, memory_order_release); x.store (10, memory_order_release); -Thread 2- if (x.load(memory_order_acquire) == 10) { assert (y.load(memory_order_acquire) == 20) y.store (10, memory_order_release) } -Thread 3- if (y.load(memory_order_acquire) == 10) assert (x.load(memory_order_acquire) == 10)
獲取/釋放模式則只要求在兩個線程間(一個使用釋放模式的線程,一個使用獲取模式的線程)進行必要的同步.這意味著這兩個線程間同步的變量并不一定對其他線程可見.線程 2 中的斷言仍然不會失敗,因為線程 1 和 線程 2 通過對 x 的寫入和讀取形成了同步關(guān)系(譯注:參見之前 獲取/釋放模式介紹中的說明),但是線程 3 并不參與線程 1 和 線程 2 的同步,所以當(dāng)線程 2 和 線程 3 通過對 y 的寫入和讀取發(fā)生同步關(guān)系時, 線程 1 與 線程 3 并沒有發(fā)生同步關(guān)系, x 的數(shù)值自然也不一定對線程 3 可見,所以線程 3 中的斷言是可能失敗的.
-Thread 1- y.store (20, memory_order_release); x.store (10, memory_order_release); -Thread 2- if (x.load(memory_order_consume) == 10) { assert (y.load(memory_order_consume) == 20) y.store (10, memory_order_release) } -Thread 3- if (y.load(memory_order_consume) == 10) assert (x.load(memory_order_consume) == 10)
使用消費/釋放模式的結(jié)果與獲取/釋放模式是一致的,區(qū)別只是 消費/釋放模式需要更少的硬件同步操作,那么我們?yōu)槭裁床灰恢笔褂?消費/釋放模式(而不使用獲取/釋放模式)呢?那是因為這個例子中沒有涉及(非原子)共享變量,如果示例中的 y 是一個(非原子)共享變量,由于其與 x 不存在依賴關(guān)系(依賴關(guān)系是指原子變量的寫入數(shù)值由(非原子)共享變量計算而得),那么我們并不一定能夠在線程 2 中看到 y 的當(dāng)前數(shù)值(20),即便線程 2 已經(jīng)讀取到 x 的數(shù)值為 10.
(譯注:這里說因為沒有涉及(非原子)共享變量所以導(dǎo)致消費/釋放模式和獲取/釋放模式表現(xiàn)一致應(yīng)該是不準(zhǔn)確的,將示例中的 assert (y.load(memory_order_consume) == 20) 修改為 assert (y.load(memory_order_relaxed) == 20) 應(yīng)該也能體現(xiàn)出消費/釋放模式和獲取/釋放模式之間的不同,更多的細節(jié)可以參看文章最后的示例)
-Thread 1- y.store (20, memory_order_relaxed); x.store (10, memory_order_relaxed); -Thread 2- if (x.load(memory_order_relaxed) == 10) { assert (y.load(memory_order_relaxed) == 20) y.store (10, memory_order_relaxed) } -Thread 3- if (y.load(memory_order_relaxed) == 10) assert (x.load(memory_order_relaxed) == 10)
如果所有操作都使用寬松模式,那么代碼中的兩個斷言都可能失敗,因為 寬松模式下沒有同步操作發(fā)生.
混合使用內(nèi)存模式
最后,我們來看下混合使用內(nèi)存模式會發(fā)生什么:
-Thread 1- y.store (20, memory_order_relaxed) x.store (10, memory_order_seq_cst) -Thread 2- if (x.load (memory_order_relaxed) == 10) { assert (y.load(memory_order_seq_cst) == 20) /* assert A */ y.store (10, memory_order_relaxed) } -Thread 3- if (y.load (memory_order_acquire) == 10) assert (x.load(memory_order_acquire) == 10) /* assert B */
首先,我必須提醒你不要這么做(混合使用內(nèi)存模式),因為這會讓人極度困惑! 😃
但這仍然是一個存在的問題,所以讓我們來試著"求解"一下…
想一想代碼中各個同步點到底會發(fā)生了什么:
寫入(store)同步會首先執(zhí)行寫入指令,然后執(zhí)行必要的系統(tǒng)狀態(tài)刷新指令
讀取(load)同步會首先執(zhí)行必要的系統(tǒng)狀態(tài)獲取指令,然后執(zhí)行加載指令
線程 1 : y.store 使用了寬松模式,所以這個寫入操作不會產(chǎn)生同步指令(即系統(tǒng)狀態(tài)刷新指令),并且該操作可能被優(yōu)化操作重排,接下來的 x.store 使用了順序一致模式,所以該操作會強制刷新線程 1 中的各個狀態(tài)(用于線程間的同步),并且會保證之前的 y.store 先發(fā)生于 x.store.
線程 2 : x.load 使用了寬松模式,所以該操作不會產(chǎn)生同步指令,即便線程 1 將其狀態(tài)刷新到了系統(tǒng)之中, 線程 2 也并沒有確保自己與系統(tǒng)之間的同步(因為沒有執(zhí)行同步指令).這意味著線程 2 中的數(shù)據(jù)處于一種未知狀態(tài)之中,即使線程 2 讀取到了 x 的數(shù)值為 10, 線程 1 中 x.store(10) 之前的寫入(y.store (20, memory_order_relaxed))對線程 2 也不一定是可見的,所以線程 2 中的斷言可能會失敗.
但奇怪的是, 線程 2 中對 y 的讀取使用了順序一致模式(y.load(memory_order_seq_cst)),這會產(chǎn)生一個同步操作(在讀取操作之前),進而導(dǎo)致線程 2 與系統(tǒng)發(fā)生同步(讀取到 y 的最新數(shù)值),于是斷言就不會失敗了… 有些混亂,對吧~
線程 3 : y.load 使用了獲取模式,所以他會在讀取之前執(zhí)行獲取系統(tǒng)狀態(tài)的指令,但不幸的是,線程 2 中的 y.store 使用的是寬松模式,所以不會產(chǎn)生系統(tǒng)狀態(tài)刷新的指令,并且可能被優(yōu)化操作重排(譯注:重排的影響在這個例子中應(yīng)該可以忽略),所以線程 3 中的斷言仍然可能是失敗的.
最后要說明的一點是: 混合使用內(nèi)存模式是危險的,尤其是當(dāng)模式中包含寬松模式的時候.小心的混合使用 順序一致模式(seq_cst) 和 獲取/釋放模式(acquire/release) 應(yīng)該是可行的,但是需要你熟稔這兩個模式的各種工作細節(jié),除此之外,你可能還需要一些優(yōu)秀的調(diào)試工具!!!
后記
關(guān)于 std:memory_order_consume, 自 C++11 引入以來,似乎從來沒有被編譯器正確實現(xiàn)過(編譯器都直接將其當(dāng)作
std:memory_order_acquire 來處理), C++17 則直接將其列為暫時不推薦使用的特性, C++20 中有可能將其廢棄.
內(nèi)存模型這個話題確實有些晦澀,網(wǎng)上相關(guān)的資料也很多,初次接觸的朋友推薦從這里的系列博文開始.
感到疑問的朋友也可以直接留言,大家一起討論.
以上所述是小編給大家介紹的C++中的內(nèi)存同步模式詳解整合,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時回復(fù)大家的。在此也非常感謝大家對腳本之家網(wǎng)站的支持!
相關(guān)文章
C++實現(xiàn)圖書管理系統(tǒng)(文件操作與類)
這篇文章主要為大家詳細介紹了C++實現(xiàn)圖書管理系統(tǒng),文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2022-03-03C語言變長數(shù)組 struct中char data[0]的用法詳解
下面小編就為大家?guī)硪黄狢語言變長數(shù)組 struct中char data[0]的用法詳解。小編覺得挺不錯的現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-01-01C語言字符串函數(shù)操作(strlen,strcpy,strcat,strcmp)詳解
大家好,本篇文章主要講的是C語言字符串函數(shù)操作(strlen,strcpy,strcat,strcmp)詳解,感興趣的同學(xué)趕快來看一看吧2021-12-12c語言實現(xiàn)的貨物管理系統(tǒng)實例代碼(增加刪除 查找貨物信息等功能)
這篇文章主要介紹了c語言實現(xiàn)的貨物管理系統(tǒng),可增加刪除、查找貨物信息、顯示貨物信息、排序貨物銷量等操作,大家參考使用吧2013-11-11詳解C++數(shù)組和數(shù)組名問題(指針、解引用)
這篇文章主要介紹了詳解C++數(shù)組和數(shù)組名問題(指針、解引用),指針的實質(zhì)就是個變量,它跟普通變量沒有任何本質(zhì)區(qū)別,指針本身是一個對象,同時指針無需在定義的時候賦值,具體內(nèi)容詳情跟隨小編一起看看吧2021-09-09