【C#】通過一個案例 徹底了解 Volatile和 內存屏障


案例如下的。我個人理解是不會出現出現0,0的結果,但是很明顯出現了。

說明對我對 Volatile\內存屏障\亂序排序的理解是不對。

今天就通過這個案例,理清這些概念。

using System; using System.Threading; using System.Threading.Tasks; namespace MemoryBarriers { class Program { static volatile int x, y, a, b; static void Main() { while (true) { var t1 = Task.Run(Test1); var t2 = Task.Run(Test2); Task.WaitAll(t1, t2); if (a == 0 && b == 0) { Console.WriteLine("{0}, {1}", a, b); } x = y = a = b = 0; } } static void Test1() { x = 1; //方案一,只用一個 Interlocked.MemoryBarrierProcessWide();test2不需要添加內存屏障。問題就可以解決
//方案二 Interlocked.MemoryBarrier(); 為什么不用這個內存屏障,即使添加了也還是會出現,必須同時在test、和test2中同時添加 a = y; } static void Test2() { y = 1;

//方案二 Interlocked.MemoryBarrier(); b = x; } } }

對這個案例我提出幾個問題:

1、為什么不用interlocked.MemoryBarrier(),它和Interlocked.MemoryBarrierProcessWide();有什么區別

2、 即使在test1中添加了Interlocked.MemoryBarrier()也還是會出現(0,0)的結果,雖然輸出結果過程很慢,只有同時在test、和test2中添加才能完全杜絕這個問題。

3、為什么會出現0,0的結果

帶着這幾個疑問開始搜索答案,首先這些問題和多任務、多核、多線程有關系,所以應該從緩存一致性入手。

先給答案

問題在於無序執行。讓我們看一下 Test1 的反匯編代碼。如果你不熟悉x86組裝,不用擔心,它其實很簡單,我已經為相關行添加了注釋。

#MemoryBarriers.Program.Test1()
    #function prolog ommitted
    L0015: mov dword ptr [rax+8], 1  # 上傳1到x的內存空間
    L001c: mov edx, [rax+0xc]        # 將y值加載到寄存器
    L001f: mov [rax+0x10], edx.      # 將y寄存器中的值,上傳到變量a的內存空間 
    L0022: add rsp, 0x28.           
    L0026: ret

cpu訪問寄存器是1個時鍾周期,而訪問內存要106個時鍾周期,所以用上傳和下載來替代寫入和讀取 顯得更合理。

為了從變量y中讀取值並將其分配給另一個內存位置a,我們必須將y讀取到CPU寄存器中,在這種情況下,edx僅用於此目的,然后我們才能將y分配給目標變量a。

    作為開發人員,您正在開發一個應用程序,假設您有一些獨立的上傳下載操作到某些Web服務。您將如何設計此類呼叫?您可以並行化它們以節省時間!這正是CPU的作用。 CPU足夠聰明,可以弄清楚這些上傳和下載操作不會在每個線程上相互影響,並且為了節省時間,這意味着這些指令的執行順序可以根據哪一個先完成而改變。因此亂序執行。

   然而,我們已經知道我們的代碼並沒有像我們想要的那樣工作。因為cpu沒有按我們的做出的假設執行 。這個假設只是基於每個線程的依賴項檢查。不幸的是,CPU在決定指令獨立性時不能考慮多線程,對於這種情況,我們必須手動幫助它。

 y=1是釋放語義。它允許后面的讀指令在它前面執行。b=x分成兩條指令 一條是【讀取x值】  一條是【將x寄存器中的值賦值給y】。所以CPU會優化一下將【讀取x值】的指令移動到【 y=1】指令之前執行 。這直接導致b=0;同樣的道理得出a=0;

 解決方案

我們已經看到,那些來自波動的半"內存屏障"是沒有幫助的。那么我們能做些什么呢?加入全內存屏障內存屏障是針對 CPU 的特殊鎖定指令,禁止指令跨屏障重新排序。因此,該程序的行為是意料之中的,但作為缺點,速度將慢數十納秒。

在我們的示例中,我注釋了一行:

   //Interlocked.MemoryBarrierProcessWide(); 

如果取消注釋該行,程序將按預期工作。但這是一個相當奇特的呼吁。.NET 中的實際內存障礙是通過以下方式發出的:

   Interlocked.MemoryBarrier(); 

但是,如果您只是使用MemoryBarrier,您仍然會看到0,0的出現速度較慢!這樣做的原因是,由於我們有兩個線程,對於實際的解決方案,我們必須在這兩個函數中設置兩個內存屏障。只有這樣,程序才能正常運行。然而,作為開發人員,在現實生活中的項目中,你能確定這樣的第3個方法不存在嗎?使用相同變量的此類代碼可能隱藏在不同的庫中,我們可能沒有意識到這一點。因此,Interlocked.MemoryBarrierProcessWide()是你從軌道按鈕上獲得的核武器。它確保這些變量的這種重新排序永遠不會成為"進程范圍"的問題。話雖如此,需要格外小心。我預計,這將是超級慢的。不過,我會采用慢代碼而不是錯誤的代碼。不過,如果您可以完全控制代碼,則首選單個內存屏障而不是進程范圍的內存屏障(或鎖定關鍵字本質上執行相同的操作)。

 =======================================================擴展學習  摸索的過程 摸索了2天=====================================

1、緩存一致性

在分析問題之前必須對緩存一致性有完全理解,並且知道MESI協議的工作原理和通信消息。我這里簡要羅列以下:

 緩存一致性協議有MSI,MESI,MOSI,Synapse,Firefly及DragonProtocol等等

inter芯片采用MESI協議。

MESI消息的類型,有如下幾種:

  • Read : 請求消息,用於通知其他處理器、主內存,當前處理器准備讀取某個數據。該消息內包含待讀取數據的主內存地址。

  • Read Response: 響應消息,該消息內包含了被請求讀取的數據。該消息可能是主內存返回的,也可能是其他高速緩存嗅探到Read 消息返回的。

  • Invalidate: 請求消息,通知其他處理器刪除指定內存地址的數據副本。其實就是告訴他們你這個緩存條目內的數據無效了,刪除只是邏輯上的,其實就是更新下緩存條目的Flag.

  • Invalidate Acknowledge: 響應消息,接收到Invalidate消息的處理器必須回復此消息,表示已經刪除了其高速緩存內對應的數據副本。

  • Read Invalidate: 請求消息,此消息為Read 和 Invalidate消息組成的復合消息,作用主要是用於通知其他處理器當前處理器准備更新一個數據了,並請求其他處理器刪除其高速緩存內對應的數據副本。接收到該消息的處理器必須回復Read Response 和 Invalidate Acknowledge消息。

  • Writeback: 請求消息,消息包含了需要寫入主內存的數據和其對應的內存地址。

詳細請查看:https://www.cnblogs.com/cdaniu/p/15758916.html

2、MESI協議帶來的問題

MESI協議帶來的問題

1、MESI協議的消息在cpu的多core之間通信需要消耗時間,導致內核在此期間將無事可做。甚至一旦某一個內核發生阻塞,將會導致其他內核也處於阻塞,從而帶來性能和穩定性的極大消耗。

 

2、MESI協議狀態切換需要時間。

為了解決這兩個問題,引入了store buffer和invalidate queue

  • store buffer:將cpu的寫入先保存這個堆棧中。等到得到其他cpu已經更新緩存行的消息后,再將數據寫入緩存。
  • invalidate queue:當前cpu的緩存收到其他cpu緩存行狀態變更的消息時就回復Acknowledge 消息,不用等當前緩存行更新完后在回復其他cpu緩存信息、並且當前cpu的緩存下設置一個invalidate queue。用戶存儲其他cpu的緩存發過來的信息,等到有空再處理。

Store Buffere---存儲緩存器

store buffer即存儲緩存。也是同常所說的寫緩存(WriteBuffer)位於內核和緩存之間。當處理器需要處理將計算結果寫入在緩存中處於shared狀態的數據時,需要通知其他內核將該緩存置為 Invalid(無效),引入store buffer后將不再需要處理器去等待其他內核的響應結果,只需要把修改的數據寫到store buffer,通知其他內核,然后當前內核即可去執行其它指令。當收到其他內核的響應結果后,再把store buffer中的數據寫回緩存,並修改狀態為M。(很類似分布式中,數據一致性保障的異步確認)

Invalidate Queue---失效隊列

簡單說處理器修改數據時,需要通知其它內核將該緩存中的數據置為Invalid(失效),我們將該數據放到了Store Buffere處理。那收到失效指令的這些內核會立即處理這種失效消息嗎?答案是不會的,因為就算是一個內核緩存了該數據並不意味着馬上要用,這些內核會將失效通知放到Invalidate Queue,然后快速返回Invalidate Acknowledge消息(意思就是盡量不耽誤正在用這個數據的內核正常工作)。后續收到失效通知的內核將會從該queue中逐個處理該命令。(意思就是我也不着急用,所以我也不着急處理)。

我們繼續接着聊。

存儲轉發(Store Fowarding)

通過上面內容我們知道了有了寫緩沖器后,處理器在寫數據時直接寫入緩沖器就直接返回了。

那么問題就來了,當我們寫完一個數據又要馬上進行讀取可咋辦呢?話不多說,咱們還是舉個例子來說,如圖:

 

 

此時第一步處理器將變量S的更新后的數據寫入到寫緩沖器返回,接着馬上執行了第二布進行S變量的讀取。由於此時處理器對S變量的更新結果還停留在寫緩沖器中,因此從高速緩存緩存行中讀到的數據還是變量S的舊值。

為了解決這種問題,存儲轉發(Store Fowarding)這個概念上線了。其理論就是處理器在執行讀操作時會先根據相應的內存地址從寫緩沖器中查詢。如果查到了直接返回,否則處理器才會從高速緩存中查找,這種從緩沖器中讀取的技術就叫做存儲轉發。看圖:

 

再解決這兩個問題后,指令重排開始發揮它的價值。想想這種等待有時是沒有必要的,因為在這個等待時間內內核完全可以去干一些其他事情。即當內核處於等待狀態時,不等待當前指令結束接着去處理下一個指令。

 由於寫緩沖器和無效化隊列的出現,處理器的執行都變成了異步操作。緩沖器是每個處理器私有的,一個處理器所存儲的內容是無法被其他處理器讀取的。

但是這時候又帶來另一個問題。內存重排序和可見性的問題

 

 當cpu0要寫數據到本地cache的時候,如果不是M或者E狀態,需要發送一個invalidate消息給cpu1,只有收到cpu1的acknowledgement才能寫數據到cache中,在這個過程中cpu0需要等待,這大大影響了性能。一種解決辦法是在cpu和cache之間引入store buffer,當發出invalidate之后直接把數據寫入store buffer。當收到acknowledgement之后可以把store buffer中的數據寫入cache。現在的架構圖是這樣的:

 

 

 3、內存重排序和可見性的問題

 

由於store buffer和無效化隊列的出現,處理器的執行都變成了異步操作。緩沖器是每個處理器私有的,一個處理器所存儲的內容是無法被其他處理器讀取的。

舉個例子:

CPU1 更新變量到store buffer中,而CPU2因為無法讀取到CPU1 store buffer內容所以從高速緩存中讀取的仍然是該變量舊值。

其實這就是store buffer導致StoreLoad重排序問題,而store buffer還會導致StoreStore重排序問題等。

為了使一個處理器上運行的線程對共享變量所做的更新被其他處理器上運行的線程讀到,我們必須將store buffer的內容寫到其他處理器的高速緩存上,從而使在緩存一致性協議作用下此次更新可以被其他處理器讀取到。

處理器在store buffer滿、I/O指令被執行時會將store buffer中的內容寫入高速緩存中。但從變量更新角度來看,處理器本身無法保障這種更新的”及時“性。為了保證處理器對共享變量的更新可被其他處理器同步,編譯器等底層系統借助一類稱為內存屏障的特殊指令來實現。

內存屏障中的存儲屏障(Store Barrier)會使執行該指令的處理器將store buffer內容寫入高速緩存。

內存屏障中的加載屏障(Load Barrier)會根據無效化隊列內容指定的內存地址,將相應處理器上的高速緩存中相應的緩存條目狀態標記為I。

 

四、內存屏障

因為說了存儲屏障(Store Barrier)和加載屏障(Load Barrier) ,所以這里再簡單的提下內存屏障的概念。

划重點:(你細品)

處理器支持哪種內存重排序(LoadLoad重排序、LoadStore重排序、StoreStore重排序、StoreLoad重排序),就會提供相對應能夠禁止重排序的指令,而這些指令就被稱之為內存屏障(LoadLoad屏障、LoadStore屏障、StoreStore屏障、StoreLoad屏障)

划重點:

如果用X和Y來代替Load或Store,這類指令的作用就是禁止該指令左側的任何 X 操作與該指令右側的任何 Y 操作之間進行重排序(就是交換位置),確保指令左側的所有 X 操作都優先於指令右側的Y操作。

內存屏障的具體作用:

屏障名稱 示例 具體作用
StoreLoad Store1;Store2;Store3;StoreLoad;Load1;Load2;Load3 禁止StoreLoad重排序,確保屏障之前任何一個寫(如Store2)的結果都會在屏障后任意一個讀操作(如Load1)加載之前被寫入
StoreStore Store1;Store2;Store3;StoreStore;Store4;Store5;Store6 禁止StoreStore重排序,確保屏障之前任何一個寫(如Store1)的結果都會在屏障后任意一個寫操作(如Store4)之前被寫入
LoadLoad Load1;Load2;Load3;LoadLoad;Load4;Load5;Load6 禁止LoadLoad重排序,確保屏障之前任何一個讀(如Load1)的數據都會在屏障后任意一個讀操作(如Load4)之前被加載
LoadStore Load1;Load2;Load3;LoadStore;Store1;Store2;Store3 禁止LoadStore重排序,確保屏障之前任何一個讀(如Load1)的數據都會在屏障后任意一個寫操作(如Store1)的結果被寫入高速緩存(或主內存)前被加載

內存屏障中的加載屏障(Load Barrier)會根據無效化隊列內容指定的內存地址,將相應處理器上的高速緩存中相應的緩存條目狀態標記為I。
X86中有三種內存屏障:
Store Memory Barrier:寫屏障,等同於前文的StoreStore Barriers 將store buffer都寫入緩存。
告訴處理器在執行這之后的指令之前,執行所有已經在存儲緩存(store buffer)中的修改(M)指令。即:所有store barrier之前的修改(M)指令都是對之后的指令可見。


Load Memory Barrier:讀屏障,等同於前文的LoadLoad Barriers 將Invalidate的 都執行完成。
告訴處理器在執行任何的加載前,執行所有已經在失效隊列(Invalidte Queues)中的失效(I)指令。即:所有load barrier之前的store指令對之后(本核心和其他核心)的指令都是可見的。
Full Barrier:萬能屏障,即Full barrier作用等同於以上二者之和。將將store buffer都寫入緩存並且將Invalidate的 都執行完成
即所有store barrier之前的store指令對之后的指令都是可見的,之后(本核心和其他核心)的指令也都是可見的,完全保證了數據的強一致性。

內存屏障的用法案例解說:

 cpu0cache里面有個b,初值為0,cpu1cache有個a,初值為0,現在cpu0運行foo, cpu1運行bar

    void foo(void) 
    { 
    a = 1; 
    smp_mb(); //內存屏障
    b = 1; 
    } 
    void bar(void) 
    { 
    while (b == 0) continue; 
    smp_mb(); //內存屏障
                             
    assert(a == 1); 
    } 


在assert之前插入內存屏障,作用是把invalidate queue標記下,在讀取下面的數據的時候,譬如a的時候會先把invalidate queue中的消息都處理掉,這里的話會使得a失效而去cpu0獲取最新的數據。

進而我們知道smp_mb有兩個作用,1,標記store buffer,在處理之后的寫請求之前需要把store buffer中的數據apply到cache,2,標記invalidate queue,在加載之后的數據之前把invalidate queue中的消息都處理掉

 進而我們再觀察上面的例子,我們發現,在foo中我們不需要處理invalidate queue,而在bar中,我們不需要處理store buffer,我們可以使用一種更弱的內存屏障來修改上例讓我們程序的性能更高,smp_wmb寫屏障,只會標記store buffer,smp_rmb讀屏障,只會標記invalidate queue,代碼如下:

    void foo(void) 
    { 
    a = 1; 
    smp_wmb(); //寫屏障
    b = 1; 
    } 
    void bar(void) 
    { 
    while (b == 0) continue; 
    smp_rmb(); //讀屏障
    assert(a == 1); 

 

 

 內存屏障的問題

CPU知道什么時候需要加入內存屏障,什么時候不需要嗎?CPU將這個加入內存屏障的時機交給了程序員。在java中這個加入內存屏障的命令就是volatile關鍵字。
澄清一點,volatile並不是僅僅加入內存屏障這么簡單,加入內存屏障只是volatile內核指令級別的內存語義。
除此之外:volatile還可以禁止編譯器的指令重排,因為JVM為了優化性能並且不違反happens-before原則的前提下也會進行指令重排。

 

易失性(volatile)關鍵字

該關鍵字指示編譯器在每次從該字段讀取時生成一個獲取圍欄,並在每次寫入該字段時生成一個釋放圍欄。獲取柵欄可防止其他讀取/寫入操作在柵欄之前移動;釋放柵欄可防止其他讀取/寫入操作在柵欄之后移動。這些"半圍欄"比完整圍欄更快,因為它們為運行時和硬件提供了更大的優化空間。volatile

碰巧的是,英特爾的 X86 和 X64 處理器始終對讀取應用獲取柵欄,對寫入應用釋放柵欄 (無論您是否使用關鍵字 ), 因此,如果您使用這些處理器,則此關鍵字對硬件沒有影響。但是,它確實對編譯器和 CLR 執行的優化以及 64 位 AMD 和(在更大程度上)安騰處理器有影響。這意味着,由於客戶端運行特定類型的 CPU,您要用volatile關鍵字。

 

 volatile字段的應用效果可以總結如下:

第一條指令 第二條指令 它們可以交換嗎?
否(CLR 確保永遠不會交換寫寫操作,即使沒有關鍵字)volatile
是的!

 參考:

揭開易失性關鍵字|的神秘面紗奧努爾·古姆斯的博客 (onurgumus.github.io)

C# - 理論與實踐中的 C# 內存模型,第 2 部分 | Microsoft Docs

C# 中的線程處理 - 第 4 部分 - 高級線程處理 (albahari.com)

hwViewForSwHackers.pdf (puppetmastertrading.com)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM