C# Volatile

dotNet源計劃發表於2021-11-14

​1.Overview

經常研究.NET原始碼庫的小夥伴會經常看到一個關鍵字volatile,那它在開發當中的作用是什麼呢?

我們一起來看看官方文件裡是怎麼描述的,如下:

volatile 關鍵字指示一個欄位可以由多個同時執行的執行緒修改。出於效能原因,編譯器,執行時系統甚至硬體都可能重新排列對儲存器位置的讀取和寫入。宣告為 volatile 的欄位將從某些型別的優化中排除。不確保從所有執行執行緒整體來看時所有易失性寫入操作均按執行順序排序。”

本文將圍繞這部分進行解讀。

宣告語法如下:

class VolatileTest
{
   public volatile int sharedStorage;
   
   public void Test(int i)
  {
       sharedStorage = i;
  }
}

2.Detail

我們先了解一下前置知識點。

(1)在CLR中將對sbytebyteshortushortintuintcharfloatbool。以及引用型別保證讀寫時原子性的(long、double不是原子性讀寫)變數中的所有位元組都是一次性寫入或讀取的。

(2)Framework Class Library(FCL) 保證所有靜態方法都是執行緒安全的。這意味著假如兩個執行緒同時呼叫一個靜態方法,不會有資料被損壞。為什麼?

public static string Print(String str)
{
   string val = "";
   val += str;
   return val;
}

因為靜態方法內宣告的變數,每個執行緒呼叫時都會新建立一份,而不會共用一個儲存單元。比如這裡的val每個執行緒都會建立自己的一份,因此不會有執行緒安全問題。注意:靜態變數,由於是在類載入時佔用一個儲存區每個執行緒都是共用這個儲存區的,所以如果在靜態方法裡使用了靜態變數;這就會有執行緒安全問題。

(3)記憶體、CPU快取(注:下列為簡述內容,實際上不僅如此)

CPU快取,CPU整合的快取。

記憶體,記憶體條硬體提供的儲存空間。


我們繼續回到主要內容上,用下面的若干程式碼示例來表達volatile的作用。

public class Program
{
       public static int bookNum = 0;

       public static void Main(string[] args)
      {
           Console.WriteLine("juster書的數量:" + bookNum);

           Thread juster = new Thread(() =>
          {
               Console.WriteLine("juster沒帶書,等待家長送書到學校...");

               while (bookNum == 0) {}

               Console.WriteLine("juster拿到書,開始上課聽講。");
          });
           juster.Name = nameof(juster);
           juster.Start();

           Thread parent = new Thread(() =>
          {
               Console.WriteLine("parent在屋裡找書中...");

               Thread.Sleep(2000);

               Console.WriteLine("parent找到了書之後,送往學校...");

               SendBook();
          });
           parent.Name = nameof(parent);
           parent.Start();
      }

       public static void SendBook()
      {
           bookNum = 1;
      }
}

程式碼執行輸出如下:

這時候詭異的來了,按照正常的程式碼執行邏輯不難看出當parent執行緒執行Sendbook()的時候juster應該就能拿到書上課了。但是這裡juster卻一直沒有拿到是為什麼呢?

心細的小夥伴應該觀察到了這裡的執行模式是Release,眾所周知Release是.Net的釋出版本執行效率會比Debug版本要高。

為什麼Release版本效率高呢?怎麼得來的?下面這段程式碼來解釋:

上面這張反編譯的圖不難看出,10*10-100這段程式碼直接編譯成0了。這種現象是因為Release編譯的時候編譯器會對程式碼進行‘優化’。這段是最直觀能看到的‘優化’效果,其實C#編譯器將你的程式碼轉換成中間語言(IL)。然後,JIT將IL轉換成本機CPU指令。此外,C#編譯器、JIT編譯器,甚至CPU本身都可能優化你的程式碼。

但是實際上在上述程式碼中count的值始終為0;所以迴圈永遠不會執行,沒有必要編譯迴圈內的程式碼在編譯後會被‘優化’。說了這麼多,只是為了給大夥證明Release編譯這一層會存在‘優化’;接下來繼續回到volatile上。

說到這裡,如何解決各種‘優化’帶來的問題呢?這時候只需要在booknum前面加上volatile關鍵字修飾即可。

public class Program
{
       public static volatile int bookNum = 0;

       public static void Main(string[] args)
      {
           Console.WriteLine("juster書的數量:" + bookNum);

           Thread juster = new Thread(() =>
          {
               Console.WriteLine("juster沒帶書,等待家長送書到學校...");

               while (bookNum == 0) { }

               Console.WriteLine("juster拿到書,開始上課聽講。");
          });
           juster.Name = nameof(juster);
           juster.Start();


           Thread parent = new Thread(() =>
          {
               Console.WriteLine("parent在屋裡找書中...");

               Thread.Sleep(2000);

               Console.WriteLine("parent找到了書之後,送往學校...");

               SendBook();
          });
           parent.Name = nameof(parent);
           parent.Start();
      }

       public static void SendBook()
      {
           bookNum = 1;
      }
}

在被各種優化之後,booknum因為是值型別在每個執行緒訪問時會發生複製且又是在靜態方法中被修改。所以每個執行緒都會複製booknum的值到當前執行緒上下文中快取起來。這樣就導致了parent執行緒修改了booknum的值juster執行緒看不到的情況。這個時候就需要用volatile關鍵字告訴編譯器不需要這樣的優化,表示用volatile定義的變數會被改變,每次都必須從記憶體中讀取,而不能把他放在CPU cache或暫存器中重複使用。最後booknum會在執行的過程中修改值且其他執行緒能‘共享訪問’達到最終的效果。

3.Conclusion

Part1

volatile 關鍵字可應用於以下型別的欄位:

  • 引用型別。

  • 指標型別(在不安全的上下文中)。請注意,雖然指標本身可以是可變的,但是它指向的物件不能是可變的。換句話說,不能宣告“指向可變物件的指標”。

  • 簡單型別,如 sbytebyteshortushortintuintcharfloatbool

  • 具有以下基本型別之一的 enum 型別:bytesbyteshortushortintuint

  • 已知為引用型別的泛型型別引數。

  • IntPtrUIntPtr

其他型別(包括 doublelong)無法標記為 volatile,因為對這些型別的欄位的讀取和寫入不能保證是原子的。若要保護對這些型別欄位的多執行緒訪問,請使用 Interlocked 類成員或使用 lock 語句保護訪問許可權。

volatile 關鍵字只能應用於 classstruct 的欄位。不能將區域性變數宣告為 volatile

Part2

volatile並不能用來做執行緒同步,它的主要作用時為了讓多個執行緒之間能看到被修改過後最新的值。

Part3

C#不支援以傳遞引用的方式將volatile欄位傳給方法。

int.TryParse("123", out x);

Part4

除了禁止編譯優化,還有同步到記憶體中因為CPU每個核心都有自己Cache所以需要同步到記憶體中方便其他核心使用。

Part5

看完本文也能解開小白時期的疑惑,為什麼我寫程式碼編譯成release版本之後就不能執行報錯的奇特現象了。

Part6

volatile 牽扯到的相關知識點和原理遠遠不止這些。

4.Reference

https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/volatile?WT.mc_id=WDIT-MVP-5004326

相關文章