淺談CLR線程池的缺點及解決方法
獨立線程池
上次我們在CLR線程池的作用與原理淺析一文中討論到,在一個.NET應(yīng)用程序中會有一個CLR線程池,可以使用ThreadPool類中的靜態(tài)方法來使用這個線程池。我們只要使用QueueUserWorkItem方法向線程池中添加任務(wù),線程池就會負(fù)責(zé)在合適的時候執(zhí)行它們。我們還討論了CLR線程池的一些高級特性,例如對線程的最大和最小數(shù)量作限制,對線程創(chuàng)建時間作限制以避免突發(fā)的大量任務(wù)消耗太多資源等等。
那么.NET提供的線程池又有什么缺點呢?有些朋友說,一個重要的缺點就是功能太簡單,例如只有一個隊列,沒法做到對多個隊列作輪詢,無法取消任務(wù),無法設(shè)定任務(wù)優(yōu)先級,無法限制任務(wù)執(zhí)行速度等等。不過其實這些簡單的功能,倒都可以通過在CLR線程池上增加一層(或者說,通過封裝CLR線程池)來實現(xiàn)。例如,您可以讓放入CLR線程池中的任務(wù),在執(zhí)行時從幾個自定義任務(wù)隊列中挑選一個運行,這樣便達(dá)到了對多個隊列作輪詢的效果。因此,在我看來,CLR線程池的主要缺點并不在此。
我認(rèn)為,CLR線程池的主要問題在于“大一統(tǒng)”,也就是說,整個進(jìn)程內(nèi)部幾乎所有的任務(wù)都會依賴這個線程池。如前篇文章所說的那樣,如Timer和WaitForSingleObject,還有委托的異步調(diào)用,.NET框架中的許多功能都依賴這個線程池。這個做法是合適的,但是由于開發(fā)人員對于統(tǒng)一的線程池?zé)o法做到精確控制,因此在一些特別的需要就無法滿足了。舉個最常見例子:控制運算能力。什么是運算能力?那么還是從線程講起吧。
我們在一個程序中創(chuàng)建一個線程,安排給它一個任務(wù),便交由操作系統(tǒng)來調(diào)度執(zhí)行。操作系統(tǒng)會管理系統(tǒng)中所有的線程,并且使用一定的方式進(jìn)行調(diào)度。什么是“調(diào)度”?調(diào)度便是控制線程的狀態(tài):執(zhí)行,等待等等。我們都知道,從理論上來說有多少個處理單元(如2 * 2 CPU的機(jī)器便有4個處理單元),就表示操作系統(tǒng)可以同時做幾件事情。但是線程的數(shù)量會遠(yuǎn)遠(yuǎn)超過處理單元的數(shù)量,因此操作系統(tǒng)為了保證每個線程都被執(zhí)行,就必須等一個線程在某個處理器上執(zhí)行到某個情況的時候,“換”一個新的線程來執(zhí)行,這便是所謂的“上下文切換(context switch)”。至于造成上下文切換的原因也有多種,可能是某個線程的邏輯決定的,如遇上鎖,或主動進(jìn)入休眠狀態(tài)(調(diào)用Thread.Sleep方法),但更有可能是操作系統(tǒng)發(fā)現(xiàn)這個線程“超時”了。在操作系統(tǒng)中會定義一個“時間片(timeslice)”2,當(dāng)發(fā)現(xiàn)一個線程執(zhí)行時間超過這個時間,便會把它撤下,換上另外一個。這樣看起來,多個線程——也就是多個任務(wù)在同時運行了。值得一提的是,對于Windows操作系統(tǒng)來說,它的調(diào)度單元是線程,這和線程究竟屬于哪個進(jìn)程并沒有關(guān)系。
舉個例子,如果系統(tǒng)中只有兩個進(jìn)程,進(jìn)程A有5個線程,而進(jìn)程B有10個線程。在排除其他因素的情況下,進(jìn)程B占有運算單元的時間便是進(jìn)程A的兩倍。當(dāng)然,實際情況自然不會那么簡單。例如不同進(jìn)程會有不同的優(yōu)先級,線程相對于自己所屬的進(jìn)程還會有個優(yōu)先級;如果一個線程在許久沒有執(zhí)行的時候,或者這個線程剛從“鎖”的等待中恢復(fù),操作系統(tǒng)還會對這個線程的優(yōu)先級作臨時的提升——這一切都是牽涉到程序的運行狀態(tài),性能等情況的因素,有機(jī)會我們在做展開。
現(xiàn)在您意識到線程數(shù)量意味著什么了沒?沒錯,就是我們剛才提到的“運算能力”。很多時候我們可以簡單的認(rèn)為,在同樣的環(huán)境下,一個任務(wù)使用的線程數(shù)量越多,它所獲得的運算能力就比另一個線程數(shù)量較少的任務(wù)要來得多。運算能力自然就涉及到任務(wù)執(zhí)行的快慢。您可以設(shè)想一下,有一個生產(chǎn)任務(wù),和一個消費任務(wù),它們使用一個隊列做臨時存儲。在理想情況下,生產(chǎn)和消費的速度應(yīng)該保持相同,這樣可以帶來最好的吞吐量。如果生產(chǎn)任務(wù)執(zhí)行較快,則隊列中便會產(chǎn)生堆積,反之消費任務(wù)就會不斷等待,吞吐量也會下降。因此,在實現(xiàn)的時候,我們往往會為生產(chǎn)任務(wù)和消費任務(wù)分別指派獨立的線程池,并且通過增加或減少線程池內(nèi)線程數(shù)量來條件運算能力,使生產(chǎn)和消費的步調(diào)達(dá)到平衡。
使用獨立的線程池來控制運算能力的做法很常見,一個典型的案例便是SEDA架構(gòu):整個架構(gòu)由多個Stage連接而成,每個Stage均由一個隊列和一個獨立的線程池組成,調(diào)節(jié)器會根據(jù)隊列中任務(wù)的數(shù)量來調(diào)節(jié)線程池內(nèi)的線程數(shù)量,最終使應(yīng)用程序獲得優(yōu)異的并發(fā)能力。
在Windows操作系統(tǒng)中,Server 2003及之前版本的API也只提供了進(jìn)程內(nèi)部單一的線程池,不過在Vista及Server 2008的API中,除了改進(jìn)線程池的性能之外,還提供了在同一進(jìn)程內(nèi)創(chuàng)建多個線程池的接口。很可惜,.NET直到如今的4.0版本,依舊沒有提供構(gòu)建獨立線程池的功能。構(gòu)造一個優(yōu)秀的線程池是一件相當(dāng)困難的事情,幸運的是,如果我們需要這方面的功能,可以借助著名的SmartThreadPool,經(jīng)過那么多年的考驗,相信它已經(jīng)足夠成熟了。如果需要,我們還可以對它做一定修改——畢竟在不同情況下,我們對線程池的要求也不完全相同。
IO線程池
IO線程池便是為異步IO服務(wù)的線程池。
訪問IO最簡單的方式(如讀取一個文件)便是阻塞的,代碼會等待IO操作成功(或失敗)之后才繼續(xù)執(zhí)行下去,一切都是順序的。但是,阻塞式IO有很多缺點,例如讓UI停止響應(yīng),造成上下文切換,CPU中的緩存也可能被清除甚至內(nèi)存被交換到磁盤中去,這些都是明顯影響性能的做法。此外,每個IO都占用一個線程,容易導(dǎo)致系統(tǒng)中線程數(shù)量很多,最終限制了應(yīng)用程序的伸縮性。因此,我們會使用“異步IO”這種做法。
在使用異步IO時,訪問IO的線程不會被阻塞,邏輯將會繼續(xù)下去。操作系統(tǒng)會負(fù)責(zé)把結(jié)果通過某種方法通知我們,一般說來,這種方式是“回調(diào)函數(shù)”。異步IO在執(zhí)行過程中是不占用應(yīng)用程序的線程的,因此我們可以用少量的線程發(fā)起大量的IO,所以應(yīng)用程序的響應(yīng)能力也可以有所提高。此外,同時發(fā)起大量IO操作在某些時候會有額外的性能優(yōu)勢,例如磁盤和網(wǎng)絡(luò)可以同時工作而不互相沖突,磁盤還可以根據(jù)磁頭的位置來訪問就近的數(shù)據(jù),而不是根據(jù)請求的順序進(jìn)行數(shù)據(jù)讀取,這樣可以有效減少磁頭的移動距離。
Windows操作系統(tǒng)中有多種異步IO方式,但是性能最高,伸縮性最好的方式莫過于傳說中的“IO完成端口(I/O Completion Port,IOCP)”了,這也是.NET中封裝的唯一異步IO方式。大約一年半前,老趙寫過一篇文章《正確使用異步操作》,其中除了描述計算密集型和IO密集型操作的區(qū)別和效果之外,還簡單地講述了IOCP與CLR交互的方式,摘錄如下:
當(dāng)我們希望進(jìn)行一個異步的IO-Bound Operation時,CLR會(通過Windows API)發(fā)出一個IRP(I/O Request Packet)。當(dāng)設(shè)備準(zhǔn)備妥當(dāng),就會找出一個它“最想處理”的IRP(例如一個讀取離當(dāng)前磁頭最近的數(shù)據(jù)的請求)并進(jìn)行處理,處理完畢后設(shè)備將會(通過Windows)交還一個表示工作完成的IRP。CLR會為每個進(jìn)程創(chuàng)建一個IOCP(I/O Completion Port)并和Windows操作系統(tǒng)一起維護(hù)。IOCP中一旦被放入表示完成的IRP之后(通過內(nèi)部的ThreadPool.BindHandle完成),CLR就會盡快分配一個可用的線程用于繼續(xù)接下去的任務(wù)。
不過事實上,使用Windows API編寫IOCP非常復(fù)雜。而在.NET中,由于需要迎合標(biāo)準(zhǔn)的APM(異步編程模型),在使用方便的同時也放棄一定的控制能力。因此,在一些真正需要高吞吐量的時候(如編寫服務(wù)器),不少開發(fā)人員還是會選擇直接使用Native Code編寫相關(guān)代碼。不過在絕大部分的情況下,.NET中利用IOCP的異步IO操作已經(jīng)足以獲得非常優(yōu)秀的性能了。使用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 將發(fā)起一個利用IOCP的異步IO操作,并在結(jié)束時調(diào)用HandleAsyncCallback回調(diào)函數(shù)。那么,這個回調(diào)函數(shù)是由哪里的線程執(zhí)行的呢?沒錯,就是傳說中“IO線程池”的線程。.NET在一個進(jìn)程中準(zhǔn)備了兩個線程池,除了上篇文章中所提到的CLR線程池之外,它還為異步IO操作的回調(diào)準(zhǔn)備了一個IO線程池。IO線程池的特性與CLR線程池類似,也會動態(tài)地創(chuàng)建和銷毀線程,并且也擁有最大值和最小值(可以參考上一篇文章列舉出的API)。
只可惜,IO線程池也僅僅是那“一整個”線程池,CLR線程池的缺點IO線程池也一應(yīng)俱全。例如,在使用異步IO方式讀取了一段文本之后,下一步操作往往是對其進(jìn)行分析,這就進(jìn)入了計算密集型操作了。但對于計算密集型操作來說,如果使用整個IO線程池來執(zhí)行,我們無法有效的控制某項任務(wù)的運算能力。因此在有些時候,我們在回調(diào)函數(shù)內(nèi)部會把計算任務(wù)再次交還給獨立的線程池。這么做從理論上看會增大線程調(diào)度的開銷,不過實際情況還得看具體的評測數(shù)據(jù)。如果它真的成為影響性能的關(guān)鍵因素之一,我們就可能需要使用Native Code來調(diào)用IOCP相關(guān)API,將回調(diào)任務(wù)直接交給獨立的線程池去執(zhí)行了。
我們也可以使用代碼來操作IO線程池,例如下面這個接口便是向IO線程池遞交一個任務(wù):
- public static class ThreadPool
- {
- public static bool UnsafeQueueNativeOverlapped(NativeOverlapped* overlapped);
- }
NativeOverlapped包含了一個IOCompletionCallback回調(diào)函數(shù)及一個緩沖對象,可以通過Overlapped對象創(chuàng)建
Overlapped會包含一個被固定的空間,這里“固定”的含義表示不會因為GC而導(dǎo)致地址改變,甚至不會被置換到硬盤上的Swap空間去。這么做的目的是迎合IOCP的要求,但是很明顯它也會降低程序性能。因此,我們在實際編程中幾乎不會使用這個方法3。
注1:如果沒有加以說明,我們這里談?wù)摰膶ο竽J(rèn)為XP及以上版本的Window操作系統(tǒng)。
注2:timeslice又被稱為quantum,不同操作系統(tǒng)中定義的這個值并不相同。在Windows客戶端操作系統(tǒng)(XP,Vista)中時間片默認(rèn)為2個clock interval,在服務(wù)器操作系統(tǒng)(2003,2008)中默認(rèn)為12個clock interval(在主流系統(tǒng)上,1個clock interval大約10到15毫秒)。服務(wù)器操作系統(tǒng)使用較長的時間片,是因為一般服務(wù)器上運行的程序比客戶端要少很多,且更注重性能和吞吐量,而客戶端系統(tǒng)更注重響應(yīng)能力——而且,如果您真需要的話,時間片的長度也是可以調(diào)整的。
注3:不過,如果程序中多次復(fù)用單個NativeOverlapped對象的話,這個方法的性能會略微好于QueueUserWorkItem,據(jù)說WCF中便使用了這種方式——微軟內(nèi)部總有那么些技巧是我們不知如何使用的,例如老趙記得之前查看ASP.NET AJAX源代碼的時候,在MSDN中不小心發(fā)現(xiàn)一個接口描述大意是“預(yù)留方法,請不要在外部使用”。對此,我們又能有什么辦法呢?
【編輯推薦】























