C#中的多线程
1簡介及概念
C# 支持通過多線程并行執行代碼,線程有其獨立的執行路徑,能夠與其它線程同時執行。
一個 C# 客戶端程序(Console 命令行、WPF 以及 Windows Forms)開始于一個單線程,這個線程(也稱為“主線程”)是由 CLR 和操作系統自動創建的,并且也可以再創建其它線程。以下是一個簡單的使用多線程的例子:
所有示例都假定已經引用了以下命名空間:
?
class ThreadTest{static void Main() { Thread t = new Thread (WriteY); // 創建新線程 t.Start(); // 啟動新線程,執行WriteY() // 同時,在主線程做其它事情 for (int i = 0; i < 1000; i++) Console.Write ("x"); } static void WriteY() { for (int i = 0; i < 1000; i++) Console.Write ("y"); }}?
輸出結果:
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx ...?
主線程創建了一個新線程t來不斷打印字母 “ y “,與此同時,主線程在不停打印字母 “ x “。
線程一旦啟動,線程的IsAlive屬性值就會為true,直到線程結束。當傳遞給Thread的構造方法的委托執行完成時,線程就會結束。一旦結束,該線程不能再重新啟動。
CLR 為每個線程分配各自獨立的棧空間,因此局部變量是獨立的。在下面的例子中,我們定義一個擁有局部變量的方法,然后在主線程和新創建的線程中同時執行該方法。
static void Main(){ new Thread (Go).Start(); // 在新線程執行Go() Go(); // 在主線程執行Go()}static void Go(){ // 定義和使用局部變量 - 'cycles' for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');}?
輸出結果:??????????
變量cycles的副本是分別在各自的棧中創建的,因此才會輸出 10 個問號。
線程可以通過對同一對象的引用來共享數據。例如:
class ThreadTest{bool done; static void Main() { ThreadTest tt = new ThreadTest(); // 創建一個公共的實例 new Thread (tt.Go).Start(); tt.Go(); } // 注意: Go現在是一個實例方法 void Go() { if (!done) { done = true; Console.WriteLine ("Done"); } }}?
由于兩個線程是調用了同一個的ThreadTest實例上的Go(),它們共享了done字段,因此輸出結果是一次 “ Done “,而不是兩次。
輸出結果:Done
靜態字段提供了另一種在線程間共享數據的方式,以下是一個靜態的done字段的例子:
class ThreadTest{static bool done; // 靜態字段在所有線程中共享 static void Main() { new Thread (Go).Start(); Go(); } static void Go() { if (!done) { done = true; Console.WriteLine ("Done"); } }}?
以上兩個例子引出了一個關鍵概念線程安全(thread safety)。上述兩個例子的輸出實際上是不確定的:” Done “ 有可能會被打印兩次。如果在Go方法里調換指令的順序,” Done “ 被打印兩次的幾率會大幅提高:
static void Go(){ if (!done) { Console.WriteLine ("Done"); done = true; }}?
輸出結果:
Done Done (很可能!)?
這個問題是因為一個線程對if中的語句估值的時候,另一個線程正在執行WriteLine語句,這時done還沒有被設置為true。
修復這個問題需要在讀寫公共字段時,獲得一個排它鎖(互斥鎖,exclusive lock )。C# 提供了lock來達到這個目的:
class ThreadSafe{static bool done; static readonly object locker = new object(); static void Main() { new Thread (Go).Start(); Go(); } static void Go() { lock (locker) { if (!done) { Console.WriteLine ("Done"); done = true; } } }}?
當兩個線程同時爭奪一個鎖的時候(例子中的locker),一個線程等待,或者說阻塞,直到鎖變為可用。這樣就確保了在同一時刻只有一個線程能進入臨界區(critical section,不允許并發執行的代碼),所以 “ Done “ 只被打印了一次。像這種用來避免在多線程下的不確定性的方式被稱為線程安全(thread-safe)。
在線程間共享數據是造成多線程復雜、難以定位的錯誤的主要原因。盡管這通常是必須的,但應該盡可能保持簡單。
一個線程被阻塞時,不會消耗 CPU 資源。
1.1Join 和 Sleep
可以通過調用線程的Join方法來等待另一個線程結束,例如:
static void Main(){ Thread t = new Thread (Go); t.Start(); t.Join(); Console.WriteLine ("Thread t has ended!");}static void Go(){ for (int i = 0; i < 1000; i++) Console.Write ("y");}?
輸出 “ y “ 1,000 次之后,緊接著會輸出 “ Thread t has ended! “。當調用Join時可以使用一個超時參數,以毫秒或是TimeSpan形式。如果線程正常結束則返回true,如果超時則返回false。
Thread.Sleep會將當前的線程阻塞一段時間:
Thread.Sleep (TimeSpan.FromHours (1)); // 阻塞 1小時Thread.Sleep (500); // 阻塞 500 毫秒?
當使用Sleep或Join等待時,線程是阻塞(blocked)狀態,因此不會消耗 CPU 資源。
Thread.Sleep(0)會立即釋放當前的時間片,將 CPU 資源出讓給其它線程。Framework 4.0 新的Thread.Yield()方法與其相同,除了它只會出讓給運行在相同處理器核心上的其它線程。
Sleep(0)和Yield在調整代碼性能時偶爾有用,它也是一個很好的診斷工具,可以用于找出線程安全(thread safety)的問題。如果在你代碼的任意位置插入Thread.Yield()會影響到程序,基本可以確定存在 bug。
1.2線程是如何工作的
線程在內部由一個線程調度器(thread scheduler)管理,一般 CLR 會把這個任務交給操作系統完成。線程調度器確保所有活動的線程能夠分配到適當的執行時間,并且保證那些處于等待或阻塞狀態(例如,等待排它鎖或者用戶輸入)的線程不消耗CPU時間。
在單核計算機上,線程調度器會進行時間切片(time-slicing),快速的在活動線程中切換執行。在 Windows 操作系統上,一個時間片通常在十幾毫秒(譯者注:默認 15.625ms),遠大于 CPU 在線程間進行上下文切換的開銷(通常在幾微秒區間)。
在多核計算機上,多線程的實現是混合了時間切片和真實的并發,不同的線程同時運行在不同的 CPU 核心上。幾乎可以肯定仍然會使用到時間切片,因為操作系統除了要調度其它的應用,還需要調度自身的線程。
線程的執行由于外部因素(比如時間切片)被中斷稱為被搶占(preempted)。在大多數情況下,線程無法控制其在何時及在什么代碼處被搶占。
1.3線程 vs 進程
好比多個進程并行在計算機上執行,多個線程是在一個進程中并行執行。進程是完全隔離的,而線程是在一定程度上隔離。一般的,線程與運行在相同程序中的其它線程共享堆內存。這就是線程為何有用的部分原因,一個線程可以在后臺獲取數據,而另一個線程可以同時顯示已獲取到的數據。
1.4線程的使用與誤用
多線程有許多用處,下面是通常的應用場景:
-
維持用戶界面的響應
-
使用工作線程并行運行時間消耗大的任務,這樣主UI線程就仍然可以響應鍵盤、鼠標的事件。
-
有效利用 CPU
-
多線程在一個線程等待其它計算機或硬件設備響應時非常有用。當一個線程在執行任務時被阻塞,其它線程就可以利用這個空閑出來的CPU核心。
-
并行計算
-
在多核心或多處理器的計算機上,計算密集型的代碼如果通過分治策略(divide-and-conquer,見第 5 部分)將工作量分攤到多個線程,就可以提高計算速度。
-
推測執行(speculative execution)
-
在多核心的計算機上,有時可以通過推測之后需要被執行的工作,提前執行它們來提高性能。LINQPad就使用了這個技術來加速新查詢的創建。另一種方式就是可以多線程并行運行解決相同問題的不同算法,因為預先不知道哪個算法更好,這樣做就可以盡早獲得結果。
-
允許同時處理請求
-
在服務端,客戶端請求可能同時到達,因此需要并行處理(如果你使用 ASP.NET、WCF、Web Services 或者 Remoting,.NET Framework 會自動創建線程)。這在客戶端同樣有用,例如處理 P2P 網絡連接,或是處理來自用戶的多個請求。
如果使用了 ASP.NET 和 WCF 之類的技術,可能不會注意到多線程被使用,除非是訪問共享數據時(比如通過靜態字段共享數據)。如果沒有正確的加鎖,就可能產生線程安全問題。
多線程同樣也會帶來缺點,最大的問題是它提高了程序的復雜度。使用多個線程本身并不復雜,復雜的是線程間的交互(一般是通過共享數據)。無論線程間的交互是否有意為之,都會帶來較長的開發周期,以及帶來間歇的、難以重現的 bug。因此,最好保證線程間的交互盡量少,并堅持簡單和已被證明的多線程交互設計。這篇文章主要就是關于如何處理這種復雜的問題,如果能夠移除線程間交互,那會輕松許多。
一個好的策略是把多線程邏輯使用可重用的類封裝,以便于獨立的檢驗和測試。.NET Framework 提供了許多高層的線程構造,之后會講到。
當頻繁地調度和切換線程時(并且如果活動線程數量大于 CPU 核心數),多線程會增加資源和 CPU 的開銷,線程的創建和銷毀也會增加開銷。多線程并不總是能提升程序的運行速度,如果使用不當,反而可能降低速度。 例如,當需要進行大量的磁盤 I/O 時,幾個工作線程順序執行可能會比 10 個線程同時執行要快。(在使用 Wait 和 Pulse 進行同步中,將會描述如何實現 生產者 / 消費者隊列,它提供了上述功能。)
2創建和啟動線程
像我們在簡介中看到的那樣,使用Thread類的構造方法來創建線程,通過傳遞ThreadStart委托來指明線程從哪里開始運行,下面是ThreadStart委托的定義:
public delegate void ThreadStart();?
調用Start方法后,線程開始執行,直到它所執行的方法返回后,線程終止。下面這個例子使用完整的 C# 語法創建TheadStart委托:
class ThreadTest{static void Main() { Thread t = new Thread (new ThreadStart (Go)); t.Start(); // 在新線程運行 GO() Go(); // 同時在主線程運行 GO() } static void Go() { Console.WriteLine ("hello!"); }}?
在這個例子中,線程t執行Go()方法,幾乎同時主線程也執行Go()方法,結果將打印兩個 hello。
線程也可以使用更簡潔的語法創建,使用方法組(method group),讓 C# 編譯器推斷ThreadStart委托類型:
Thread t = new Thread (Go); // 無需顯式使用 ThreadStart?
另一個快捷的方式是使用 lambda 表達式或者匿名方法:
static void Main(){ Thread t = new Thread ( () => Console.WriteLine ("Hello!") ); t.Start();}?
2.1向線程傳遞數據
向一個線程的目標方法傳遞參數最簡單的方式是使用 lambda 表達式調用目標方法,在表達式內指定參數:
static void Main(){ Thread t = new Thread ( () => Print ("Hello from t!") ); t.Start();}static void Print (string message){ Console.WriteLine (message);}?
使用這種方式,可以向方法傳遞任意數量的參數。甚至可以將整個實現封裝為一個多語句的 lambda 表達式:
new Thread (() =>{ Console.WriteLine ("I'm running on another thread!"); Console.WriteLine ("This is so easy!");}).Start();?
在 C# 2.0 中,也可以很容易的使用匿名方法來進行相同的操作:
new Thread (delegate(){ ...}).Start();?
另一個方法是向Thread的Start方法傳遞參數:
static void Main(){ Thread t = new Thread (Print); t.Start ("Hello from t!");}static void Print (object messageObj){ string message = (string) messageObj; // 需要強制類型轉換 Console.WriteLine (message);}?
可以這樣是因為Thread的構造方法通過重載來接受兩個委托中的任意一個:
public delegate void ThreadStart();public delegate void ParameterizedThreadStart (object obj);?
ParameterizedThreadStart的限制是它只接受一個參數。并且由于它是object類型,通常需要類型轉換。
Lambda 表達式與被捕獲變量
如我們所見,lambda 表達式是向線程傳遞數據的最強大的方法。然而必須小心,不要在啟動線程之后誤修改被捕獲變量(captured variables)。例如,考慮下面的例子:
for (int i = 0; i < 10; i++) new Thread (() => Console.Write (i)).Start();?
輸出結果是不確定的!可能是這樣0223557799。
問題在于變量i在整個循環中指向相同的內存地址。所以,每一個線程在調用Console.Write時,都在使用這個值在運行時會被改變的變量!
類似的問題在C# 4.0 in a Nutshell的第 8 章的 “Captured Variables” 有描述。這個問題與多線程沒什么關系,而是和 C# 的捕獲變量的規則有關(在for和foreach的場景下有時不是很理想)。
解決方法就是使用臨時變量,如下所示:
for (int i = 0; i < 10; i++){ int temp = i; new Thread (() => Console.Write (temp)).Start();}?
變量temp對于每一個循環迭代是局部的。所以,每一個線程會捕獲一個不同的內存地址,從而不會產生問題。我們可以使用更為簡單的代碼來演示前面的問題:
string text = "t1";Thread t1 = new Thread ( () => Console.WriteLine (text) );text = "t2";Thread t2 = new Thread ( () => Console.WriteLine (text) );t1.Start();t2.Start();?
因為兩個lambda表達式捕獲了相同的text變量,t2會被打印兩次:
t2 t2?
2.2線程命名
每一個線程都有一個 Name 屬性,我們可以設置它以便于調試。這在 Visual Studio 中非常有用,因為線程的名字會顯示在線程窗口(Threads Window)與調試位置(Debug Location)工具欄上。線程的名字只能設置一次,以后嘗試修改會拋出異常。
靜態的Thread.CurrentThread屬性會返回當前執行的線程。在下面的例子中,我們設置主線程的名字:
class ThreadNaming{static void Main() { Thread.CurrentThread.Name = "main"; Thread worker = new Thread (Go); worker.Name = "worker"; worker.Start(); Go(); } static void Go() { Console.WriteLine ("Hello from " + Thread.CurrentThread.Name); }}?
2.3前臺與后臺線程
默認情況下,顯式創建的線程都是前臺線程(foreground threads)。只要有一個前臺線程在運行,程序就可以保持存活,而后臺線程(background threads)并不能保持程序存活。當一個程序中所有前臺線程停止運行時,仍在運行的所有后臺線程會被強制終止。
線程的前臺/后臺狀態與它的優先級和執行時間的分配無關。
可以通過線程的IsBackground屬性來查詢或修改線程的前后臺狀態。如下面的例子:
class PriorityTest{static void Main (string[] args) { Thread worker = new Thread ( () => Console.ReadLine() ); if (args.Length > 0) worker.IsBackground = true; worker.Start(); }}?
如果這個程序以無參數的形式運行,工作線程會默認為前臺,并在ReadLine時等待用戶輸入回車。此時主線程退出,但是程序仍然在運行,因為有一個前臺線程依然存活。
相反,如果給Main()傳遞了參數,工作線程設置為后臺狀態,當主線程結束時,程序幾乎立即退出(終止ReadLine需要一咪咪時間)。
當進程以這種方式結束時,后臺線程執行棧中所有finally塊就會被避開。如果程序依賴finally(或是using)塊來執行清理工作,例如釋放資源或是刪除臨時文件,就可能會產生問題。為了避免這種問題,在退出程序時可以顯式的等待這些后臺線程結束。有兩種方法可以實現:
-
如果是自己創建的線程,在線程上調用Join方法。
-
如果是使用線程池線程,使用事件等待句柄。
在任一種情況下,都應指定一個超時時間,從而可以放棄由于某種原因而無法正常結束的線程。這是后備的退出策略:我們希望程序最后可以關閉,而不是讓用戶去開任務管理器(╯-_-)╯╧══╧
如果用戶使用任務管理器強行終止了 .NET 進程,所有線程都會被當作后臺線程一般丟棄。這是通過觀察得出的結論,并不是通過文檔,而且可能會因為 CLR 和操作系統的版本不同而有不同的行為。
前臺線程不需要這種處理,但是必須小心避免會使線程無法結束的 bug。程序無法正常退出的一個很有可能的原因就是仍有前臺線程存在。
2.4線程優先級
線程的Priority屬性決定了相對于操作系統中的其它活動線程,它可以獲得多少執行時間。線程優先級的取值如下:
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }?
只有當多個線程同時活動時,線程優先級才有意義。
在提升線程優先級前請三思,這可能會導致其它線程的資源饑餓(resource starvation,譯者注:指沒有分配到足夠的CPU時間來運行)等問題。
提升線程的優先級是無法使其能夠處理實時任務的,因為它還受到程序進程優先級的影響。要進行實時任務,必須同時使用System.Diagnostics中的Process類來提升進程的優先級(記得這不是我告訴你的):
using (Process p = Process.GetCurrentProcess()) p.PriorityClass = ProcessPriorityClass.High;?
ProcessPriorityClass.High實際上就是一個略低于最高優先級Realtime的級別。將一個進程的優先級設置為Realtime是通知操作系統,我們絕不希望該進程將 CPU 時間出讓給其它進程。如果你的程序誤入一個無限循環,會發現甚至是操作系統也被鎖住了,就只好去按電源按鈕了o(>_<)o 正是由于這一原因,High 通常是實時程序的最好選擇。
如果實時程序擁有用戶界面,提升進程的優先級會導致大量的 CPU 時間被用于屏幕更新,這會降低整臺機器的速度(特別是當 UI 很復雜時)。降低主線程的優先級,并提升進程的優先級可以保證需要進行實時任務的工作線程不會被屏幕重繪所搶占。但是這依然沒有解決其它程序的CPU時間饑餓的問題,因為操作系統依然為這個進程分配了大量 CPU 資源。
理想的解決方案是分離 UI 線程和實時工作線程,使用兩個進程分別運行。這樣就可以分別設置各自的進程優先級,彼此之間通過 Remoting 或是內存映射文件進行通信。內存映射文件十分適用于這一任務,在C# 4.0 in a Nutshell的第 14 和 25 章會講到。
即使是提升了進程優先級,托管環境在處理強實時需求時仍然有限制。除了自動垃圾回收帶來的延遲,操作系統也不能夠完全滿足實時任務的需要,即便是非托管的程序也是如此。最好的解決辦法還是使用獨立的硬件或者專門的實時平臺。
2.5異常處理
當線程開始運行后,其創建代碼所在的try / catch / finally塊與該線程不再有任何關系。考慮下面的程序:
public static void Main(){ try { new Thread (Go).Start(); } catch (Exception ex) { // 永遠執行不到這里 Console.WriteLine ("Exception!"); }}static void Go() { throw null; } // 產生 NullReferenceException 異常?
這個例子中的try / catch語句是無效的,而新創建的線程將會遇到一個未處理的NullReferenceException。當你考慮到每一個線程具有獨立的執行路徑時,這種行為就可以理解了。
修改方法是將異常處理移到Go方法中:
public static void Main(){ new Thread (Go).Start();}static void Go(){ try { // ... throw null; // 異常會在下面被捕獲 // ... } catch (Exception ex) { // 一般會記錄異常, 和/或通知其它線程我們遇到問題了 // ... }}?
在生產環境的程序中,所有線程的入口方法處都應該有一個異常處理器,就如同在主線程所做的那樣(一般可能是在執行棧上靠近入口的地方)。未處理的異常會使得整個程序停止運行,彈出一個難看的對話框。
在寫異常處理塊的時候,最好不要忽略錯誤。一般應該記錄異常詳細信息,然后可以彈出一個對話框讓用戶可以選擇是否自動把這些信息提交到你的服務器。最后應該關閉程序,因為很可能錯誤已經破壞的程序的狀態。然而這么做會導致用戶丟失當前的工作,比如打開的文檔。
WPF 和 Windows Forms 應用中的“全局”異常處理事件(Application.DispatcherUnhandledException和Application.ThreadException)只會在主UI線程有未處理的異常時觸發。對于工作線程上的異常仍然需要手動處理。
AppDomain.CurrentDomain.UnhandledException會對所有未處理的異常觸發,但是它不提供阻止程序退出的辦法。
然而在某些情況下,可以不必處理工作線程上的異常,因為 .NET Framework 會為你處理。這些會在接下來的內容中講到:
-
異步委托
-
BackgroundWorker
-
任務并行庫(TPL)
3線程池
當啟動一個線程時,會有幾百毫秒的時間花費在準備一些額外的資源上,例如一個新的私有局部變量棧這樣的事情。每個線程會占用(默認情況下)1MB 內存。線程池(thread pool)可以通過共享與回收線程來減輕這些開銷,允許多線程應用在很小的粒度上而沒有性能損失。在多核心處理器以分治(divide-and-conquer)的風格執行計算密集代碼時將會十分有用。
線程池會限制其同時運行的工作線程總數。太多的活動線程會加重操作系統的管理負擔,也會降低 CPU 緩存的效果。一旦達到數量限制,任務就會進行排隊,等待一個任務完成后才會啟動另一個。這使得程序任意并發成為可能,例如 web 服務器。(異步方法模式(asynchronous method pattern)是進一步高效利用線程池的高級技術,我們在C# 4.0 in a Nutshell的 23 章來講)。
有多種方法可以使用線程池:
-
通過任務并行庫(TPL)(Framework 4.0 中加入)
-
調用ThreadPool.QueueUserWorkItem
-
通過異步委托
-
通過BackgroundWorker
以下構造會間接使用線程池:
-
WCF、Remoting、ASP.NET 和 ASMX 網絡服務應用
-
System.Timers.Timer 和 System.Threading.Timer
-
.NET Framework 中名字以 Async 結尾的方法,例如WebClient上的方法(使用基于事件的異步模式,EAP),和大部分BeginXXX方法(異步編程模型模式,APM)
-
PLINQ
任務并行庫(Task Parallel Library,TPL)與 PLINQ 足夠強大并面向高層,即使使用線程池并不重要,也應該使用它們來輔助多線程。我們會在第 5 部分中進行更詳細的討論。現在,簡單看一下如何使用Task類作為在線程池線程上運行委托的簡單方法。
在使用線程池線程時有幾點需要小心:
-
無法設置線程池線程的Name屬性,這會令調試更為困難(當然,調試時也可以在 Visual Studio 的線程窗口中給線程附加備注)。
-
線程池線程永遠是后臺線程(一般不是問題)。
-
阻塞線程池線程可能會在程序早期帶來額外的延遲,除非調用了ThreadPool.SetMinThreads(見優化線程池)。
可以改變線程池線程的優先級,當它用完后返回線程池時會被恢復到默認狀態。
可以通過Thread.CurrentThread.IsThreadPoolThread屬性來查詢當前是否運行在線程池線程上。
3.1通過 TPL 使用線程池
可以很容易的使用任務并行庫(Task Parallel Library,TPL)中的Task類來使用線程池。Task類在 Framework 4.0 時被加入:如果你熟悉舊式的構造,可以將非泛型的Task類看作ThreadPool.QueueUserWorkItem的替代,而泛型的Task<TResult>看作異步委托的替代。比起舊式的構造,新式的構造會更快速,更方便,并且更靈活。
要使用非泛型的Task類,調用Task.Factory.StartNew,并傳遞目標方法的委托:
static void Main() // Task 類在 System.Threading.Tasks 命名空間中{ Task.Factory.StartNew (Go);}static void Go(){ Console.WriteLine ("Hello from the thread pool!");}?
Task.Factory.StartNew返回一個Task對象,可以用來監視任務,例如通過調用Wait方法來等待其結束。
當調用Task的Wait方法時,所有未處理的異常會在宿主線程被重新拋出。(如果不調用Wait而是丟棄不管,未處理的異常會像普通的線程那樣結束程序。)
泛型的Task<TResult>類是非泛型Task的子類。它可以使你在其完成執行后得到一個返回值。在下面的例子中,我們使用Task<TResult>來下載一個網頁:
static void Main(){ // 啟動 task: Task<string> task = Task.Factory.StartNew<string> ( () => DownloadString ("http://www.gkarch.com") ); // 執行其它工作,它會和 task 并行執行: RunSomeOtherMethod(); // 通過 Result 屬性獲取返回值: // 如果仍在執行中, 當前進程會阻塞等待直到 task 結束: string result = task.Result;}static string DownloadString (string uri){ using (var wc = new System.Net.WebClient()) return wc.DownloadString (uri);}?
(這里的<string> 類型參數是為了示例的清晰,它可以被省略,讓編譯器推斷。)
查詢task的Result屬性時,未處理的異常會被封裝在AggregateException中自動重新拋出。然而,如果沒有查詢Result屬性(并且也沒有調用Wait),未處理的異常會令程序結束。
TPL 具有更多的特性,非常適合于利用多核處理器。關于 TPL 的討論我們在第 5 部分中繼續。
3.2不通過 TPL 使用線程池
如果是使用 .NET Framework 4.0 以前的版本,則不能使用任務并行庫。你必須通過一種舊的構造使用線程池:ThreadPool.QueueUserWorkItem與異步委托。這兩者之間的不同在于異步委托可以讓你從線程中返回數據,同時異步委托還可以將異常封送回調用方。
QueueUserWorkItem
要使用QueueUserWorkItem,僅需要使用希望在線程池線程上運行的委托來調用該方法:
static void Main(){ ThreadPool.QueueUserWorkItem (Go); ThreadPool.QueueUserWorkItem (Go, 123); Console.ReadLine();}static void Go (object data) // 第一次調用時 data 為 null{ Console.WriteLine ("Hello from the thread pool! " + data);}?
輸出結果:
Hello from the thread pool! Hello from the thread pool! 123?
目標方法Go,必須接受單一一個object參數(來滿足WaitCallback委托)。這提供了一種向方法傳遞數據的便捷方式,就像ParameterizedThreadStart一樣。與Task不同,QueueUserWorkItem并不會返回一個對象來幫助我們在后續管理其執行。并且,你必須在目標代碼中顯式處理異常,未處理的異常會令程序結束。
異步委托
ThreadPool.QueueUserWorkItem并沒有提供在線程執行結束之后從線程中返回值的簡單機制。異步委托調用(asynchronous delegate invocations )解決了這一問題,可以允許雙向傳遞任意數量的參數。而且,異步委托上的未處理異常可以方便的原線程上重新拋出(更確切的說,在調用EndInvoke的線程上),所以它們不需要顯式處理。
不要混淆異步委托和異步方法(asynchronous methods ,以 Begin 或 End 開始的方法,比如File.BeginRead/File.EndRead)。異步方法表面上看使用了相似的方式,但是其實是為了解決更困難的問題。我們在C# 4.0 in a Nutshell的第 23 章中描述。
下面是如何通過異步委托啟動一個工作線程:
創建目標方法的委托(通常是一個Func類型的委托)。
在該委托上調用BeginInvoke,保存其IAsyncResult類型的返回值。
BeginInvokde會立即返回。當線程池線程正在工作時,你可以執行其它的動作。
當需要結果時,在委托上調用EndInvoke,傳遞所保存的IAsyncResult對象。
接下來的例子中,我們使用異步委托調用來和主線程中并行運行一個返回字行串長度的簡單方法:
static void Main(){ Func<string, int> method = Work; IAsyncResult cookie = method.BeginInvoke ("test", null, null); // // 這里可以并行執行其它任務 // int result = method.EndInvoke (cookie); Console.WriteLine ("String length is: " + result);}static int Work (string s) { return s.Length; }?
EndInvoke會做三件事:
如果異步委托還沒有結束,它會等待異步委托完成執行。
它會接收返回值(也包括ref和out方式的參數)。
它會向調用線程拋出未處理的異常。
如果使用異步委托調用的方法沒有返回值,技術上你仍然需要調用EndInvoke。在實踐中,這里存在爭論,因為不調用EndInvoke也不會有什么損失。然而如果你選擇不調用它,就需要考慮目標方法中的異常處理來避免錯誤無法察覺。
(譯者注:MSDN文檔中明確寫了 “無論您使用何種方法,都要調用 EndInvoke 來完成異步調用。”,所以最好不要偷懶。)
當調用BeginInvoke時也可以指定一個回調委托。這是一個在完成時會被自動調用的、接受IAsyncResult對象的方法。這樣可以在后面的代碼中“忘記”異步委托,但是需要在回調方法里做點其它工作:
static void Main(){ Func<string, int> method = Work; method.BeginInvoke ("test", Done, method); // ... //}static int Work (string s) { return s.Length; }static void Done (IAsyncResult cookie){ var target = (Func<string, int>) cookie.AsyncState; int result = target.EndInvoke (cookie); Console.WriteLine ("String length is: " + result);}?
BeginInvoke的最后一個參數是一個用戶狀態對象,用于設置IAsyncResult的AsyncState屬性。它可以是需要的任何東西,在這個例子中,我們用它向回調方法傳遞method委托,這樣才能夠在它上面調用EndInvoke。
3.3優化線程池
線程池初始時其池內只有一個線程。隨著任務的分配,線程池管理器就會向池內“注入”新線程來滿足工作負荷的需要,直到最大數量的限制。在足夠的非活動時間之后,線程池管理器在認為“回收”一些線程能夠帶來更好的吞吐量時進行線程回收。
可以通過調用ThreadPool.SetMaxThreads方法來設置線程池可以創建的線程上限;默認如下:
-
Framework 4.0,32位環境下:1023
-
Framework 4.0,64位環境下:32768
-
Framework 3.5:每個核心 250
-
Framework 2.0:每個核心 25
(這些數字可能根據硬件和操作系統不同而有差異。)數量這么多是因為要確定阻塞(等待一些條件,比如遠程計算機的相應)的線程的條件是否被滿足。
也可以通過ThreadPool.SetMinThreads設置線程數量下限。下限的作用比較奇妙:它是一種高級的優化技術,用來指示線程池管理器在達到下限之前不要延遲線程的分配。當存在阻塞線程時,提高下限可以改善程序并發性。
默認下限數量是 CPU 核心數,也就是能充分利用 CPU 的最小數值。在服務器環境下(比如 IIS 中的 ASP.NET),下限數量一般要高的多,差不多 50 或者更高。
最小線程數量是如何起作用的?
將線程池的最小線程數設置為 x 并不是立即創建至少 x 個線程,而是線程會根據需要來創建。這個數值是指示線程池管理器當需要的時候,立即 創建 x 個線程。那么問題是為什么線程池在其它情況下會延遲創建線程?
答案是為了防止短生命周期的任務導致線程數量短暫高峰,使程序的內存足跡(memory footprint)快速膨脹。為了描述這個問題,考慮在一個 4 核的計算機上運行一個客戶端程序,它一次發起了 40 個任務請求。如果每個任務都是一個 10ms 的計算,假設它們平均分配在 4 個核心上,總共的開銷就是 100ms 多。理想情況下,我們希望這 40 個任務運行在 4 個線程上:
-
如果線程數量更少,就無法充分利用 4 個核心。
-
如果線程數量更多,會浪費內存和 CPU 時間去創建不必要的線程。
線程池就是以這種方式工作的。讓線程數量和 CPU 核心數量匹配,就能夠既保持小的內存足跡,又不損失性能。當然這也需要線程都能被充分使用(在這個例子中滿足該條件)。
但是,現在來假設任務不是進行 10ms 的計算,而是請求互聯網,使用半秒鐘等待響應,此時本地 CPU 是空閑狀態。線程池管理器的線程經濟策略(譯者注:指上面說的線程數量匹配核心數)這時就不靈了,應該創建更多的線程,讓所有的請求同時進行。
幸運的是,線程池管理器還有一個后備方案。如果在半秒內沒有能夠響應請求隊列,就會再創建一個新的線程,以此類推,直到線程數量上限。
半秒的等待時間是一把雙刃劍。一方面它意味著一次性的短暫任務不會使程序快速消耗不必要的40MB(或者更多)的內存。另一方面,在線程池線程被阻塞時,比如在請求數據庫或者調用WebClient.DownloadFile,就進行不必要的等待。因為這種原因,你可以通過調用SetMinThreads來讓線程池管理器在分配最初的 x 個線程時不要等待,例如:
ThreadPool.SetMinThreads (50, 50);?
(第二個參數是表示多少個線程分配給 I/O 完成端口(I/O completion ports,IOCP),來被APM使用,這會在C# 4.0 in a Nutshell 的第 23 章描述。)
最小線程數量的默認值是 CPU 核心數。
?
原文鏈接: http://blog.gkarch.com/threading/part1.html
轉載于:https://www.cnblogs.com/JustForDream/p/4887859.html
總結
- 上一篇: hdu 3887 Counting O
- 下一篇: SI 9000 及阻抗匹配学习笔记(四)