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

上一篇文章中,咱們簡單討論了線程池的做用,以及CLR線程池的一些特性。不過關於線程池的基本概念尚未結束,此次咱們再來補充一些必要的信息,有助於咱們在程序中選擇合適的使用方式。html

獨立線程池

上次咱們討論到,在一個.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均由一個隊列和一個獨立的線程池組成,調節器會根據隊列中任務的數量來調節線程池內的線程數量,最終使應用程序得到優異的併發能力。app

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

相關文章
相關標籤/搜索