C# 基礎回顧: volatile 關鍵字


有些人可能從來沒看到過這個關鍵字,這也難怪,因為這個關鍵字並不常用。那這個關鍵字到底有什么用呢?

我在網上搜索這個關鍵字的時候,發現很多朋友都有一個錯誤的認識 ------ 認為這個關鍵字可以防止並發爭用(有點類似 lock 的趕腳)。

 

volatile 作用重定義

volatile 中文解釋是“可變的”,MSDN 上關於此關鍵字的解釋如下:“volatile 關鍵字指示一個字段可以由多個同時執行的線程修改。 聲明為 volatile 的字段不受編譯器優化(假定由單個線程訪問)的限制。 這樣可以確保該字段在任何時間呈現的都是最新的值。”

不知道你看了上述描述是不是恍然大悟,反正我是沒看懂。在網上查閱了眾多資料后,才算有所明白,把上面的話用新的方式重新解讀后,就有了如下的結論。

 

1、阻止編譯器優化:JIT 編譯器會自動對代碼進行優化,從而導致最終代碼的指令順序發生變化。使用 volatile 關鍵字就可以避免 JIT 編譯器對此進行優化,如:

public bool _goOn = true;
 
//未優化
public void Execute()
{
    while(_goOn)
    {
        //do something
    }
}
 
//優化后
public void ExecuteOptimized
{
    if(_goOn)
    {
        while(true)
        {
            //do something
        }
    }
}

 上面的方法只是拿來舉個例子,實際優化后的情況並不完全一樣。

因為 JIT 認為在單個線程內,_goOn 這個變量的值並沒有在循環中修改,所以不需要每次重新去讀取,因此就會把這個值提取出來。但是如果在循環的時候,有另一個線程修改了 _goOn 的值,那邏輯就會出現錯誤。

C++ 中的 volatile 關鍵字無法保證指令的順序執行

 

2、阻止處理器優化:對於多線程尤其是多核心的 CPU 來說,當兩個線程操作同一個變量,其中一個在不斷的讀取這個變量,另一個在不斷修改這個變量,CPU 會為了減少對內存的大量訪問,而將這個變量緩存在多個核的 Cache 中,這樣每次執行指令都可以從 Cache 中迅速返回(訪問高速緩存的速度要遠高於訪問內存的速度)。這樣雖然性能提高了,但了伴隨着一個問題就是,其中一個線程無法立刻收到另一個線程對該變量的更新。使用 volatile 關鍵字可以確保每次對變量的讀取和更新都是直接操作內存,也就是說每個線程所獲取到的值都是相同的,不會有沖突。

 

volatile 運行效果

在 Stackoverflow 上,有個朋友給出了一個可以運行的 volatile 實例,通過這個實例就能更直觀的知道 volatile 的作用。

static void Main()
{
    var test = new Test();
 
    new Thread(delegate() { Thread.Sleep(500); test.foo = 255; }).Start();
 
    while (true)
    {
        if (test.foo == 255)
        {
            break;
        }
    };
    Console.WriteLine("OK");
}

 

根據那位朋友給出的運行方案,我在 release 模式下,使用 Ctrl + F5 直接運行得到的輸出是:

等待許多,仍然沒有任何輸出

 

修改 foo 的修飾符,加上 volatile,然后再運行:

 

本機的運行環境為:Win7 x64、Visual Studio 2012。

 

之所以在不用 volatile 關鍵字修飾的時候會導致死循環,就是因為指令被優化了。不同的 CPU 架構采用的方式會有所不同,在我的機器上(x64)上,通過查看運行時的匯編指令時可以發現在沒有使用 volatile 的情況下,在判斷 test.foo == 255 這句話的時候,一直是在讀取 EAX 寄存器中的值。而當使用了 volatile 關鍵字后,每次都是重新從內在中讀取。

// 沒有使用 volatile 的情況
0000004f  mov         eax,dword ptr [esi+4]    // 讀取內存中的值,並保存在寄存器 EAX 中(esi 指向內存中的地址)
00000052  mov         eax,dword ptr [eax+4] 
00000055  cmp         eax,0FFh                 // 直接比較寄存器 EAX 的值是否為 255
0000005a  jne         00000055                 // 如果判斷不成立,則繼續執行上一行代碼
 
 
// 使用了 volatile 的情況
0000004f  mov         eax,dword ptr [esi+4]     // 讀取內存中的值,並保存在寄存器 EAX 中 
00000052  cmp         dword ptr [eax+4],0FFh    // 比較寄存器 EAX 的值是否為 255
00000059  jne         0000004F                  // 如果判斷不成立,則繼續執行地址為 4f 的代碼

 當沒有 volatile 修飾時,執行循環的線程只讀取了一次 foo 值,然后一直使用該值,造成了死循環。而使用 volatile 后,每次都會去查看最新的 foo 值,因此才能正常執行。

寄存器知識拾遺:多核 CPU 中,每個核心都有全套寄存器。一個線程只可能在一個核心上運行,不可能開始的時候在核心 A 上,結束時卻在核心 B 上,這意味着一個線程在其生命周期內只可能操作一套寄存器。而當同一個核心上的不同線程切換時,當前CPU的寄存器值會被保存到線程內核對象的一個上下文結構中,然后下次該線程被再次調度時,會用內核對象中保存的值恢復寄存器。

volatile 不能替代 lock

從上述提到的兩點,應該不難看出 volatile 關鍵字的作用中並沒有哪一點是用於避免多線程對同一個變量的爭用的,也就是說它不具有同步的作用。

先來看一個示例:

static int i = 0;
 
static void Main(string[] args)
{
    Task t = Task.Factory.StartNew(() =>
    {
        i = 10; 
        //Thread.Sleep(500);
        Console.WriteLine("10 i={0}", i);
    });
    Task t2 = Task.Factory.StartNew(() =>
    {
        i = 100;
        //Thread.Sleep(1);
        Console.WriteLine("100 i={0}", i);
    });
         
    Console.ReadLine();
}


10 i=100上述程序運行后,除了主線程,還會創建兩個新線程,且都會修改同一個變量。由於無法控制每個線程執行的時機,上述代碼運行的結果有可能如下(把注釋掉的代碼反注釋回來,效果更明顯):

100 i=100

 

這就需要同步機制。修改上述代碼,加上 lock 看下效果:

static object lckObj = new object();
static int i = 0;
 
static void Main(string[] args)
{
 
    Task t = Task.Factory.StartNew(() =>
    {
        lock (lckObj)
        {
            i = 10;
            //Thread.Sleep(500);
            Console.WriteLine("10 Thread.Id:{0} i={1}", Thread.CurrentThread.ManagedThreadId, i);
        }
    });
    Task t2 = Task.Factory.StartNew(() =>
    {
        lock (lckObj)
        {
            i = 100;
            //Thread.Sleep(1);
            Console.WriteLine("100 Thread.Id:{0} i={1}", Thread.CurrentThread.ManagedThreadId, i);
        }
    });
         
    Console.ReadLine();
}


10 i=10現在,無論運行上述代碼多少次,得的答案都是一樣的:

100 i=100

 

現在,再使用 volatile 看看,是否有同步的效果:

static volatile int i = 0;
 
static void Main(string[] args)
{
    Task t = Task.Factory.StartNew(() =>
    {
        i = 10; 
        //Thread.Sleep(500);
        Console.WriteLine("10 i={0}", i);
    });
    Task t2 = Task.Factory.StartNew(() =>
    {
        i = 100;
        //Thread.Sleep(1);
        Console.WriteLine("100 i={0}", i);
    });
         
    Console.ReadLine();
}


 運行后,你便會發現,屏幕上顯示的輸出和沒有使用 lock 是完全一樣的。

什么時候使用 volatile?

x86 和 x64 架構的 CPU 本身已經對指令的順序進行了嚴格的約束,除了各別情況,大多數情況下使用和不使用 volatile 的效果是一樣的。

As it happens, Intel’s X86 and X64 processors always apply acquire-fences to reads and release-fences to writes — whether or not you use the volatile keyword — so this keyword has no effect on the hardware if you’re using these processors. However, volatile does have an effect on optimizations performed by the compiler and the CLR — as well as on 64-bit AMD and (to a greater extent) Itanium processors. This means that you cannot be more relaxed by virtue of your clients running a particular type of CPU.

Nonblocking Synchronization

上面的文字大致意思是指 X86 和 X64 的處理器總是會加入內存屏障來防止亂序,所以加不加 volatile 效果一樣。但是在諸如 64位的 AMD CPU 或者 Itanium CPU 則需要手動去預防可能的亂序。

 

lock 關鍵字會隱式提供內存屏障,且更嚴格(完全禁止亂序和緩存,而 volatile 只是禁止一部分的亂序,這樣編譯器仍然可以在一定程度上進行代碼優化),在性能上要差於 volatile。因此,除非你非常在意性能,同時對內存模型或CPU平台非常了解,否則建議直接使用 lock 關鍵字,lock 關鍵字不止屏蔽了亂序和緩存可能引起的異常,同時也可以避免多個線程的爭用。

The following implicitly generate full fences: C#'s lock statement (Monitor.Enter/Monitor.Exit)、All methods on the Interlocked class (we’ll cover these soon) ...

Nonblocking Synchronization

volatile is used to create a memory barrier* between reads and writes on the variable. lock, when used, causes memory barriers to be created around the block inside the lock, in addition to limiting access to the block to one thread.

  --- Stackoverflow

 

修改 <volatile 運行效果> 這一節中的示例,使用 lock 關鍵字,如:

int foo;
    static object lckObj = new object();
 
    static void Main()
    {
        var test = new Program();
 
        new Thread(delegate()
        {
            Thread.Sleep(500);
            lock (lckObj)
                test.foo = 255;
        }).Start();
 
        while (true)
        {
            lock (lckObj)
                if (test.foo == 255)
                {
                    break;
                }
        }
        Console.WriteLine("OK");
    }

 上述代碼運行效果與使用了 volatile 關鍵字的效果一樣。

參考資源

Don't get C# volatile the wrong way

Volatile fields in .NET: A look inside

Volatile vs. Interlocked vs. lock

Nonblocking Synchronization

C/C++ Volatile關鍵詞深度剖析

轉載至 http://blog.chenxu.me/post/detail?id=1d39c8ae-4ed7-4498-8408-9ef3a71ed954


免責聲明!

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



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