可能一看下面的代碼你可能會放棄繼續看了,但若是你想要完全弄明白volatile,你須要耐心,下面的代碼很簡單!緩存
在下面的代碼中,咱們定義了4個字段x,y,a和b,它們被初始化爲0
而後,咱們建立2個分別調用Test1和Test2的任務,並等待兩個任務完成。
完成兩個任務後,咱們檢查a和b是否仍爲0,
若是是,則打印它們的值。
最後,咱們將全部內容重置爲0,而後一次又一次地運行相同的循環。多線程
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(); a = y; } static void Test2() { y = 1; b = x; } }
若是您運行上述代碼(最好在Release模式下運行),則會看到輸出爲0、0的許多輸出,以下圖。ide
在Test1中,咱們將x設置爲1,將a設置爲y,而Test2將y設置爲1,將b設置爲x
所以這4條語句會在2個線程中競爭
羅列下可能會發生的幾種狀況:優化
x = 1 a = y y = 1 b = x
在這種狀況下,咱們假設Test1在Test2以前完成,那麼最終值將是spa
x = 1,a = 0,y = 1,b = 1
y = 1 b = x x = 1 a = y
在這種狀況下,那麼最終值將是線程
x = 1,a = 1,y = 1,b = 0
x = 1 y = 1 b = x a = y
在這種狀況下,那麼最終值將是設計
x = 1,a = 1,y = 1,b = 1
y = 1 x = 1 a = y b = x
在這種狀況下,那麼最終值將是code
x = 1,a = 1,y = 1,b = 1
x = 1 y = 1 a = y b = x
在這種狀況下,那麼最終值將是blog
x = 1,a = 1,y = 1,b = 1
y = 1 x = 1 b = x a = y
在這種狀況下,那麼最終值將是排序
x = 1,a = 1,y = 1,b = 1
我認爲上面已經羅列的
已經涵蓋了全部可能的狀況,
可是不管發生哪一種競爭狀況,
看起來一旦兩個任務都完成,
就不可能使a和b都同時爲零,
可是奇蹟般地,竟然一直在打印0,0 (請看上面的動圖,若是你懷疑的話代碼copy執行試試)
先揭曉答案:cpu的亂序執行。
讓咱們看一下Test1和Test2的IL中間代碼。
我在相關部分中添加了註釋。
#ConsoleApp9.Program.Test1() #function prolog ommitted L0015: mov dword ptr [rax+8], 1 # 把值 1 上傳到內存地址 'x' L001c: mov edx, [rax+0xc] # 從內存地址 'y' 下載值並放到edx(寄存器) L001f: mov [rax+0x10], edx. # 從(edx)寄存器把值上傳到內存地址 'a' L0022: add rsp, 0x28. L0026: ret #ConsoleApp9.Program.Test2() #function prolog L0015: mov dword ptr [rax+0xc], 1 # 把值 1 上傳到內存地址 'y' L001c: mov edx, [rax+8]. # 從內存地址 'x' 下載值並放到edx(寄存器) L001f: mov [rax+0x14], edx. # 從(edx)寄存器把值上傳到內存地址 'b' L0022: add rsp, 0x28 L0026: ret
請注意,我在註釋中使用「上載」和「下載」一詞,而不是傳統的讀/寫術語。
爲了從變量中讀取值並將其分配到另外一個存儲位置,
咱們必須將其讀取到CPU寄存器(如上面的edx),
而後才能將其分配給目標變量。
因爲CPU操做很是快,所以與在CPU中執行的操做相比,對內存的讀取或寫入真的很慢。
因此我使用「上傳」和「下載」,相對於CPU的高速緩存而言【讀取和寫入內存的行爲】
就像咱們向遠程Web服務上載或從中下載同樣慢。
L1 cache reference: 1 ns
L2 cache reference: 4 ns
Branch mispredict: 3 ns
Mutex lock/unlock: 17 ns
Main memory reference: 100 ns
Compress 1K bytes with Zippy: 2000 ns
Send 2K bytes over commodity network: 44 ns
Read 1 MB sequentially from memory: 3000 ns
Round trip within same datacenter: 500,000 ns
Disk seek: 2,000,000 ns
Read 1 MB sequentially from disk: 825,000 ns
Read 1 MB sequentially from SSD: 49000 ns
若是讓你開發一個應用程序,實現上載或者下載功能。
您將如何設計此?確定想要開多線程,並行化執行以節省時間!
這正是CPU的功能。CPU被咱們設計的很聰明,
在實際運行中能夠肯定某些「上載」和「下載」操做(指令)不會互相影響,
而且CPU爲了節省時間,對它們(指令)進行了(優化)並行處理,
也叫【cpu亂序執行】(out-of-order)
上面我說道:在實際運行中能夠肯定某些「上載」和「下載」操做(指令)不會互相影響,
這裏有一個前提條件哈:該假設僅基於基於線程的依賴性檢查進行(per-thread basis dependency checks)。
雖然在單個線程是能夠被肯定爲指令獨立性,但CPU沒法考慮多個線程的狀況,因此提供了【volatile關鍵字】
通常說道volatile我都通常都會舉下面的例子(內存可見性)
using System; using System.Threading; public class C { bool completed; static void Main() { C c = new C(); var t = new Thread (() => { bool toggle = false; while (!c.completed) toggle = !toggle; }); t.Start(); Thread.Sleep (1000); c.completed = true; t.Join(); // Blocks indefinitely } }
若是您使用release模式運行上述代碼,它也會無限死循環。
此次CPU沒有罪,但罪魁禍首是JIT優化。
你若是把:
bool completed;
改爲
volatile bool completed;
就不會死循環了。
讓咱們來看一下[沒有加volatile]和[加了volatile]這2種狀況的IL代碼:
L0000: xor eax, eax L0002: mov rdx, [rcx+8] L0006: movzx edx, byte ptr [rdx+8] L000a: test edx, edx L000c: jne short L001a L000e: test eax, eax L0010: sete al L0013: movzx eax, al L0016: test edx, edx # <-- 注意看這裏 L0018: je short L000e L001a: ret
L0000: xor eax, eax L0002: mov rdx, [rcx+8] L0006: cmp byte ptr [rdx+8], 0 L000a: jne short L001e L000c: mov rdx, [rcx+8] L0010: test eax, eax L0012: sete al L0015: movzx eax, al L0018: cmp byte ptr [rdx+8], 0 <-- 注意看這裏 L001c: je short L0010 L001e: ret
留意我打了註釋的那行。上面的這些IL代碼行 其實是代碼進行檢查的地方:
while (!c.completed)
當不使用volatile時,JIT將完成的值緩存到寄存器(edx),而後僅使用edx寄存器的值來判斷(while (!c.completed))。
可是,當咱們使用volatile時,將強制JIT不進行緩存,
而是每次咱們須要讀取它直接訪問內存的值 (cmp byte ptr [rdx+8], 0)
JIT緩存到寄存器 是由於 發現了 內存訪問的速度慢了100倍以上,就像CPU同樣,JIT出於良好的意圖,緩存了變量。
所以它沒法檢測到別的線程中的修改。
volatile解決了這裏的問題,迫使JIT不進行緩存。
說完可見性了咱們在來講下volatile的另一個特性:內存屏障
確保在執行下一個上傳/下載指令以前,已完成從volatile變量的下載指令。
確保在執行對volatile變量的當前上傳指令以前,完成了上一個上傳/下載指令。
可是volatile並不由止在完成上一條上傳指令以前完成對volatile變量的下載指令。
CPU能夠並行執行並能夠繼續執行任何先執行的操做。
正是因爲volatile關鍵字沒法阻止,因此這就是這裏發生的狀況:
mov dword ptr [rax+0xc], 1 # 把值 1 上傳到內存地址 'y' mov edx, [rax+8]. # 從內存地址 'x' 下載值並放到edx(寄存器)
變成這個
mov edx, [rax+8]. # 從內存地址 'x' 下載值並放到edx(寄存器) mov dword ptr [rax+0xc], 1 # 把值 1 上傳到內存地址 'y'
所以,因爲CPU認爲這些指令是獨立的,所以在y更新以前先讀取x,同理在Test1方法也是會發生x更新以前先讀取y。
因此纔會出現本文例子的坑~~!
輸入內存屏障 內存屏障是對CPU的一種特殊鎖定指令,它禁止指令在該屏障上從新排序。所以,該程序將按預期方式運行,但缺點是會慢幾十納秒。
在咱們的示例中,註釋了一行代碼:
//Interlocked.MemoryBarrierProcessWide();
若是取消註釋該行,程序將正常運行~~~~~
日常咱們說volatile通常很容易去理解它的內存可見性,很難理解內存屏障這個概念,內存屏障的概念中對於volatile變量的賦值, volatile並不由止在完成上一條上傳指令以前完成對volatile變量的下載指令。這個在多線程環境下必定得注意!