文章系參考轉載,英文原文網址請參考:http://www.albahari.com/threading/
作者 Joseph Albahari, 翻譯 Swanky Wu
中文翻譯作者把原文放在了"google 協作"上面,GFW屏蔽,不能訪問和查看,因此我根據譯文和英文原版整理轉載到園子裡面。
本系列文章可以算是一本很出色的C#線程手冊,思路清晰,要點都有介紹,看了後對C#的線程及同步等有了更深入的理解。
- 入門
- 線程同步基礎
- 同步要領
- 鎖和安全執行緒
- Interrupt 和 Abort
- 線程狀態
- 等待控制代碼
- 同步環境
- 使用多線程
- 單元模式和Windows Forms
- BackgroundWorker類
- ReaderWriterLock類
- 線程池
- 非同步委託
- 計時器
- 局部儲存
- 進階話題
- 非阻止同步
- Wait和Pulse
- Suspend和Resume
- 終止線程
一、入門
1. 概述與概念
C#支援通過多線程並行地執行代碼,一個線程有它獨立的執行路徑,能夠與其它的線程同時地運行。一個C#程式開始於一個單線程,這個單線程是被CLR和作業系統(也稱為“主線程”)自動建立的,並具有多線程建立額外的線程。這裡的一個簡單的例子及其輸出:
除非被指定,否則所有的例子都假定以下命名空間被引用了:
using System;
using System.Threading;
class ThreadTest { static void Main() { Thread t = new Thread (WriteY); t.Start(); // Run WriteY on the new thread while (true) Console.Write ("x"); // Write 'x' forever } static void WriteY() { while (true) Console.Write ("y"); // Write 'y' forever }}
主線程建立了一個新線程“t”,它運行了一個重複列印字母"y"的方法,同時主線程重複但因字母“x”。CLR分配每個線程到它自己的記憶體堆棧上,來保證局部變數的分離運行。在接下來的方法中我們定義了一個局部變數,然後在主線程和新建立的線程上同時地調用這個方法。
static void Main() { new Thread (Go).Start(); // Call Go() on a new thread Go(); // Call Go() on the main thread} static void Go() { // Declare and use a local variable - 'cycles' for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');}
變數cycles的副本分別在各自的記憶體堆棧中建立,輸出也一樣,可預見,會有10個問號輸出。當線程們引用了一些公用的目標執行個體的時候,他們會共用資料。下面是執行個體:
class ThreadTest { bool done; static void Main() { ThreadTest tt = new ThreadTest(); // Create a common instance new Thread (tt.Go).Start(); tt.Go(); } // Note that Go is now an instance method void Go() { if (!done) { done = true; Console.WriteLine ("Done"); } }}因為在相同的ThreadTest執行個體中,兩個線程都調用了Go(),它們共用了done欄位,這個結果輸出的是一個"Done",而不是兩個。
靜態欄位提供了另一種線上程間共用資料的方式,下面是一個以done為靜態欄位的例子:
class ThreadTest { static bool done; // Static fields are shared between all threads static void Main() { new Thread (Go).Start(); Go(); } static void Go() { if (!done) { done = true; Console.WriteLine ("Done"); } }}
上述兩個例子足以說明, 另一個關鍵概念, 那就是安全執行緒(或反之,它的不足之處! ) 輸出實際上是不確定的:它可能(雖然不大可能) , "Done" ,可以被列印兩次。然而,如果我們在Go方法裡調換指令的順序, "Done"被列印兩次的機會會大幅地上升:
static void Go() { if (!done) { Console.WriteLine ("Done"); done = true; }}
問題就是一個線程在判斷if塊的時候,正好另一個線程正在執行WriteLine語句——在它將done設定為true之前。
補救措施是當讀寫公用欄位的時候,提供一個獨佔鎖定;C#提供了lock語句來達到這個目的:
class ThreadSafe { static bool done; static 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),一個線程等待,或者說被阻止到那個鎖變的可用。在這種情況下,就確保了在同一時刻只有一個線程能進入臨界區,所以"Done"只被列印了1次。代碼以如此方式在不確定的多線程環境中被叫做安全執行緒。
臨時暫停,或阻止是多線程的協同工作,同步活動的本質特徵。等待一個排它鎖被釋放是一個線程被阻止的原因,另一個原因是線程想要暫停或Sleep一段時間:
Thread.Sleep (TimeSpan.FromSeconds (30)); // Block for 30 seconds
一個線程也可以使用它的Join方法來等待另一個線程結束:
Thread t = new Thread (Go); // Assume Go is some static methodt.Start();t.Join(); // Wait (block) until thread t ends
一個線程,一旦被阻止,它就不再消耗CPU的資源了。
線程是如何工作的
線程被一個線程協調程式管理著——一個CLR委託給作業系統的函數。線程協調程式確保將所有活動的線程被分配適當的執行時間;並且那些等待或阻止的線程——比如說在排它鎖中、或在使用者輸入——都是不消耗CPU時間的。
在單核處理器的電腦中,線程協調程式完成一個時間片之後迅速地在活動的線程之間進行切換執行。這就導致“波濤洶湧”的行為,例如在第一個例子,每次重複的X 或 Y 塊相當於分給線程的時間片。在Windows XP中時間片通常在10毫秒內選擇要比CPU開銷在處理線程切換的時候的消耗大的多。(即通常在幾微秒區間)
在多核的電腦中,多線程被實現成混合時間片和真實的並發——不同的線程在不同的CPU上運行。這幾乎可以肯定仍然會出現一些時間切片, 由於作業系統的需要服務自己的線程,以及一些其他的應用程式。
線程由於外部因素(比如時間片)被中斷被稱為被搶佔,在大多數情況下,一個線程方面在被搶佔的那一時那一刻就失去了對它的控制權。
線程 vs. 進程
屬於一個單一的應用程式的所有的線程邏輯上被包含在一個進程中,進程指一個應用程式所啟動並執行作業系統單元。
線程於進程有某些相似的地方:比如說進程通常以時間片方式與其它在電腦中啟動並執行進程的方式與一個C#程式線程啟動並執行方式大致相同。二者的關鍵區別在於進程彼此是完全隔絕的。線程與運行在相同程式其它線程共用(堆heap)記憶體,這就是線程為何如此有用:一個線程可以在後台讀取資料,而另一個線程可以在前台展現已讀取的資料。
何時使用多線程
多線程程式一般被用來在後台執行耗時的任務。主線程保持運行,並且背景工作執行緒做它的後台工作。對於Windows Forms程式來說,如果主線程試圖執行冗長的操作,鍵盤和滑鼠的操作會變的遲鈍,程式也會失去響應。由於這個原因,應該在背景工作執行緒中運行一個耗時任務時添加一個背景工作執行緒,即使在主線程上有一個有好的提示“處理中...”,以防止工作無法繼續。這就避免了程式出現由作業系統提示的“沒有相應”,來誘使使用者強制結束程式的進程而導致錯誤。強制回應對話方塊還允許實現“取消”功能,允許繼續接收事件,而實際的任務已被背景工作執行緒完成。BackgroundWorker恰好可以輔助完成這一功能。
在沒有使用者介面的程式裡,比如說Windows Service, 多線程在當一個任務有潛在的耗時,因為它在等待另台電腦的響應(比如一個應用伺服器,資料庫伺服器,或者一個用戶端)的實現特別有意義。用背景工作執行緒完成任務意味著主線程可以立即做其它的事情。
另一個多線程的用途是在方法中完成一個複雜的計算工作。這個方法會在多核的電腦上啟動並執行更快,如果工作量被多個線程分開的話(使用Environment.ProcessorCount屬性來偵測處理晶片的數量)。
一個C#程式稱為多線程的可以通過2種方式:明確地建立和運行多線程,或者使用.NET framework的暗中使用了多線程的特性——比如BackgroundWorker類, 線程池,threading timer,遠程伺服器,或Web Services或ASP.NET程式。在後面的情況,人們別無選擇,必須使用多線程;一個單線程的ASP.NET web server不是太酷,即使有這樣的事情;幸運的是,應用伺服器中多線程是相當普遍的;唯一值得關心的是提供適當鎖機制的靜態變數問題。
何時不要使用多線程
多線程也同樣會帶來缺點,最大的問題是它使程式變的過於複雜,擁有多線程本身並不複雜,複雜是的線程的互動作用,這帶來了無論是否互動是否是有意的,都會帶來較長的開發週期,以及帶來間歇性和非重複性的bugs。因此,要麼多線程的互動設計簡單一些,要麼就根本不使用多線程。除非你有強烈的重寫和調試慾望。
當使用者頻繁地分配和切換線程時,多線程會帶來增加資源和CPU的開銷。在某些情況下,太多的I/O操作是非常棘手的,當只有一個或兩個背景工作執行緒要比有眾多的線程在相同時間執行任務塊的多。稍後我們將實現生產者/耗費者 隊列,它提供了上述功能。
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(); // Run Go() on the new thread. Go(); // Simultaneously run Go() in the main thread. } static void Go() { Console.WriteLine ("hello!"); }
在這個例子中,線程t執行Go()方法,大約與此同時主線程也調用了Go(),結果是兩個幾乎同時hello被列印出來:
一個線程可以通過C#堆委託簡短的文法更便利地建立出來:
static void Main() { Thread t = new Thread (Go); // No need to explicitly use ThreadStart t.Start(); ...}static void Go() { ... }在這種情況,ThreadStart被編譯器自動推斷出來,另一個快捷的方式是使用匿名方法來啟動線程:
static void Main() { Thread t = new Thread (delegate() { Console.WriteLine ("Hello!"); }); t.Start();}
線程有一個IsAlive屬性,在調用Start()之後直到線程結束之前一直為true。一個線程一旦結束便不能重新開始了。
將資料傳入ThreadStart中
話又說回來,在上面的例子裡,我們想更好地區分開每個線程的輸出結果,讓其中一個線程輸出大寫字母。我們傳入一個狀態字到Go中來完成整個任務,但我們不能使用ThreadStart委託,因為它不接受參數,所幸的是,.NET framework定義了另一個版本的委託叫做ParameterizedThreadStart, 它可以接收一個單獨的object型別參數:
public delegate void ParameterizedThreadStart (object obj);之前的例子看起來是這樣的:
class ThreadTest { static void Main() { Thread t = new Thread (Go); t.Start (true); // == Go (true) Go (false); } static void Go (object upperCase) { bool upper = (bool) upperCase; Console.WriteLine (upper ? "HELLO!" : "hello!"); }
在整個例子中,編譯器自動推斷出ParameterizedThreadStart委託,因為Go方法接收一個單獨的object參數,就像這樣寫:
Thread t = new Thread (new ParameterizedThreadStart (Go));t.Start (true);
ParameterizedThreadStart的特性是在使用之前我們必需對我們想要的類型(這裡是bool)進行裝箱操作,並且它只能接收一個參數。
一個替代方案是使用一個匿名方法調用一個普通的方法如下:
static void Main() { Thread t = new Thread (delegate() { WriteText ("Hello"); }); t.Start();}static void WriteText (string text) { Console.WriteLine (text); }
優點是目標方法(這裡是WriteText),可以接收任意數量的參數,並且沒有裝箱操作。不過這需要將一個外部變數放入到匿名方法中,向下面的一樣:
static void Main() { string text = "Before"; Thread t = new Thread (delegate() { WriteText (text); }); text = "After"; t.Start();}static void WriteText (string text) { Console.WriteLine (text); }
匿名方法開啟了一種怪異的現象,當外部變數被後來的部分修改了值的時候,可能會透過外部變數進行無意的互動。有意的互動(通常通過欄位)被認為是足夠了!一旦線程開始運行了,外部變數最好被處理成唯讀——除非有人願意使用適當的鎖。
另一種較常見的方式是將對象執行個體的方法而不是靜態方法傳入到線程中,對象執行個體的屬性可以告訴線程要做什麼,如下列重寫了原來的例子:
class ThreadTest { bool upper; static void Main() { ThreadTest instance1 = new ThreadTest(); instance1.upper = true; Thread t = new Thread (instance1.Go); t.Start(); ThreadTest instance2 = new ThreadTest(); instance2.Go(); // 主線程——運行 upper=false } void Go() { Console.WriteLine (upper ? "HELLO!" : "hello!"); }
命名線程
線程可以通過它的Name屬性進行命名,這非產有利於調試:可以用Console.WriteLine列印出線程的名字,Microsoft Visual Studio可以將線程的名字顯示在調試工具列的位置上。線程的名字可以在被任何時間設定——但只能設定一次,重新命名會引發異常。
程式的主線程也可以被命名,下面例子裡主線程通過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); }}
前台和後台線程
線程預設為前台線程,這意味著任何前台線程在運行都會保持程式存活。C#也支援後台線程,當所有前台線程結束後,它們不維持程式的存活。
改變線程從前台到後台不會以任何方式改變它在CPU協調程式中的優先順序和狀態。
線程的IsBackground屬性控制它的前後台狀態,如下執行個體:
class PriorityTest { static void Main (string[] args) { Thread worker = new Thread (delegate() { Console.ReadLine(); }); if (args.Length > 0) worker.IsBackground = true; worker.Start(); }}
如果程式被調用的時候沒有任何參數,背景工作執行緒為前台線程,並且將等待ReadLine語句來等待使用者的觸發斷行符號,這期間,主線程退出,但是程式保持運行,因為一個前台線程仍然活著。
另一方面如果有參數傳入Main(),背景工作執行緒被賦值為後台線程,當主線程結束程式立刻退出,終止了ReadLine。
後台線程終止的這種方式,使任何最後操作都被規避了,這種方式是不太合適的。好的方式是明確等待任何後台背景工作執行緒完成後再結束程式,可能用一個timeout(大多用Thread.Join)。如果因為某種原因某個背景工作執行緒無法完成,可以用試圖終止它的方式,如果失敗了,再拋棄線程,允許它與 與進程一起消亡。(記錄是一個難題,但這個情境下是有意義的)
擁有一個後台背景工作執行緒是有益的,最直接的理由是它當提到結束程式它總是可能有最後的發言權。交織以不會消亡的前台線程,保證程式的正常退出。拋棄一個前台背景工作執行緒是尤為險惡的,尤其對Windows Forms程式,因為程式直到主線程結束時才退出(至少對使用者來說),但是它的進程仍然運行著。在Windows工作管理員它將從應用程式欄消失不見,但卻可以在進程欄找到它。除非使用者找到並結束它,它將繼續消耗資源,並可能阻止一個新的執行個體的運行從開始或影響它的特性。
對於程式失敗退出的普遍原因就是存在“被忘記”的前台線程。
線程優先順序
線程的Priority 屬性確定了線程相對於其它同一進程的活動的線程擁有多少執行時間,以下是層級:
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
只有多個線程同時為活動時,優先順序才有作用。
設定一個線程的優先順序為高一些,並不意味著它能執行即時的工作,因為它受限於程式的進程的層級。要執行即時的工作,必須提升在System.Diagnostics 命名空間下Process的層級,像下面這樣:(我沒有告訴你如何做到這一點:))
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
ProcessPriorityClass.High 其實是一個短暫缺口的過程中的最高優先順序別:Realtime。設定進程層級到Realtime通知作業系統:你不想讓你的進程被搶佔了。如果你的程式進入一個偶然的死迴圈,可以預期,作業系統被鎖住了,除了關機沒有什麼可以拯救你了!基於此,High大體上被認為最高的有用進程層級。
如果一個即時的程式有一個使用者介面,提升進程的層級是不太好的,因為當使用者介面UI過於複雜的時候,介面的更新耗費過多的CPU時間,拖慢了整台電腦。(雖然在寫這篇文章的時候,在互連網電話程式Skype僥倖地這麼做, 也許是因為它的介面相當簡單吧。) 降低主線程的層級、提升進程的層級、確保即時線程不進行介面重新整理,但這樣並不能避免電腦越來越慢,因為作業系統仍會撥出過多的CPU給整個進程。最理想的方案是使即時工作和使用者介面在不同的進程(擁有不同的優先順序)運行,通過Remoting或共用記憶體方式進行通訊,共用記憶體需要Win32 API中的 P/Invoking。(可以搜尋看看CreateFileMapping 和 MapViewOfFile)
異常處理
任何線程建立範圍內try/catch/finally塊,當線程開始執行便不再與其有任何關係。考慮下面的程式:
public static void Main() { try { new Thread (Go).Start(); } catch (Exception ex) { // 不會在這得到異常 Console.WriteLine ("Exception!"); } static void Go() { throw null; } }
這裡try/catch語句一點用也沒有,新建立的線程將引發NullReferenceException異常。當你考慮到每個線程有獨立的執行路徑的時候,便知道這行為是有道理的,
補救方法是線上程處理的方法內加入他們自己的異常處理:
public static void Main() { new Thread (Go).Start();} static void Go() { try { ... throw null; // 這個異常在下面會被捕捉到 ... } catch (Exception ex) { 記錄異常日誌,並且或通知另一個線程 我們發生錯誤 ... }
從.NET 2.0開始,任何線程內的未處理的異常都將導致整個程式關閉,這意味著忽略異常不再是一個選項了。因此為了避免由未處理異常引起的程式崩潰,try/catch塊需要出現在每個線程進入的方法內,至少要在產品程式中應該如此。對於經常使用“全域”異常處理的Windows Forms程式員來說,這可能有點麻煩,像下面這樣:
using System;using System.Threading;using System.Windows.Forms; static class Program { static void Main() { Application.ThreadException += HandleError; Application.Run (new MainForm()); } static void HandleError (object sender, ThreadExceptionEventArgs e) { 記錄異常或者退出程式或者繼續運行... }}
Application.ThreadException事件在異常被拋出時觸發,以一個Windows資訊(比如:鍵盤,滑鼠活著 "paint" 等資訊)的方式,簡言之,一個Windows Forms程式的幾乎所有代碼。雖然這看起來很完美,它使人產生一種虛假的安全感——所有的異常都被中央異常處理捕捉到了。由背景工作執行緒拋出的異常便是一個沒有被Application.ThreadException捕捉到的很好的例外。(在Main方法中的代碼,包括構造器的形式,在Windows資訊開始前先執行)
.NET framework為全域異常處理提供了一個更低層級的事件:AppDomain.UnhandledException,這個事件在任何類型的程式(有或沒有使用者介面)的任何線程有任何未處理的異常觸發。儘管它提供了好的不得已的異常處理解決機制,但是這不意味著這能保證程式不崩潰,也不意味著能取消.NET異常對話方塊。
在產品程式中,明確地使用異常處理在所有線程進入的方法中是必要的,可以使用封裝類和協助類來分解工作來完成任務,比如使用BackgroundWorker類(在第三部分進行討論)