老實說,沒有哪個開發人員願意在其編碼時還要考慮線程同步。更糟糕的情況是,編寫線程同步代碼 一點也不好玩。稍一不慎,就會導致共享資源狀態不一致,從而引發程序未預期行為。此外,當我們添加 線程同步代碼時還會導致程序運行變慢,損害性能和可伸縮性。從這點上來看,線程同步簡直一無是處。 可惜,這也是現實生活中必要的一部分。尤其在多核CPU成為主流的今天。
考慮下這種情況:只有一個線程試圖訪問某個資源。在此種狀況下,那些線程同步代碼純粹是額外開 銷。那麼如何保護性能呢?
首先,不要讓線程從用戶模式轉到內核模式。在Windows上,這是個非常昂貴的操作。比如大家熟知的 Critical Section(在.NET上我們叫它Monitor)在等待持有鎖時,便在用戶模式先旋轉等待一段時間,實 在不行才轉入內核模式。
其次,當線程進入等待狀態時,它便停止運行。很顯然,停止運行的線程還有啥性能可言。所幸的是 ,假如線程進入內核模式,它也就只能進入等待狀態。所以,如果你遵守第一條規則(避免進入內核),那 麼第二條規則(不要讓線程進入等待狀態)將會自動遵守。我們應當讓線程大多數時間在運行而不是在等待 。
最後,盡可能用盡量少的線程來完成任務。最理想的情況是,從不讓存在的線程數多於你的計算機上 的CPU個數。因此,當你的機器只有四個CPU的時候,最好也不要超過四個線程的數量。為什麼?因為當超 過四個線程的時候,操作系統就會以每隔幾毫秒~幾十毫秒的頻率調度線程到CPU。在Windows上,這個數 字大概是20毫秒。線程上下文切換花費不少時間,而且這純粹是額外開銷。在Windows上,線程上下文切 換需要幾千個時鐘周期。如果進行切換的線程屬於另一個進程,開銷還會更大,因為操作系統還要切換虛 擬地址空間。如果可運行的線程數量等於或小於CPU數量的話,操作系統只運行這些線程,而不會發生上 下文切換。在這種情況下,當然性能最佳。
但是,這僅在理想的世界中才可行。所以,我們通常可以看到機器上存在的線程數量遠多於CPU數量。 事實上,在我的單核CPU機器上,目前就有378個線程活動,這跟理想情況差了豈止十萬八千裡。Windows 上每個進程都有自己的線程,這主要是為了進程彼此隔離。假如一個進程進入死循環,其他進程還可以繼 續執行。現在我的機器上運行著29個進程,也就是說,至少有29個線程在運行了。那剩下的349個線程呢 ?很多程序通常還會用多線程來進行隔離。比如,CLR就專門有一個GC線程來負責回收資源,而不管其他 線程在干什麼。
當然,還有其他眾多原因。其中一個比較重要的原因就是相當多的(或者我大膽的說絕大多數)開發人 員並不知道如何有效的使用線程。多年來,我們從各種知識資源(甚至不少講解多線程的教材)上學習到的 編程方式,實際上對構建高性能,高可伸縮性程序有害。比如在《Windows System Programming, Third Edition》中,作者多處提到他偏愛使用線程而不是系統提供的異步方式。很明顯的一個例子就是作者采 用線程編寫的同步I/O API要快於系統異步I/O API。雖然異步和線程有著很親密的關系,但我們得到的暗 示是鼓勵使用線程而不是異步。考慮下當有大量線程爭用I/O時的情景:我們的CPU忙著線程上下文切換, 磁盤的磁頭數量有限,不夠這些線程來回搗騰,而每一個I/O操作卻又耗時良多。再比如MSDN上Microsoft 提供的Windows API示例。它們大多數均使用順序思維編寫。大多數情況下這無可厚非,而且符合人類直 覺。然而這卻讓我們學習模仿,甚至變成了習慣。在並行程序設計上相當有害。
如果你發現思考下列問題,那麼很可能你錯誤的架構了你的程序。而且需要重新架構它。
Windows支持的最大線程數是多少?
在一個單CPU上,我能最大創建多少線程?
在線程池中,我能最大擁有多少線程?
我能增加線程池中最大線程數嗎?
我應該在線程和用戶間進行1對1通信嗎?我知道很少有服務端程序每個用戶或客戶端用掉一個線程。
接下來,我們進入正題,講解SpinWait。那麼SpinWait是什麼呢?
我們知道一般的鎖(比如Mutex)在當前線程無法持有時,會使得線程在內核等待。Critical Section/Monitor則有所不同。它大概需要以下幾個步驟(以下簡稱其鎖為CS):
執行EnterCriticalSection()或者Monitor.Enter()/lock的線程會首先測試CS的鎖定位。如果該位為 OFF(解鎖),那麼線程就會在該位上設置一下(加鎖),且不需要等待便繼續。這通常只需執行1~2個機器指 令。
如果CS被鎖定,線程就會進入一個旋轉等待持有鎖。而線程在旋轉期間會反復測試鎖定位。單處理器 系統會立即放棄,而在SMP或多核CPU系統上則旋轉一段時間才會放棄。在此之前,線程都在用戶模式下運 行。
一旦線程放棄測試鎖定位(在單處理器上立即如此),線程使用信號量在內核進入等待狀態。
執行LeaveCriticalSection()/Monitor.Exit()或代碼退出了lock塊。如果存在等待線程,則使用 ReleaseSemaphore()通知內核。
在第二步,我們看到會有一個旋轉等待。這就是本文的主旨:SpinWait。CS正是使用它在用戶模式下 旋轉等待的。值得注意的是SpinWait只有在SMP或多核CPU下才具有使用意義。在單處理器下,旋轉非常浪 費CPU時間,沒有任何意義。
下面讓我們實現一個SpinWait。
using System;
using System.Threading;
namespace Lucifer.DataStructure
{
public struct SpinWait
{
internal const int yieldFrequency = 4000;
private volatile int count;
public void Spin()
{
this.count = ++count % Int32.MaxValue;
if (IsSingleProcessor)
{
SwitchToThread();
}
else
{
int numeric = this.count % yieldFrequency;
if (numeric > 0)
{
Thread.SpinWait((int)(1f + (numeric * 0.032f)));
}
else
{
SwitchToThread();
}
}
}
public void Reset()
{
this.count = 0;
}
public int Count
{
get
{
return this.count;
}
}
/**//*
* 判斷是否單處理器系統。
*/
private static readonly bool IsSingleProcessor =
(Environment.ProcessorCount == 1);
[DllImport("Kernel32", ExactSpelling = true)]
private static extern void SwitchToThread();
}
}
注意,我們封裝了非托管API-SwitchToThread()來處理單處理器情況,以便線程調度可以平滑進行。 假如使用Sleep(0)則需要注意在某些地方優先級反向,且性能也不如SwitchToThread高。
此外,我們使用Thread.SpinWait()方法來處理超線程問題。超線程是一項在Xeon,P4及以後CPU上的 技術。超線程CPU上有兩個邏輯CPU。每個邏輯CPU都有自己的架構狀態(CPU寄存器),但是各個邏輯CPU共 享一個單獨的執行資源集合(例如CPU緩存)。當一個邏輯CPU暫停時(由於緩存失效,分支預測失敗或者等 待上一指令的結果),芯片就會切換到另一個邏輯CPU上,並讓它運行。據Intel報告說超線程CPU可以獲得 10%~30%的性能提升。x86架構提供PAUSE匯編指令來完成上述情況,同時它等效於REP NOP;匯編指令。這 個指令可以兼容於那些早期未支持超線程技術的IA-32 CPU。而在Windows上,Microsoft定義了 YieldProcessor()宏來完成這些工作。.NET則對其進行了封裝。Thread.SpinWait(int iterations)在x86 平台上實際上相當於一下代碼:
void Thread.SpinWait(Int32 iterations)
{
for (Int32 i = 0; i < iterations; i++)
{
YieldProcessor();
}
}
由此,我們也應該知道SpinWait(0)沒有意義,調用它必須使用大於0之數。
我們為什麼要使用SpinWait呢?因為它運行在用戶模式下。某些情況下,我們想要在退回到真正等待 狀態前先旋轉一段時間。看起來這似乎很難理解。這根本在做無用功嘛。大多數人在一開始就避免旋轉等 待。我們知道線程上下文切換需要花費幾千個周期(在Windows上確實如此)。我們暫且稱其為C。假如線程 所等待的時間小於2C(1C用於等待自身,1C用於喚醒),則旋轉等待可以降低等待所造成的系統開銷和滯後 時間,從而提升算法的整體吞吐量和可伸縮性。
當我們采用旋轉等待時,必須謹慎行事。比如:要確保在旋轉循環內調用Thread.SpinWait(),以提高 Intel 超線程技術的計算機上硬件對其他硬件線程的可用性;通過輕微的回退 (back-off) 來引入隨機選 擇,從而改善訪問的局部性(假定調用方持續重讀共享狀態)並可能避免活鎖;當然,在單 CPU 的計算 機最好不要采用這種方法(因為在這種環境下旋轉非常浪費資源)。
不可否認,選擇頻率和旋轉計數是不確定的。與 Win32 臨界區旋轉計數類似,我們應該根據測試和實 驗的結果來選擇合理的數值,而且即使合理的數值在不同系統中也會發生變化。例如,根據 Microsoft Media Center 和 Windows kernel 團隊的經驗,MSDN 文檔建議臨界區旋轉計數為 4,000 ,但您的選擇 可以有所不同。理想的計數取決於多種因素,包括在給定時間等待事件的線程數和事件出現的頻率等。一 個經過實踐的數字大概在4000~12000之間。
接下來,我們通過《並發數據結構:Stack》和本文學習到的知識構造ConcurrentStack,來學習 SpinWait如何使用,同時也以它作為本文結束。
代碼如下:
public class ConcurrentStack<T>
{
private volatile ListNode<T> topOfStack;
public bool IsEmpty()
{
return topOfStack == null;
}
public void Push(T item)
{
ListNode<T> newTopOfStack = new ListNode<T> (item);
SpinWait wait = new SpinWait();
while (true)
{
ListNode<T> oldTopOfStack = topOfStack;
newTopOfStack.next = oldTopOfStack;
if (Interlocked.CompareExchange<ListNode<T>> (ref topOfStack, newTopOfStack, oldTopOfStack) == oldTopOfStack)
{
return;
}
wait.Spin();
}
}
/**//// <summary>
/// 考慮到在多線程環境中,這裡不拋出異常。我們需要人為判斷其是否為空。
/// </summary>
/// <returns></returns>
public bool TryPop(out T result)
{
ListNode<T> oldTopOfStack;
ListNode<T> newTopOfStack;
SpinWait wait = new SpinWait();
Spin:
oldTopOfStack = topOfStack;
if (oldTopOfStack == null)
{
result = default(T);
return false;
}
newTopOfStack = topOfStack.next;
if (Interlocked.CompareExchange<ListNode<T>>(ref topOfStack, newTopOfStack, oldTopOfStack) != oldTopOfStack)
{
wait.Spin();
goto Spin;
}
result = oldTopOfStack.item;
return true;
}
public bool TryPeek(out T result)
{
ListNode<T> head = topOfStack;
if (head == null)
{
result = default(T);
return false;
}
result = head.item;
return true;
}
/**//* *****************************************
* 簡單的單向鏈表實現
* *****************************************/
class ListNode<T>
{
internal T item;
internal ListNode<T> next;
public ListNode(T initItem, ListNode<T> initNext)
{
this.item = initItem;
this.next = initNext;
}
public ListNode(T initItem)
: this(initItem, null)
{
}
}
}
最後,重申一下,SpinWait僅適合在線程等待時間較短,且在SMP或多核環境下。單處理器系統使用沒 有意義。