volatile的內存屏障的坑

請看下面的代碼並嘗試猜想輸出:

可能一看下面的代碼你可能會放棄繼續看了,但若是你想要完全弄明白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

image

咱們先根據代碼自我分析下

在Test1中,咱們將x設置爲1,將a設置爲y,而Test2將y設置爲1,將b設置爲x
所以這4條語句會在2個線程中競爭
羅列下可能會發生的幾種狀況:優化

1. Test1先於Test2執行:

x = 1
a = y
y = 1
b = x

在這種狀況下,咱們假設Test1在Test2以前完成,那麼最終值將是spa

x = 1,a = 0,y = 1,b = 1

2. Test2執行完成後執行Test1:

y = 1 
b = x
x = 1
a = y

在這種狀況下,那麼最終值將是線程

x = 1,a = 1,y = 1,b = 0

2. Test1執行期間執行Test2:

x = 1
y = 1
b = x
a = y

在這種狀況下,那麼最終值將是設計

x = 1,a = 1,y = 1,b = 1

3. Test2執行期間執行Test1

y = 1
x = 1
a = y
b = x

在這種狀況下,那麼最終值將是code

x = 1,a = 1,y = 1,b = 1

4. Test1交織Test2

x = 1
y = 1
a = y
b = x

在這種狀況下,那麼最終值將是blog

x = 1,a = 1,y = 1,b = 1

5.Test2交織Test1

y = 1
x = 1
b = x
a = y

在這種狀況下,那麼最終值將是排序

x = 1,a = 1,y = 1,b = 1

我認爲上面已經羅列的
已經涵蓋了全部可能的狀況,
可是不管發生哪一種競爭狀況,
看起來一旦兩個任務都完成,
就不可能使a和b都同時爲零,
可是奇蹟般地,竟然一直在打印0,0 (請看上面的動圖,若是你懷疑的話代碼copy執行試試)

image

真相永遠只有一個

先揭曉答案: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服務上載或從中下載同樣慢。

如下是各項指標(2020年數據)(ns爲納秒)

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緩存中的內容慢100倍

若是讓你開發一個應用程序,實現上載或者下載功能。
您將如何設計此?確定想要開多線程,並行化執行以節省時間!
這正是CPU的功能。CPU被咱們設計的很聰明,
在實際運行中能夠肯定某些「上載」和「下載」操做(指令)不會互相影響,
而且CPU爲了節省時間,對它們(指令)進行了(優化)並行處理,
也叫【cpu亂序執行】(out-of-order)

上面我說道:在實際運行中能夠肯定某些「上載」和「下載」操做(指令)不會互相影響,
這裏有一個前提條件哈:該假設僅基於基於線程的依賴性檢查進行(per-thread basis dependency checks)。
雖然在單個線程是能夠被肯定爲指令獨立性,但CPU沒法考慮多個線程的狀況,因此提供了【volatile關鍵字】

咱們回到上面的示例,儘管咱們已將字段標記爲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代碼:

沒有加volatile

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

加了volatile

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的另一個特性:內存屏障

  1. 確保在執行下一個上傳/下載指令以前,已完成從volatile變量的下載指令。

  2. 確保在執行對​​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變量的下載指令。這個在多線程環境下必定得注意!

相關文章
相關標籤/搜索