volatile的記憶體屏障的坑

魚東東發表於2021-02-15

請看下面的程式碼並嘗試猜測輸出:

可能一看下面的程式碼你可能會放棄繼續看了,但如果你想要徹底弄明白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的許多輸出,如下圖。

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之前完成,那麼最終值將是

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

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

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

4. Test1交織Test2

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

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

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變數的下載指令。這個在多執行緒環境下一定得注意!

相關文章