再說 lock-free 編程
lock-free 編程實在讓人又愛又恨。博主以前曾經寫過幾篇關于 lock-free 編程的文章。比如關于無鎖編程、并發數據結構:迷人的原子。如果想更加深入的了解和實踐 lock-free 編程,可以參考CLR 2.0 Memory Model、并發數據結構:Stack。這篇文章并不打算繼續闡述如何使用 lock-free 技術,而是談一下它的負面影響。從而讓大家對 lock-free 有個更加全面的認識。
說到 lock-free 編程,現實中經常使用 CAS 原語。CAS 是英文 Compare and Swap 的簡寫。在 Windows 和 .NET 平臺,由于歷史原因,它被寫做 Interlocked API。原子操作在 x86 架構 CPU 對應的匯編指令有 XCHG、CMPXCHG、INC 等,當然還得加上 LOCK 作為前綴(更多信息請看 并發數據結構:迷人的原子)。
CAS 原語在輕度和中度爭用情況下確實可以大幅度提高程序性能。但凡事有利必有弊,CAS 原語極度扼殺了程序的可伸縮性(其他缺點請看關于無鎖編程)。各位看官可能覺得這種觀點有點偏激,但事實如此。請容博主細細道來:
- CAS 的原子性完全取決于硬件實現。大多數 Intel 和 AMD 的 CPU 采用了一種叫做 MOSEI 緩存一致性協議來管理緩存。這種架構下,處理器緩存內 CAS 操作相對成本低廉。但一旦資源爭用,就會引起緩存失效和總線占用。緩存越失效,總線越被占用,完成 CAS 操作也越被延遲。緩存爭用是程序可伸縮性殺手。當然對于非 CAS 內存操作來說也是如此,但 CAS 情況更加槽糕。
- CAS 操作要比普通內存操作花費更多 CPU 周期。這歸功于緩存分級的額外負擔、刷新寫緩沖區與穿越內存柵欄限制和需求以及編譯器對 CAS 操作優化的能力。
- CAS 經常被用在優化并行操作上。這意味著 CAS 操作失敗將導致重新嘗試某些指令(典型的回滾操作)。即便沒有任何爭用,它也會做一些無用功。不論成功或失敗都會增加爭用的風險。
大多數 CAS 操作發生在鎖進入和退出時。盡管鎖可由單一 CAS 操作構建,但 .NET CLR Monitor 類卻使用了兩個(一個在 Enter 方法,另一個在 Exit 方法)。lock-free 算法也經常使用 CAS 原語來代替使用鎖機制。但是由于內存重組,這樣的算法也常常需要顯式的柵欄,即便使用了 CAS 指令。鎖機制非常邪惡,但大多數合格的開發人員都知道讓鎖持有盡量少的時間。因此,雖然鎖機制讓人非常討厭,且影響性能。但相對于大量,頻繁的 CAS 操作而言,它卻并不影響程序的可伸縮性。
舉個很簡單的例子,增加計數 100,000,000 次。要做到這樣,有幾種方式。如果僅運行在單核單處理器上,我們可以使用普通的內存操作:
static volatile int counter = 0; static void BaselineCounter() { for (int i = 0; i < Count; i++) { counter++; } }
很明顯,上述代碼示例不是線程安全的,但給計數器提供了一個很好的時間基準。下面我們使用 LOCK INC 來作為線程安全的第一種方式:
static volatile int counter = 0; static void LockIncCounter() { for (int i = 0; i < Count; i++) { Interlocked.Increment(ref counter); } }
現在代碼示例線程安全了。我們還可以采取另外一種方式來保證線程安全。如果需要執行一些驗證(比如內存溢出保護),我們通常會使用這種方式。就是使用 CMPXCHG(即 CAS):
static volatile int counter = 0; static void CASCounter() { for (int i = 0; i < Count; i++) { int oldValue; do { oldValue = counter; } while (Interlocked.CompareExchange(ref counter, oldValue + 1, oldValue) != oldValue); } }