由來
在CLR 2.0 Memory Model中,我們知道現代CPU架構從CPU到Memory Controller每一級都有速度,容量 不同的高速緩存。之所以這樣設計,主要是因為性能。為了進一步提升性能,當線程讀取內存中所期望的 元素值時,CPU並不是只讀取我們所期望的元素值,它實際上會同時讀取該值周圍的若干字節,並將其放 入高速緩存中。這是因為應用程序通常讀取的字節在內存中彼此相鄰。當應用程序又讀取該值周圍的字節 時,這些字節已經在高速緩存中了,這樣就避免了應用程序再次訪問內存,也提升了性能。
應用程序在單核CPU的機器上運行時,高速緩存不會有什麼影響。但是當應用程序跑在多CPU/多核CPU 的機器上時,我們就要考慮高速緩存所帶來的顯著影響了(請參考CLR 2.0 Memory Model)。更槽糕的是, C#或JIT編譯器編譯代碼時,會將指令重新排序。因此,應用程序的執行順序可能會跟編寫的順序不同, 而且現代CPU本身也支持亂序執行CPU指令。
這樣,我們就不得不考慮如何來處理高速緩存一致性。不同的CPU處理方式也不盡相同。比如在CLR 2.0 Memory Model中講到的x86架構的CPU就會維持高速緩存一致性,而x64架構向後兼容x86架構,所以也 有此特性。但是IA64架構的CPU則被設計用來充分利用每個CPU的高速緩存,而且為了提升性能,盡量避免 高速緩存一致性問題。
為了解決高速緩存一致性所帶來的問題,CLR在System.Threading.Thread類中提供了若干個下述形式 的靜態方法(這是最簡單,最原始的方式,所有的鎖機制都會強制高速緩存一致性):
static Object VolatileRead(ref Object address);
static Byte VolatileRead(ref Byte address);
static void VolatileWrite(ref Object address, Object value);
static void VolatileWrite(ref Byte address, Byte value);
static void MemoryBarrier();
所有的VolatileRead方法都執行一個包含獲取語義的讀取操作,這些方法讀取由參數address引用的值 ,然後使得CPU高速緩存內的相應字節失效。所有的VolatileWrite方法則執行一個包含釋放語義的寫入操 作,這些方法將CPU高速緩存內的字節刷到內存中,然後將address參數引用的值修改為value參數所表示 的值。MemoryBarrier方法則執行一個內存柵欄,將CPU高速緩存內的字節刷到內存中,然後使CPU的高速 緩存內的相應字節失效。
C#編譯器提供了volatile關鍵字,該關鍵字可以用於下述類型的靜態/實例字段:byte,sbyte,short ,ushort,int,uint,char,float和bool。此外,我們還可以將volatile關鍵字應用於引用類型以及枚 舉類型的基礎類型是byte,sbyte,short,ushot,int,uint,float和bool的枚舉字段。volatile關鍵 字告訴C#和JIT編譯器不再在CPU寄存器中緩存字段,從而確保字段的所有讀寫操作都是對內存的讀寫, JIT編譯器則確保其語義正確,這樣就不必顯式調用Thread的靜態方法VolatileXXX了。
飛升
鎖具有兩種主要特性:互斥和可見性。互斥指的是一次只允許一個線程持有某個特定的鎖,因此可以 保證共享數據內容的一致性;可見性指的是必須確保鎖被釋放之前對共享數據的修改,隨後獲得鎖的另一 個線程能夠知道該行為。
volatile變量可以看作是“輕量級lock”。當出於簡單編碼和可伸縮性考慮時,我們可能會選擇使用 volatile變量而不是鎖機制。某些情況下,如果讀操作遠多於寫操作,也會比鎖機制帶來更高性能。
volatile變量具有“lock”的可見性,卻不具備原子特性。也就是說線程能夠自動發現volatile變量 的最新值。volatile變量可以實現線程安全,但其應用有限。使用volatile變量的主要原因在於它使用非 常簡單,至少比使用鎖機制要簡單的多;其次便是性能原因了,某些情況下,它的性能要優於鎖機制。此 外,volatile操作不會造成阻塞。
所有的並發專家都在告誡,盡量不要用volatile變量來實現線程安全。為啥呢?因為使用volatile的 代碼比鎖機制更加容易出錯,看看CLR 2.0 Memory Model和前面我啰裡啰嗦的廢話就知道,要用這玩意, 得加倍小心。下面咱們就來看看如何正確使用這害人不償命的“小可愛”。
先說兩個准則,只要我們的程序能遵循它,咱就可以放心使用volatile變量來實現線程安全。
對變量的寫操作不依賴於當前值。
該變量沒有包含在具有其他變量的不變式中。
丫,是不是有些頭大,這說的跟教導主任似的。實際上,它的意思是說,老子得了失憶症,不記得任 何人了,甚至連自己現在的樣子也記不起來了。老子獨立於程序的任何狀態,包括自己當前的狀態。
我們謹記其限制,只有在其狀態完全獨立於程序其他狀態時才可使用volatile變量。
先來看下最簡單最規范的應用,使用一個布爾變量,用來表示某個重要事件的標志。例如完成初始化 或請求停止。
很多桌面客戶端應用程度都會提供一個掃描/查找文件的功能,這當然需要另開一個工作線程去查找, 不然UI會失去響應一段時間,尤其是要掃描的文件特多的時候,這種情況下,客戶會受不了的。我們還要 注意的是,工作線程應當能夠隨時停止,不然客戶點擊取消按鈕時,要等到線程真正結束時,才能完畢, 這個情況也會影響客戶體驗。咋辦呢,加個停止標志,讓工作線程每次想要掃描/查找文件的時候都要先 看看是否需要停止掃描了。如下代碼所示:
private volatile bool stopped;
public void Stop()
{
stopped = true;
}
public void FindFiles()
{
while (!stopped)
{
// searching files
}
}
另外的線程很可能調用上述代碼的Stop方法,這需要某種同步方式來保證stopped變量的可見性,並且 stopped變量也不依賴於程序內其他狀態,因此此處非常適合使用volatile。當然使用lock也可以,但卻 不如volatile來的簡便,而且volatile還有性能上的優勢。
這種場景下的變量標志有個通性:通常只有一種狀態轉換。stopped從false轉換為true,然後停止。 可以擴展讓狀態標志來回轉換,但只能在轉換周期不被察覺的情況下才能擴展。此外,還需要某些原子狀 態轉換機制。
接下來我們看一下同步對象引用。
缺乏同步會導致無法實現可見性。這使得何時寫入對象引用而不是原語值變得困難起來。在缺乏同步 的情況下,可能會遇到某個對象引用的更新值(由另一個線程寫入)和該對象狀態的舊值同時存在。
比如我們的後台線程在啟動階段從數據庫加載一些數據,當其他代碼在使用這些數據時,先檢查一下 這些數據是否已經被加載。
public class LargeDataObject
{
public volatile LargeDataType LargeData;
public void InitInBackground()
{
// do lots of work
// here initialize a LargeDataType object
LargeData = new LargeDataType();
}
}
public class OtherClass
{
public void DoWork()
{
while (true)
{
// if it is ready, process it.
if (largeDataObject.LargeData != null)
{
Process(largeDataObject.LargeData);
}
}
}
}
假如LargeData引用不是volatile類型,當DoWork處理數據時,就有可能得到一個不完全構造的 LargeData對象。這裡還有一個必要條件就是LargeData必須是線程安全的,或者該對象創建之後永遠不會 被修改。volatile類型的引用可以確保LargeData的可見性,但是如果其被修改,那麼就需要額外的同步 措施了。
到這裡,我們已經知道volatile提供的同步機制還不足以能夠實現線程安全計數器。因為計數器雖然 簡單,卻是三種操作的組合,如果多線程試圖進行增量操作,很可能會丟失其更新值。如果讀操作遠多於 寫操作,那我們可以結合鎖機制和volatile變量來提供一個開銷較低的計數器。
public class ThreadSafeCounter
{
private volatile int value;
public int Value
{
get
{
return value;
}
}
public int Increase()
{
lock (this)
{
return value++;
}
}
}
如果更新不頻繁的話,該方法可實現更好的性能,通常優於一個無競爭的鎖的開銷。這個例子的寫操 作因為違反了第一個准則,所以我們使用鎖來確保原子操作。當讀操作遠多於寫操作時,該示例可以提供 競爭性的性能優勢,因為volatile變量讀操作的開銷非常低,幾乎跟非volatile變量一樣,但我們同時也 應該牢記這個弱點,否則其帶來的可能是性能的低下。
此外,該示例還有一個缺陷,就是有死鎖的危險。問題主要出現在lock(this)語句上,Jeffrey Richter在他的《CLR via C#》第24章3.7節講述的非常清楚。
最後,我們來總結一下,volatile關鍵字提供了一個非常脆弱的同步機制,在上述情境下或者我們知 道其能帶來線程安全的情境下,可以使用volatile變量來簡化編碼,以及提升程序性能和伸縮性。重申一 下使用volatile變量的正確條件 -- volatile變量必須真正獨立於其他變量和其以前的值。還有並發專家 也同時告誡我們:盡量遠離volatile變量,除非你真正的理解其涵義和使用場景。