1. 程式人生 > >淺談線程池(中):獨立線程池的作用及IO線程池

淺談線程池(中):獨立線程池的作用及IO線程池

關於 線程數 客戶端 pool 網絡 程序 服務器 缺點 public

在上一篇文章中,我們簡單討論了線程池的作用,以及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

相關文章

  • 淺談線程池(上):線程池的作用及CLR線程池
  • 淺談線程池(中):獨立線程池的作用及IO線程池
  • 淺談線程池(下):相關試驗及註意事項

註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中不小心發現一個接口描述大意是“預留方法,請不要在外部使用”。對此,我們又能有什麽辦法呢?

淺談線程池(中):獨立線程池的作用及IO線程池