C# 線程入門 00
內容預告:
- 線程入門(線程概念,創建線程)
- 同步基礎(同步本質,線程安全,線程中斷,線程狀態,同步上下文)
- 使用線程(後臺任務,線程池,讀寫鎖,異步代理,定時器,本地存儲)
- 高級話題(非阻塞線程,扶起和恢復)
概覽:
C#支持通過多線程並行地執行代碼,一個線程是獨立的執行個體,可以和其他線程同時運行。
CLR和操作系統會給C#程序開啟一個線程(主線程),可以被用來作為創建多線程的起點,例子:
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 } }
運行結果將是:
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
主線程創建了一個線程 t ,執行了重復輸出y的操作,主要線程執行了重復輸出x的操作。
CLR給每個線程分配了單獨的線程棧,所以本地變量是每個線程單獨保存的,下面的例子,我們用一個本地變量定義一個函數,然後在main函數和新的線程裏同時執行這個函數
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 (‘?‘); }
執行結果:
??????????
每個線程的內存棧裏都創建了一個單獨的變量cycle,所以輸出是10個?
如果是引用同一個對象的話,線程則共享這個數據:
因為兩個線程都調用Go(),它們共享done這個變量,所以done只輸出一次:
Done
static變量提供一種不同的方式在線程中共享變量,這裏是一個例子:
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的次序,
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) { Console.WriteLine ("Done"); done = true; } } }
done就有可能輸出兩次。因為在一個線程計算if表達式然後執行Console.WriteLine ("Done");的時候,另一個線程可能有機會在done的值改變之前先輸出done。
其實在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; } } } }
當兩個線程同時競爭一個鎖時,一個線程等待,或者說阻塞,直到鎖空出來。這主要是保證同時只能有一個線程可以進入臨界代碼區域,"Done"只會被輸出一次。
代碼是以這樣的方式被保護的,來自於多線程上下文的不確定性,叫做線程安全。
臨時地暫停,或阻塞,是線程同步的基本功能。
如果一個線程想要暫停,或者休眠一段時間,可以用:
Thread.Sleep (TimeSpan.FromSeconds (30)); // 阻塞30秒
一個線程可以通過調用Join等待另一個線程結束:
Thread t = new Thread (Go); // 假設Go是靜態函數。 t.Start(); Thread.Join (t); // 阻塞,只到線程t結束。
線程如何工作:
在內部,多線程是被線程調度器管理的,是CLR代替操作系統幹的活。線程調度器要保證所有活躍線程合理分配執行時間,以及在等待中的線程(這些線程是不消耗CPU時間的)。
在單核機器上,線程調度是以在活躍線程間快速切換時間片的方式工作的。就像就第一個例子,重復輸出x或y的線程輪換得到時間片。在Windows XP下,一個時間片就是幾十毫秒,這還是要比CPU在線程間切換能幹更多事,一次線程切換也就幾毫秒的事。
在多核機器上,多線程的實現是結合了時間片輪換和並發,並發是不同的線程同時運行在不同的CPU上,因為機器要運行的線程數遠遠大於CPU的數量,所以還需要時間片切換。
線程不能控制自己什麽時候執行,完全由操作系統的時間片切換機制來控制。
線程和進程:
總是有面試官喜歡把線程和進程做比較,其實兩者根本不是一個級別的東西。一個單獨的應用程序內所有的線程都在邏輯或屬於一個進程的。進程:一個運行應用程序的操作系統單元。線程與進程有些相似之處,比如:對於實例,進程和線程都是典型的時間片輪換的執行機制。關鍵的不同點在於進程間是相互獨立的,而同一個應用程序裏的線程間是共享堆內存的,這也是性能的用武之地:一個線程可以在後臺運行,另一個線程可以顯示得到的數據。
什麽時候應該用多線程:
- 一個普通的多線程程序在後臺運行耗時的任務時。主線程保持運行狀態,工作線程幹後臺的活。在Windows Form程序裏,如果主線程被長時間占用,鍵盤和鼠標的操作就不能處理了,然後程序就變成“無響應”了。所以,需要把耗時的任務放在後臺運行,讓主線程保證響應用戶輸入。
- 在非UI程序中,比如Windows服務,多線程就特別有用了,當等待另一臺機器(例如一個應用服務器,數據庫服務器,客戶端)的響應時,用一個工作線程來等待,讓主線程保持暢通。
- 多線程的另一個用處是在函數中有大量計算時,函數劃成多個線程可以在多核的機器上執行更快(可以用Environment.ProcessorCount得到CPU核心數量)。
- 一個C#程序可以通過兩種方式成為多線程:顯示地創建線程,或者使用.NET顯示創建線程的功能(比如BackgroundWorker,線程池,定時器,遠程服務器,WebSerivce或ASP.NET程序),在後面這些情況下,只能是多線程。單線程的web服務器肯定不行。在無狀態的web服務器裏,多線程是相當簡單的。主要的問題是如果處理緩存數據的鎖機制。
什麽時候不應該用多線程:
多線程也有缺點,最大的問題是會讓程序變得復雜,多線程本身並不復雜,復雜在於線程間的交互。能讓開發周期變長,以及Bug變多。所以需要把多個線程間的交互設計的盡量簡單,或者就別用多線程,除非你可以保證的很好。
過多地在線程間切換和分配內存棧,也會帶來CPU資源的消耗,通常,當硬盤IO很多時,只有一兩個線程依次執行任務的程序性能要更好,而多個性能同時執行一個任務的性能不怎麽樣。後面會討論生產者/消費者模型。
創建和啟動線程:
可以用Thread類的構造函數創建線程,傳遞一個ThreadStart的代理作為參數,這個代理指向將要執行的函數,以下是這個代理的定義:
public delegate void ThreadStart();
執行Start()函數,線程即開始運行,在函數結束後線程會返回,下面是創建ThreadStart的C#語法:
按 Ctrl+C 復制代碼 按 Ctrl+C 復制代碼線程t執行Go函數,同時主線程也調用Go,執行結果是:
hello! hello!
也可以用C#的語法糖:編譯器會自動創建一個ThreadStart的代理。
static void Main() { Thread t = new Thread (Go); // No need to explicitly use ThreadStart t.Start(); ... } static void Go() { ... }
還有更簡單的匿名函數語法:
static void Main() { Thread t = new Thread (delegate() { Console.WriteLine ("Hello!"); }); t.Start(); }
給ThreadStart傳遞參數:這種形式只能傳遞一個參數
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!"); }
結果:
hello! HELLO!
如果用匿名函數方式:可以傳遞多個參數,且也不需要類型轉換,
static void Main() { Thread t = new Thread (delegate() { WriteText ("Hello"); }); 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(); // Main thread – runs with upper=false } void Go() { Console.WriteLine (upper ? "HELLO!" : "hello!"); }
線程命名:線程有一個Name屬性,在調試時很有用。
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); } }
輸出:
Hello from main Hello from worker
前臺線程和後臺線程:
默認情況下,線程都是前臺線程,意味著任何一個前臺線程正在運行,程序就是運行的。而後臺線程在所有前臺線程終止時也會立即終止。
把線程從前臺改為後臺,線程在CPU調度器的優先級和狀態是不會改變的。
class PriorityTest { static void Main (string[] args) { Thread worker = new Thread (delegate() { Console.ReadLine(); }); if (args.Length > 0)
worker.IsBackground = true; worker.Start(); } }
如果這個程序執行時不帶參數,worker線程默認是前臺線程,並且會在ReadLine這一行等著用戶輸入。同時,主線程退出,但是程序會繼續運行,因為ReadLine也是前臺線程。如果傳了一個參數給Main函數,worker線程的狀態則被設置成後臺狀態,程序幾乎會在主線程結束時立即退出--終於ReadLine。當後臺線程以這種方式終止時,任何代碼都不再執行了,這種代碼是不推薦的,所以最好在程序退出前等待所有後臺線程,可以用超時時間(Thread.Join)來做。如果因為某些原因worker線程一直不結束,也能終止這個線程,這種情況下最好記錄一下日誌來分析什麽情況導致的。
在Windows Form中被拋棄的前臺線程是個潛在的危險,因為程序在主線程結束將要退出時,它還在繼續運行。在Windows的任務管理器裏,它在應用程序Tab裏會消失,但在進程Tab裏還在。除非用戶顯式地結束它。
常見的程序退出失敗的可能性就是忘記了前臺線程。
線程的優先級:線程的優先級決定了線程的執行時間。
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
線程的優先級為Highest時,並不意味著線程會實時運行,要想實時運行,進程的優先級也得是High。當你的進程的優先級是High時,如果程序進入了死循環,系統會死鎖。這個時候就只有按電源鍵了。所以,慎用。
最好將實時線程和UI分開在兩個線程,並設置成不同的優先級,通過遠程或共享內存通信,共享內存需要P/Invoking Win32 API(CreateFileMapping和MapViewOfFile)。
線程的異常處理:線程一旦啟動,任何在try/catch/finally範圍內創建線程的代碼塊與try/catch/finally就沒有什麽關系了。
public static void Main() { try { new Thread (Go).Start(); } catch (Exception ex) { // We‘ll never get here! Console.WriteLine ("Exception!"); } static void Go() { throw null; } }
上例中的try/catch基本沒用了,新創建的線程可能是未處理的空引用異常,最好在線程要執行的代碼裏加異常捕獲:
public static void Main() { new Thread (Go).Start(); } static void Go() { try { ... throw null; // this exception will get caught below ... } catch (Exception ex) { Typically log the exception, and/or signal another thread that we‘ve come unstuck ... }
從.NET2.0開始,線程上任何未處理的異常會導致整個程序掛掉,意味著千萬別忽略異常,在線程要執行的函數裏,給每個可能異常的代碼加上try/catch。這可能有點麻煩,所以,很多人這樣處理,用全局的異常處理:
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) { Log exception, then either exit the app or continue... } }
Application.ThreadException事件會在代碼拋出異常時被觸發,這樣看起來很完美--可以捕獲所有異常,但在worker線程上的異常可能捕獲不了,在main函數裏的窗體的構造函數,在Windows的消息循環之前就執行了。.NET提供了一個低層的事件捕獲全局異常:AppDomain.UnhandledException,它才可以捕獲所有異常(UI和非UI的)。雖然它提供了一個很好的方式捕獲所有異常並記錄異常日誌,但是它沒有辦法阻止程序關系,也沒有辦法阻止.NET的異常對話框
C# 線程入門 00