您好,登錄后才能下訂單哦!
內存模型中的同步模式(memory model synchronization modes)
原子變量同步是內存模型中最讓人感到困惑的地方.原子(atomic)變量的主要作用就是同步多線程間的共享內存訪問,一般來講,某個線程會創建一些數據,然后給原子變量設置標志數值(譯注:此處的原子變量類似于一個flag);其他線程則讀取這個原子變量,當發現其數值變為了標志數值之后,之前線程中的共享數據就應該已經創建完成并且可以在當前線程中進行讀取了.不同的內存同步模式標識了線程間數據共享機制的"強弱"程度,富有經驗的程序員可以使用"較弱"的同步模式來提高程序的執行效率.
每一個原子類型都有一個 load() 方法(用于加載操作)和一個 store() 方法(用于存儲操作).使用這些方法(而不是普通的讀取操作)可以更清晰的標示出代碼中的原子操作.
atomic_var1.store(atomic_var2.load()); // atomic variables vs var1 = var2; // regular variables
這些方法還支持一個可選參數,這個參數可以用于指定內存模型的同步模式.
目前這些用于線程間同步的內存模式共有 3 種,我們依此來看下~
順序一致模式(sequentially consistent)
第一種模式是順序一致模式(sequentially consistent),這也是原子操作的默認模式,同時也是限制最嚴格的一種模式.我們可以通過 std::memory_order_seq_cst 來顯示的指定這種模式.這種模式下,線程間指令重排的限制與在順序性代碼中進行指令重排的限制是一致的.
觀察以下代碼:
-Thread 1- -Thread 2- y = 1 if (x.load() == 2) x.store (2); assert (y == 1)
雖然代碼中的 x 和 y 是沒有關聯的兩個變量,但是代碼中指定的內存模型(譯注:代碼中沒有顯示指定,則使用默認的內存模式,即順序一致模式)保證了線程 2 中的斷言不會失敗.線程 1 中 對 y 的寫入 先發生于(happens-before) 對 x 的寫入,如果線程 2 讀取到了線程 1 對 x 的寫入(x.load() == 2),那么線程 1 中 對 x 寫入 之前的所有寫入操作都必須對線程 2 可見,即使對于那些和 x 無關的寫入操作也是如此.這意味著優化操作不能重排線程 1 中的兩個寫入操作(y = 1 和 x.store (2)),因為當線程 2 讀取到線程 1 對 x 的寫入之后,線程 1 對 y 的寫入也必須對線程 2 可見.
(譯注:編譯器或者 CPU 會因為性能因素而重排代碼指令,這種重排操作對于單線程程序而言是無感知的,但是對于多線程程序而言就不是了,拿上面代碼舉例,如果將 x.store (2) 重排于 y = 1 之前,那么線程 2 中即使讀取發現 x == 2 了,但此時 y 的數值也不一定是 1)
加載操作也有類似的優化限制:
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 一直循環到 y 發生數值變更,然后對 a 進行賦值;線程 1 則一直在等待 a 發生數值變化.
從順序性代碼的角度來看,線程 1 中的代碼 ‘while (a.load() == x)' 似乎是一個無限循環,編譯器編譯這段代碼時也可能會直接將其優化為一個無限循環(譯注:優化為 while (true); 之類的指令);但實際上,我們必須保證每次循環都對 a 執行讀取操作(a.load()) 并且將其與 x 進行比較,否則線程 1 和 線程 2 將不能正常工作(譯注:線程 1 將進入無限循環,與正確的執行結果不一致).
從實踐的角度講,所有的原子操作都相當于優化屏障(譯注:用于阻止優化操作的指令).原子操作(load/store)可以類比為副作用未知的函數調用,優化操作可以在原子操作之間任意的調整代碼順序,但是不能越過原子操作(譯注:原子操作類似于是優化調整的邊界),當然,線程的私有數據并不受此影響,因為這些數據其他線程并不可見.
順序一致模式也保證了所有線程間(原子變量(使用 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) }
從順序性代碼的角度來看,似乎這是(所有斷言都不會失敗)理所當然的,但是在多線程環境下,我們必須同步系統總線才能達到這種效果(以使線程 3 與線程 2 觀察到的原子變量(使用 memory_order_seq_cst 模式)變更順序一致),可想而知,這往往需要昂貴的硬件同步.
由于保證順序一致的特性, 順序一致模式成為了原子操作中默認使用的內存模式, 當程序員使用這種模式時,一般不太可能獲得意外的程序結果.
寬松模式(relaxed)
與順序一致模式相對的就是 std::memory_order_relaxed 模式,即寬松模式.由于去除了先發生于(happens-before)這個關系限制, 寬松模式僅需極少的同步指令即可實現.這種模式下,不同于之前的順序一致模式,我們可以對原子變量操作進行各種優化了,譬如執行死代碼刪除等等.
看一下之前的示例:
-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 */
由于線程間不再需要同步(譯注:由于使用了寬松模式,原子操作之間不再形成同步關系,這里的不需要同步指的是不需要原子操作間的同步),所以代碼中的任一斷言都可能失敗.
由于沒有了先發生于(happens-before)的關系,從單一線程的角度來看,其他線程不再存在對其可見的特定原子變量寫入順序.如果使用時不是非常小心,寬松模式會導致很多非預期的結果.這個模式唯一保證的一點就是: 一旦線程 2 觀察到了線程 1 中對某一原子變量的寫入數值,那么線程 2 就不會再看到線程 1 對該變量更早的寫入數值.
我們還是來看個示例(假定 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 的數值為 2,那么線程 2 后面對 x 的讀取操作將不可能取得數值 1(1 較 2 是 x 更早的寫入數值).這一特性導致了一個結果:
如果代碼中存在多個對同一變量的寬松模式讀取,但是這些讀取之間存在對其他引用(可能是之前同一變量的別名)的寬松模式讀取,那么我們不能把這多個對同一變量的寬松模式讀取合并(多個讀取并成一個).
這里還有一個假定就是某一線程對于原子變量的寬松寫入將在一段合理的時間內對另一線程可見(通過寬松讀取).這意味著,在一些非緩存一致的體系架構上, 寬松操作需要主動的去刷新緩存(當然,刷新操作可以進行合并,譬如在多個寬松操作之后再進行一次刷新操作).
寬松模式最常用的場景就是當我們僅需要一個原子變量,而不需要使用該原子變量同步線程間共享內存的時候.(譯注:譬如一個原子計數器)
獲得/釋放模式(acquire/release)
第三種模式混合了之前的兩種模式.獲得/釋放模式類似于之前的順序一致模式,不同的是該模式只保證依賴變量間產生先發生于(happens-before)的關系.這也使得獨立讀取操作和獨立寫入操作之間只需要比較少的同步.
假設 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 中的兩個寫入操作并沒有先后順序.
但是如果我們使用順序一致模式來改寫上面的代碼,那么這兩個寫入操作中必然有一個寫入先發生于(happens-before)另一個寫入(盡管運行時才能確定實際的先后順序),并且這個順序是多線程一致的(通過必要的同步操作),所以代碼中如果一個斷言通過,那么另一個斷言就一定會失敗.
如果我們在代碼中使用非原子變量,那么事情會變的更復雜一些,但是這些非原子變量的可見性同他們是原子變量時是一致的(譯注:參看下面代碼).任何原子寫入操作(使用釋放模式)之前的寫入對于其他同步的線程(使用獲取模式并且讀取到了之前釋放模式寫入的數值)都是可見的.
-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)先發生于對 x 的寫入(x.store (10, memory_order_release)),因此線程 2 中的斷言不會失敗(譯注:這里說的有些簡略,擴展來講的話應該是線程 1 中 對 y 的寫入 先發生于 對 x 的寫入, 而線程 1 中 對 x 的寫入 又同步于線程 2 中 對 x 的讀取, 由于線程 2 中 對 x 的讀取 又先發生于 對 y 的斷言,于是線程 1 中 對 y 的寫入 先發生于線程 2 中 對 y 的斷言,這個 對 y 的斷言 也就不會失敗了).由于有上述的同步要求,原子操作周圍的共享內存(非原子變量)操作一樣有優化上的限制(譯注:不能隨意對這些操作進行優化,以上面代碼為例,優化操作不能將 y = 20 重排于 x.store (10, memory_order_release) 之后).
消費/釋放模式(consume/release)
消費/釋放模式是對獲取/釋放模式進一步的改進,該模式下,非依賴共享變量的先發生于關系不再成立.
假設 n 和 m 是兩個一般的共享變量,初始值都為 0,并且假設線程 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 的寫入 先發生于 對 p 的寫入.
但是線程 3 中的斷言就可能失敗了,因為 p 和 m 沒有依賴關系,而線程 3 中讀取 p 使用了消費模式,這導致線程 1 中 對 m 的寫入 并不能與線程 3 中的 斷言 形成先發生于的關系,該 斷言 自然也就可能失敗了.PowerPC 架構和 ARM 架構中,指針加載的默認內存模式就是消費模式(一些 MIPS 架構可能也是如此).
另外的,線程 1 和 線程 2 都能夠正確的讀取到 n 的數值,因為 n 和 p 存在依賴關系(譯注: p.store (&n, memory_order_release), p 中寫入了 n 的地址,于是 p 和 n 形成依賴關系).
內存模式的真正區別其實就是為了同步,硬件需要刷新的狀態數量.消費/釋放模式相較獲取/釋放模式而言,執行速度上會更快一些,可以用于一些對性能極度敏感的程序之中.
總結
內存模式其實并不像聽起來的那么復雜,為了加深你的理解,我們來看下這個示例:
-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)
當使用順序一致模式時,所有的共享變量都會在各線程間進行同步,所以線程 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 的寫入和讀取形成了同步關系(譯注:參見之前 獲取/釋放模式介紹中的說明),但是線程 3 并不參與線程 1 和 線程 2 的同步,所以當線程 2 和 線程 3 通過對 y 的寫入和讀取發生同步關系時, 線程 1 與 線程 3 并沒有發生同步關系, x 的數值自然也不一定對線程 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)
使用消費/釋放模式的結果與獲取/釋放模式是一致的,區別只是 消費/釋放模式需要更少的硬件同步操作,那么我們為什么不一直使用 消費/釋放模式(而不使用獲取/釋放模式)呢?那是因為這個例子中沒有涉及(非原子)共享變量,如果示例中的 y 是一個(非原子)共享變量,由于其與 x 不存在依賴關系(依賴關系是指原子變量的寫入數值由(非原子)共享變量計算而得),那么我們并不一定能夠在線程 2 中看到 y 的當前數值(20),即便線程 2 已經讀取到 x 的數值為 10.
(譯注:這里說因為沒有涉及(非原子)共享變量所以導致消費/釋放模式和獲取/釋放模式表現一致應該是不準確的,將示例中的 assert (y.load(memory_order_consume) == 20) 修改為 assert (y.load(memory_order_relaxed) == 20) 應該也能體現出消費/釋放模式和獲取/釋放模式之間的不同,更多的細節可以參看文章最后的示例)
-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)
如果所有操作都使用寬松模式,那么代碼中的兩個斷言都可能失敗,因為 寬松模式下沒有同步操作發生.
混合使用內存模式
最后,我們來看下混合使用內存模式會發生什么:
-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 */
首先,我必須提醒你不要這么做(混合使用內存模式),因為這會讓人極度困惑! 😃
但這仍然是一個存在的問題,所以讓我們來試著"求解"一下…
想一想代碼中各個同步點到底會發生了什么:
寫入(store)同步會首先執行寫入指令,然后執行必要的系統狀態刷新指令
讀取(load)同步會首先執行必要的系統狀態獲取指令,然后執行加載指令
線程 1 : y.store 使用了寬松模式,所以這個寫入操作不會產生同步指令(即系統狀態刷新指令),并且該操作可能被優化操作重排,接下來的 x.store 使用了順序一致模式,所以該操作會強制刷新線程 1 中的各個狀態(用于線程間的同步),并且會保證之前的 y.store 先發生于 x.store.
線程 2 : x.load 使用了寬松模式,所以該操作不會產生同步指令,即便線程 1 將其狀態刷新到了系統之中, 線程 2 也并沒有確保自己與系統之間的同步(因為沒有執行同步指令).這意味著線程 2 中的數據處于一種未知狀態之中,即使線程 2 讀取到了 x 的數值為 10, 線程 1 中 x.store(10) 之前的寫入(y.store (20, memory_order_relaxed))對線程 2 也不一定是可見的,所以線程 2 中的斷言可能會失敗.
但奇怪的是, 線程 2 中對 y 的讀取使用了順序一致模式(y.load(memory_order_seq_cst)),這會產生一個同步操作(在讀取操作之前),進而導致線程 2 與系統發生同步(讀取到 y 的最新數值),于是斷言就不會失敗了… 有些混亂,對吧~
線程 3 : y.load 使用了獲取模式,所以他會在讀取之前執行獲取系統狀態的指令,但不幸的是,線程 2 中的 y.store 使用的是寬松模式,所以不會產生系統狀態刷新的指令,并且可能被優化操作重排(譯注:重排的影響在這個例子中應該可以忽略),所以線程 3 中的斷言仍然可能是失敗的.
最后要說明的一點是: 混合使用內存模式是危險的,尤其是當模式中包含寬松模式的時候.小心的混合使用 順序一致模式(seq_cst) 和 獲取/釋放模式(acquire/release) 應該是可行的,但是需要你熟稔這兩個模式的各種工作細節,除此之外,你可能還需要一些優秀的調試工具!!!
后記
關于 std:memory_order_consume, 自 C++11 引入以來,似乎從來沒有被編譯器正確實現過(編譯器都直接將其當作
std:memory_order_acquire 來處理), C++17 則直接將其列為暫時不推薦使用的特性, C++20 中有可能將其廢棄.
內存模型這個話題確實有些晦澀,網上相關的資料也很多,初次接觸的朋友推薦從這里的系列博文開始.
網上還有不少很好的文章,譬如這里,這里和這里.
感到疑問的朋友也可以直接留言,大家一起討論.
以上所述是小編給大家介紹的C++中的內存同步模式詳解整合,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時回復大家的。在此也非常感謝大家對億速云網站的支持!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。