詳解C++中的內(nèi)存同步模式(memoryorder)

內(nèi)存模型中的同步模式(memory model synchronization modes)

為平利等地區(qū)用戶提供了全套網(wǎng)頁設(shè)計(jì)制作服務(wù),及平利網(wǎng)站建設(shè)行業(yè)解決方案。主營業(yè)務(wù)為網(wǎng)站建設(shè)、成都做網(wǎng)站、平利網(wǎng)站設(shè)計(jì),以傳統(tǒng)方式定制建設(shè)網(wǎng)站,并提供域名空間備案等一條龍服務(wù),秉承以專業(yè)、用心的態(tài)度為用戶提供真誠的服務(wù)。我們深信只要達(dá)到每一位用戶的要求,就會得到認(rèn)可,從而選擇與我們長期合作。這樣,我們也可以走得更遠(yuǎn)!

原子變量同步是內(nèi)存模型中最讓人感到困惑的地方.原子(atomic)變量的主要作用就是同步多線程間的共享內(nèi)存訪問,一般來講,某個(gè)線程會創(chuàng)建一些數(shù)據(jù),然后給原子變量設(shè)置標(biāo)志數(shù)值(譯注:此處的原子變量類似于一個(gè)flag);其他線程則讀取這個(gè)原子變量,當(dāng)發(fā)現(xiàn)其數(shù)值變?yōu)榱藰?biāo)志數(shù)值之后,之前線程中的共享數(shù)據(jù)就應(yīng)該已經(jīng)創(chuàng)建完成并且可以在當(dāng)前線程中進(jìn)行讀取了.不同的內(nèi)存同步模式標(biāo)識了線程間數(shù)據(jù)共享機(jī)制的"強(qiáng)弱"程度,富有經(jīng)驗(yàn)的程序員可以使用"較弱"的同步模式來提高程序的執(zhí)行效率.

每一個(gè)原子類型都有一個(gè) load() 方法(用于加載操作)和一個(gè) store() 方法(用于存儲操作).使用這些方法(而不是普通的讀取操作)可以更清晰的標(biāo)示出代碼中的原子操作.

atomic_var1.store(atomic_var2.load()); // atomic variables
   vs
 var1 = var2;  // regular variables

這些方法還支持一個(gè)可選參數(shù),這個(gè)參數(shù)可以用于指定內(nèi)存模型的同步模式.

目前這些用于線程間同步的內(nèi)存模式共有 3 種,我們依此來看下~

順序一致模式(sequentially consistent)

第一種模式是順序一致模式(sequentially consistent),這也是原子操作的默認(rèn)模式,同時(shí)也是限制最嚴(yán)格的一種模式.我們可以通過 std::memory_order_seq_cst 來顯示的指定這種模式.這種模式下,線程間指令重排的限制與在順序性代碼中進(jìn)行指令重排的限制是一致的.

觀察以下代碼:

 -Thread 1-    -Thread 2-
 y = 1      if (x.load() == 2)
 x.store (2);    assert (y == 1)

雖然代碼中的 x 和 y 是沒有關(guān)聯(lián)的兩個(gè)變量,但是代碼中指定的內(nèi)存模型(譯注:代碼中沒有顯示指定,則使用默認(rèn)的內(nèi)存模式,即順序一致模式)保證了線程 2 中的斷言不會失敗.線程 1 中 對 y 的寫入 先發(fā)生于(happens-before) 對 x 的寫入,如果線程 2 讀取到了線程 1 對 x 的寫入(x.load() == 2),那么線程 1 中 對 x 寫入 之前的所有寫入操作都必須對線程 2 可見,即使對于那些和 x 無關(guān)的寫入操作也是如此.這意味著優(yōu)化操作不能重排線程 1 中的兩個(gè)寫入操作(y = 1 和 x.store (2)),因?yàn)楫?dāng)線程 2 讀取到線程 1 對 x 的寫入之后,線程 1 對 y 的寫入也必須對線程 2 可見.

(譯注:編譯器或者 CPU 會因?yàn)樾阅芤蛩囟嘏糯a指令,這種重排操作對于單線程程序而言是無感知的,但是對于多線程程序而言就不是了,拿上面代碼舉例,如果將 x.store (2) 重排于 y = 1 之前,那么線程 2 中即使讀取發(fā)現(xiàn) x == 2 了,但此時(shí) 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 進(jìn)行賦值;線程 1 則一直在等待 a 發(fā)生數(shù)值變化.

從順序性代碼的角度來看,線程 1 中的代碼 ‘while (a.load() == x)' 似乎是一個(gè)無限循環(huán),編譯器編譯這段代碼時(shí)也可能會直接將其優(yōu)化為一個(gè)無限循環(huán)(譯注:優(yōu)化為 while (true); 之類的指令);但實(shí)際上,我們必須保證每次循環(huán)都對 a 執(zhí)行讀取操作(a.load()) 并且將其與 x 進(jìn)行比較,否則線程 1 和 線程 2 將不能正常工作(譯注:線程 1 將進(jìn)入無限循環(huán),與正確的執(zhí)行結(jié)果不一致).

從實(shí)踐的角度講,所有的原子操作都相當(dāng)于優(yōu)化屏障(譯注:用于阻止優(yōu)化操作的指令).原子操作(load/store)可以類比為副作用未知的函數(shù)調(diào)用,優(yōu)化操作可以在原子操作之間任意的調(diào)整代碼順序,但是不能越過原子操作(譯注:原子操作類似于是優(yōu)化調(diào)整的邊界),當(dāng)然,線程的私有數(shù)據(jù)并不受此影響,因?yàn)檫@些數(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)總線才能達(dá)到這種效果(以使線程 3 與線程 2 觀察到的原子變量(使用 memory_order_seq_cst 模式)變更順序一致),可想而知,這往往需要昂貴的硬件同步.

由于保證順序一致的特性, 順序一致模式成為了原子操作中默認(rèn)使用的內(nèi)存模式, 當(dāng)程序員使用這種模式時(shí),一般不太可能獲得意外的程序結(jié)果.

寬松模式(relaxed)

與順序一致模式相對的就是 std::memory_order_relaxed 模式,即寬松模式.由于去除了先發(fā)生于(happens-before)這個(gè)關(guān)系限制, 寬松模式僅需極少的同步指令即可實(shí)現(xiàn).這種模式下,不同于之前的順序一致模式,我們可以對原子變量操作進(jì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)系,從單一線程的角度來看,其他線程不再存在對其可見的特定原子變量寫入順序.如果使用時(shí)不是非常小心,寬松模式會導(dǎo)致很多非預(yù)期的結(jié)果.這個(gè)模式唯一保證的一點(diǎn)就是: 一旦線程 2 觀察到了線程 1 中對某一原子變量的寫入數(shù)值,那么線程 2 就不會再看到線程 1 對該變量更早的寫入數(shù)值.

我們還是來看個(gè)示例(假定 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)致了一個(gè)結(jié)果:
如果代碼中存在多個(gè)對同一變量的寬松模式讀取,但是這些讀取之間存在對其他引用(可能是之前同一變量的別名)的寬松模式讀取,那么我們不能把這多個(gè)對同一變量的寬松模式讀取合并(多個(gè)讀取并成一個(gè)).

這里還有一個(gè)假定就是某一線程對于原子變量的寬松寫入將在一段合理的時(shí)間內(nèi)對另一線程可見(通過寬松讀取).這意味著,在一些非緩存一致的體系架構(gòu)上, 寬松操作需要主動的去刷新緩存(當(dāng)然,刷新操作可以進(jìn)行合并,譬如在多個(gè)寬松操作之后再進(jìn)行一次刷新操作).

寬松模式最常用的場景就是當(dāng)我們僅需要一個(gè)原子變量,而不需要使用該原子變量同步線程間共享內(nèi)存的時(shí)候.(譯注:譬如一個(gè)原子計(jì)數(shù)器)

獲得/釋放模式(acquire/release)

第三種模式混合了之前的兩種模式.獲得/釋放模式類似于之前的順序一致模式,不同的是該模式只保證依賴變量間產(chǎn)生先發(fā)生于(happens-before)的關(guān)系.這也使得獨(dú)立讀取操作和獨(dú)立寫入操作之間只需要比較少的同步.

假設(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)

代碼中的兩個(gè)斷言可能同時(shí)通過,因?yàn)榫€程 1 和線程 2 中的兩個(gè)寫入操作并沒有先后順序.

但是如果我們使用順序一致模式來改寫上面的代碼,那么這兩個(gè)寫入操作中必然有一個(gè)寫入先發(fā)生于(happens-before)另一個(gè)寫入(盡管運(yùn)行時(shí)才能確定實(shí)際的先后順序),并且這個(gè)順序是多線程一致的(通過必要的同步操作),所以代碼中如果一個(gè)斷言通過,那么另一個(gè)斷言就一定會失敗.

如果我們在代碼中使用非原子變量,那么事情會變的更復(fù)雜一些,但是這些非原子變量的可見性同他們是原子變量時(shí)是一致的(譯注:參看下面代碼).任何原子寫入操作(使用釋放模式)之前的寫入對于其他同步的線程(使用獲取模式并且讀取到了之前釋放模式寫入的數(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 中的斷言不會失敗(譯注:這里說的有些簡略,擴(kuò)展來講的話應(yīng)該是線程 1 中 對 y 的寫入 先發(fā)生于 對 x 的寫入, 而線程 1 中 對 x 的寫入 又同步于線程 2 中 對 x 的讀取, 由于線程 2 中 對 x 的讀取 又先發(fā)生于 對 y 的斷言,于是線程 1 中 對 y 的寫入 先發(fā)生于線程 2 中 對 y 的斷言,這個(gè) 對 y 的斷言 也就不會失敗了).由于有上述的同步要求,原子操作周圍的共享內(nèi)存(非原子變量)操作一樣有優(yōu)化上的限制(譯注:不能隨意對這些操作進(jìn)行優(yōu)化,以上面代碼為例,優(yōu)化操作不能將 y = 20 重排于 x.store (10, memory_order_release) 之后).

消費(fèi)/釋放模式(consume/release)

消費(fèi)/釋放模式是對獲取/釋放模式進(jìn)一步的改進(jìn),該模式下,非依賴共享變量的先發(fā)生于關(guān)系不再成立.

假設(shè) n 和 m 是兩個(gè)一般的共享變量,初始值都為 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 中的斷言不會失敗,因?yàn)榫€程 1 中 對 m 的寫入 先發(fā)生于 對 p 的寫入.

但是線程 3 中的斷言就可能失敗了,因?yàn)?p 和 m 沒有依賴關(guān)系,而線程 3 中讀取 p 使用了消費(fèi)模式,這導(dǎo)致線程 1 中 對 m 的寫入 并不能與線程 3 中的 斷言 形成先發(fā)生于的關(guān)系,該 斷言 自然也就可能失敗了.PowerPC 架構(gòu)和 ARM 架構(gòu)中,指針加載的默認(rèn)內(nèi)存模式就是消費(fèi)模式(一些 MIPS 架構(gòu)可能也是如此).

另外的,線程 1 和 線程 2 都能夠正確的讀取到 n 的數(shù)值,因?yàn)?n 和 p 存在依賴關(guān)系(譯注: p.store (&n, memory_order_release), p 中寫入了 n 的地址,于是 p 和 n 形成依賴關(guān)系).

內(nèi)存模式的真正區(qū)別其實(shí)就是為了同步,硬件需要刷新的狀態(tài)數(shù)量.消費(fèi)/釋放模式相較獲取/釋放模式而言,執(zhí)行速度上會更快一些,可以用于一些對性能極度敏感的程序之中.

總結(jié)

內(nèi)存模式其實(shí)并不像聽起來的那么復(fù)雜,為了加深你的理解,我們來看下這個(gè)示例:

-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)使用順序一致模式時(shí),所有的共享變量都會在各線程間進(jìn)行同步,所以線程 2 和 線程 3 中的兩個(gè)斷言都不會失敗.

-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)

獲取/釋放模式則只要求在兩個(gè)線程間(一個(gè)使用釋放模式的線程,一個(gè)使用獲取模式的線程)進(jìn)行必要的同步.這意味著這兩個(gè)線程間同步的變量并不一定對其他線程可見.線程 2 中的斷言仍然不會失敗,因?yàn)榫€程 1 和 線程 2 通過對 x 的寫入和讀取形成了同步關(guān)系(譯注:參見之前 獲取/釋放模式介紹中的說明),但是線程 3 并不參與線程 1 和 線程 2 的同步,所以當(dāng)線程 2 和 線程 3 通過對 y 的寫入和讀取發(fā)生同步關(guān)系時(shí), 線程 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)

使用消費(fèi)/釋放模式的結(jié)果與獲取/釋放模式是一致的,區(qū)別只是 消費(fèi)/釋放模式需要更少的硬件同步操作,那么我們?yōu)槭裁床灰恢笔褂?消費(fèi)/釋放模式(而不使用獲取/釋放模式)呢?那是因?yàn)檫@個(gè)例子中沒有涉及(非原子)共享變量,如果示例中的 y 是一個(gè)(非原子)共享變量,由于其與 x 不存在依賴關(guān)系(依賴關(guān)系是指原子變量的寫入數(shù)值由(非原子)共享變量計(jì)算而得),那么我們并不一定能夠在線程 2 中看到 y 的當(dāng)前數(shù)值(20),即便線程 2 已經(jīng)讀取到 x 的數(shù)值為 10.

(譯注:這里說因?yàn)闆]有涉及(非原子)共享變量所以導(dǎo)致消費(fèi)/釋放模式和獲取/釋放模式表現(xiàn)一致應(yīng)該是不準(zhǔn)確的,將示例中的 assert (y.load(memory_order_consume) == 20) 修改為 assert (y.load(memory_order_relaxed) == 20) 應(yīng)該也能體現(xiàn)出消費(fèi)/釋放模式和獲取/釋放模式之間的不同,更多的細(xì)節(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)

如果所有操作都使用寬松模式,那么代碼中的兩個(gè)斷言都可能失敗,因?yàn)?寬松模式下沒有同步操作發(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)存模式),因?yàn)檫@會讓人極度困惑! 😃

但這仍然是一個(gè)存在的問題,所以讓我們來試著"求解"一下…

想一想代碼中各個(gè)同步點(diǎn)到底會發(fā)生了什么:

寫入(store)同步會首先執(zhí)行寫入指令,然后執(zhí)行必要的系統(tǒng)狀態(tài)刷新指令
讀取(load)同步會首先執(zhí)行必要的系統(tǒng)狀態(tài)獲取指令,然后執(zhí)行加載指令
線程 1 : y.store 使用了寬松模式,所以這個(gè)寫入操作不會產(chǎn)生同步指令(即系統(tǒng)狀態(tài)刷新指令),并且該操作可能被優(yōu)化操作重排,接下來的 x.store 使用了順序一致模式,所以該操作會強(qiáng)制刷新線程 1 中的各個(gè)狀態(tài)(用于線程間的同步),并且會保證之前的 y.store 先發(fā)生于 x.store.

線程 2 : x.load 使用了寬松模式,所以該操作不會產(chǎn)生同步指令,即便線程 1 將其狀態(tài)刷新到了系統(tǒng)之中, 線程 2 也并沒有確保自己與系統(tǒng)之間的同步(因?yàn)闆]有執(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)生一個(gè)同步操作(在讀取操作之前),進(jì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)化操作重排(譯注:重排的影響在這個(gè)例子中應(yīng)該可以忽略),所以線程 3 中的斷言仍然可能是失敗的.

最后要說明的一點(diǎn)是: 混合使用內(nèi)存模式是危險(xiǎn)的,尤其是當(dāng)模式中包含寬松模式的時(shí)候.小心的混合使用 順序一致模式(seq_cst) 和 獲取/釋放模式(acquire/release) 應(yīng)該是可行的,但是需要你熟稔這兩個(gè)模式的各種工作細(xì)節(jié),除此之外,你可能還需要一些優(yōu)秀的調(diào)試工具!!!

后記

關(guān)于 std:memory_order_consume, 自 C++11 引入以來,似乎從來沒有被編譯器正確實(shí)現(xiàn)過(編譯器都直接將其當(dāng)作

std:memory_order_acquire 來處理), C++17 則直接將其列為暫時(shí)不推薦使用的特性, C++20 中有可能將其廢棄.

內(nèi)存模型這個(gè)話題確實(shí)有些晦澀,網(wǎng)上相關(guān)的資料也很多,初次接觸的朋友推薦從這里的系列博文開始.

網(wǎng)上還有不少很好的文章,譬如這里,這里和這里.

感到疑問的朋友也可以直接留言,大家一起討論.

以上所述是小編給大家介紹的C++中的內(nèi)存同步模式詳解整合,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時(shí)回復(fù)大家的。在此也非常感謝大家對創(chuàng)新互聯(lián)網(wǎng)站的支持!

文章標(biāo)題:詳解C++中的內(nèi)存同步模式(memoryorder)
本文路徑:http://muchs.cn/article32/igedsc.html

成都網(wǎng)站建設(shè)公司_創(chuàng)新互聯(lián),為您提供App設(shè)計(jì)網(wǎng)站營銷、標(biāo)簽優(yōu)化、靜態(tài)網(wǎng)站、域名注冊、定制網(wǎng)站

廣告

聲明:本網(wǎng)站發(fā)布的內(nèi)容(圖片、視頻和文字)以用戶投稿、用戶轉(zhuǎn)載內(nèi)容為主,如果涉及侵權(quán)請盡快告知,我們將會在第一時(shí)間刪除。文章觀點(diǎn)不代表本網(wǎng)站立場,如需處理請聯(lián)系客服。電話:028-86922220;郵箱:631063699@qq.com。內(nèi)容未經(jīng)允許不得轉(zhuǎn)載,或轉(zhuǎn)載時(shí)需注明來源: 創(chuàng)新互聯(lián)

小程序開發(fā)