1. 程式人生 > >C#淺談執行緒池(中):獨立執行緒池的作用及IO執行緒池

C#淺談執行緒池(中):獨立執行緒池的作用及IO執行緒池

上一篇文章中,我們簡單討論了執行緒池的作用,以及CLR執行緒池的一些特性。不過關於執行緒池的基本概念還沒有結束,這次我們再來補充一些必要的資訊,有助於我們在程式中選擇合適的使用方式。

獨立執行緒池

上次我們討論到,在一個.NET應用程式中會有一個CLR執行緒池,可以使用ThreadPool類中的靜態方法來使用這個執行緒池。我們只要使用QueueUserWorkItem方法向執行緒池中新增任務,執行緒池就會負責在合適的時候執行它們。我們還討論了CLR執行緒池的一些高階特性,例如對執行緒的最大和最小數量作限制,對執行緒建立時間作限制以避免突發的大量任務消耗太多資源等等。

那麼.NET提供的執行緒池又有什麼缺點呢?有些朋友說,一個重要的缺點就是功能太簡單,例如只有一個佇列,沒法做到對多個佇列作輪詢,無法取消任務,無法設定任務優先順序,無法限制任務執行速度等等。不過其實這些簡單的功能,倒都可以通過在CLR執行緒池上增加一層(或者說,通過封裝CLR執行緒池)來實現。例如,您可以讓放入CLR執行緒池中的任務,在執行時從幾個自定義任務佇列中挑選一個執行,這樣便達到了對多個佇列作輪詢的效果。因此,在我看來,CLR執行緒池的主要缺點並不在此。

我認為,CLR執行緒池的主要問題在於“大一統”,也就是說,整個程序內部幾乎所有的任務都會依賴這個執行緒池。如前篇文章所說的那樣,如Timer和WaitForSingleObject,還有委託的非同步呼叫,.NET框架中的許多功能都依賴這個執行緒池。這個做法是合適的,但是由於開發人員對於統一的執行緒池無法做到精確控制,因此在一些特別的需要就無法滿足了。舉個最常見例子:控制運算能力。什麼是運算能力?那麼還是從執行緒講起吧1

我們在一個程式中建立一個執行緒,安排給它一個任務,便交由作業系統來排程執行。作業系統會管理系統中所有的執行緒,並且使用一定的方式進行排程。什麼是“排程”?排程便是控制執行緒的狀態:執行,等待等等。我們都知道,從理論上來說有多少個處理單元(如2 * 2 CPU的機器便有4個處理單元),就表示作業系統可以同時做幾件事情。但是執行緒的數量會遠遠超過處理單元的數量,因此作業系統為了保證每個執行緒都被執行,就必須等一個執行緒在某個處理器上執行到某個情況的時候,“換”一個新的執行緒來執行,這便是所謂的“上下文切換(context switch)”。至於造成上下文切換的原因也有多種,可能是某個執行緒的邏輯決定的,如遇上鎖,或主動進入休眠狀態(呼叫Thread.Sleep方法),但更有可能是作業系統發現這個執行緒“超時”了。在作業系統中會定義一個“時間片(timeslice)”2

,當發現一個執行緒執行時間超過這個時間,便會把它撤下,換上另外一個。這樣看起來,多個執行緒——也就是多個任務在同時運行了。

值得一提的是,對於Windows作業系統來說,它的排程單元是執行緒,這和執行緒究竟屬於哪個程序並沒有關係。舉個例子,如果系統中只有兩個程序,程序A有5個執行緒,而程序B有10個執行緒。在排除其他因素的情況下,程序B佔有運算單元的時間便是程序A的兩倍。當然,實際情況自然不會那麼簡單。例如不同程序會有不同的優先順序,執行緒相對於自己所屬的程序還會有個優先順序;如果一個執行緒在許久沒有執行的時候,或者這個執行緒剛從“鎖”的等待中恢復,作業系統還會對這個執行緒的優先順序作臨時的提升——這一切都是牽涉到程式的執行狀態,效能等情況的因素,有機會我們在做展開。

現在您意識到執行緒數量意味著什麼了沒?沒錯,就是我們剛才提到的“運算能力”。很多時候我們可以簡單的認為,在同樣的環境下,一個任務使用的執行緒數量越多,它所獲得的運算能力就比另一個執行緒數量較少的任務要來得多。運算能力自然就涉及到任務執行的快慢。您可以設想一下,有一個生產任務,和一個消費任務,它們使用一個佇列做臨時儲存。在理想情況下,生產和消費的速度應該保持相同,這樣可以帶來最好的吞吐量。如果生產任務執行較快,則佇列中便會產生堆積,反之消費任務就會不斷等待,吞吐量也會下降。因此,在實現的時候,我們往往會為生產任務和消費任務分別指派獨立的執行緒池,並且通過增加或減少執行緒池內執行緒數量來條件運算能力,使生產和消費的步調達到平衡。

使用獨立的執行緒池來控制運算能力的做法很常見,一個典型的案例便是SEDA架構:整個架構由多個Stage連線而成,每個Stage均由一個佇列和一個獨立的執行緒池組成,調節器會根據佇列中任務的數量來調節執行緒池內的執行緒數量,最終使應用程式獲得優異的併發能力。

在Windows作業系統中,Server 2003及之前版本的API也只提供了程序內部單一的執行緒池,不過在Vista及Server 2008的API中,除了改進執行緒池的效能之外,還提供了在同一程序內建立多個執行緒池的介面。很可惜,.NET直到如今的4.0版本,依舊沒有提供構建獨立執行緒池的功能。構造一個優秀的執行緒池是一件相當困難的事情,幸運的是,如果我們需要這方面的功能,可以藉助著名的SmartThreadPool,經過那麼多年的考驗,相信它已經足夠成熟了。如果需要,我們還可以對它做一定修改——畢竟在不同情況下,我們對執行緒池的要求也不完全相同。

IO執行緒池

IO執行緒池便是為非同步IO服務的執行緒池。

訪問IO最簡單的方式(如讀取一個檔案)便是阻塞的,程式碼會等待IO操作成功(或失敗)之後才繼續執行下去,一切都是順序的。但是,阻塞式IO有很多缺點,例如讓UI停止響應,造成上下文切換,CPU中的快取也可能被清除甚至記憶體被交換到磁碟中去,這些都是明顯影響效能的做法。此外,每個IO都佔用一個執行緒,容易導致系統中執行緒數量很多,最終限制了應用程式的伸縮性。因此,我們會使用“非同步IO”這種做法。

在使用非同步IO時,訪問IO的執行緒不會被阻塞,邏輯將會繼續下去。作業系統會負責把結果通過某種方法通知我們,一般說來,這種方式是“回撥函式”。非同步IO在執行過程中是不佔用應用程式的執行緒的,因此我們可以用少量的執行緒發起大量的IO,所以應用程式的響應能力也可以有所提高。此外,同時發起大量IO操作在某些時候會有額外的效能優勢,例如磁碟和網路可以同時工作而不互相沖突,磁碟還可以根據磁頭的位置來訪問就近的資料,而不是根據請求的順序進行資料讀取,這樣可以有效減少磁頭的移動距離。

Windows作業系統中有多種非同步IO方式,但是效能最高,伸縮性最好的方式莫過於傳說中的“IO完成埠(I/O Completion Port,IOCP)”了,這也是.NET中封裝的唯一非同步IO方式。大約一年半前,老趙寫過一篇文章《正確使用非同步操作》,其中除了描述計算密集型和IO密集型操作的區別和效果之外,還簡單地講述了IOCP與CLR互動的方式,摘錄如下:

當我們希望進行一個非同步的IO-Bound Operation時,CLR會(通過Windows API)發出一個IRP(I/O Request Packet)。當裝置準備妥當,就會找出一個它“最想處理”的IRP(例如一個讀取離當前磁頭最近的資料的請求)並進行處理,處理完畢後設備將會(通過Windows)交還一個表示工作完成的IRP。CLR會為每個程序建立一個IOCP(I/O Completion Port)並和Windows作業系統一起維護。IOCP中一旦被放入表示完成的IRP之後(通過內部的ThreadPool.BindHandle完成),CLR就會盡快分配一個可用的執行緒用於繼續接下去的任務。

不過事實上,使用Windows API編寫IOCP非常複雜。而在.NET中,由於需要迎合標準的APM(非同步程式設計模型),在使用方便的同時也放棄一定的控制能力。因此,在一些真正需要高吞吐量的時候(如編寫伺服器),不少開發人員還是會選擇直接使用Native Code編寫相關程式碼。不過在絕大部分的情況下,.NET中利用IOCP的非同步IO操作已經足以獲得非常優秀的效能了。使用APM方式在.NET中使用非同步IO非常簡單,如下:

static void Main(string[] args)
{WebRequest request = HttpWebRequest.Create("http://www.cnblogs.com");
    request.BeginGetResponse(HandleAsyncCallback, request);
}

static void HandleAsyncCallback(IAsyncResult ar)
{
    WebRequest request = (WebRequest)ar.AsyncState;
    WebResponse response = request.EndGetResponse(ar);
    // more operations...
}

BeginGetResponse將發起一個利用IOCP的非同步IO操作,並在結束時呼叫HandleAsyncCallback回撥函式。那麼,這個回撥函式是由哪裡的執行緒執行的呢?沒錯,就是傳說中“IO執行緒池”的執行緒。.NET在一個程序中準備了兩個執行緒池,除了上篇文章中所提到的CLR執行緒池之外,它還為非同步IO操作的回調準備了一個IO執行緒池。IO執行緒池的特性與CLR執行緒池類似,也會動態地建立和銷燬執行緒,並且也擁有最大值和最小值(可以參考上一篇文章列舉出的API)。

只可惜,IO執行緒池也僅僅是那“一整個”執行緒池,CLR執行緒池的缺點IO執行緒池也一應俱全。例如,在使用非同步IO方式讀取了一段文字之後,下一步操作往往是對其進行分析,這就進入了計算密集型操作了。但對於計算密集型操作來說,如果使用整個IO執行緒池來執行,我們無法有效的控制某項任務的運算能力。因此在有些時候,我們在回撥函式內部會把計算任務再次交還給獨立的執行緒池。這麼做從理論上看會增大執行緒排程的開銷,不過實際情況還得看具體的評測資料。如果它真的成為影響效能的關鍵因素之一,我們就可能需要使用Native Code來呼叫IOCP相關API,將回調任務直接交給獨立的執行緒池去執行了。

我們也可以使用程式碼來操作IO執行緒池,例如下面這個介面便是向IO執行緒池遞交一個任務:

public static class ThreadPool
{
    public static bool UnsafeQueueNativeOverlapped(NativeOverlapped* overlapped);
}

NativeOverlapped包含了一個IOCompletionCallback回撥函式及一個緩衝物件,可以通過Overlapped物件建立。Overlapped會包含一個被固定的空間,這裡“固定”的含義表示不會因為GC而導致地址改變,甚至不會被置換到硬碟上的Swap空間去。這麼做的目的是迎合IOCP的要求,但是很明顯它也會降低程式效能。因此,我們在實際程式設計中幾乎不會使用這個方法3

相關文章

注1:如果沒有加以說明,我們這裡談論的物件預設為XP及以上版本的Window作業系統。

注2:timeslice又被稱為quantum,不同作業系統中定義的這個值並不相同。在Windows客戶端作業系統(XP,Vista)中時間片預設為2個clock interval,在伺服器作業系統(2003,2008)中預設為12個clock interval(在主流系統上,1個clock interval大約10到15毫秒)。伺服器作業系統使用較長的時間片,是因為一般伺服器上執行的程式比客戶端要少很多,且更注重效能和吞吐量,而客戶端系統更注重響應能力——而且,如果您真需要的話,時間片的長度也是可以調整的。

注3:不過,如果程式中多次複用單個NativeOverlapped物件的話,這個方法的效能會略微好於QueueUserWorkItem,據說WCF中便使用了這種方式——微軟內部總有那麼些技巧是我們不知如何使用的,例如老趙記得之前檢視ASP.NET AJAX原始碼的時候,在MSDN中不小心發現一個介面描述大意是“預留方法,請不要在外部使用”。對此,我們又能有什麼辦法呢?