C#中的線程(一)入門


C#中的線程(一)入門 - wota - 博客園



公告


 文章系參考轉載,英文原文網址請參考:http://www.albahari.com/threading/

作者 Joseph Albahari,  翻譯 Swanky Wu

  本系列文章可以算是一本很出色的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
  }
}

p_w_picpath

   主線程創建了一個新線程“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 ('?');
}

p_w_picpath

   變量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; }
}

p_w_picpath

問題就是一個線程在判斷if塊的時候,正好另一個線程正在執行WriteLine語句&mdash;&mdash;在它將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 method
t.Start();
t.Join();                             // Wait (block) until thread t ends

一個線程,一旦被阻止,它就不再消耗CPU的資源了。

  線程是如何工作的

   線程被一個線程協調程序管理着&mdash;&mdash;一個CLR委託給操作系統的函數。線程協調程序確保將所有活動的線程被分配適當的執行時間;並且那些等待或阻止的線程&mdash;&mdash;比如說在排它鎖中、或在用戶輸入&mdash;&mdash;都是不消耗CPU時間的。

   在單核處理器的電腦中,線程協調程序完成一個時間片之後迅速地在活動的線程之間進行切換執行。這就導致&ldquo;波濤洶涌&rdquo;的行爲,例如在第一個例子,每次重複的X 或 Y 塊相當於分給線程的時間片。在Windows XP中時間片通常在10毫秒內選擇要比CPU開銷在處理線程切換的時候的消耗大的多。(即通常在幾微秒區間)

   在多核的電腦中,多線程被實現成混合時間片和真實的併發&mdash;&mdash;不同的線程在不同的CPU上運行。這幾乎可以肯定仍然會出現一些時間切片, 由於操作系統的需要服務自己的線程,以及一些其他的應用程序。

   線程由於外部因素(比如時間片)被中斷被稱爲被搶佔,在大多數情況下,一個線程方面在被搶佔的那一時那一刻就失去了對它的控制權。

   線程 vs. 進程

    屬於一個單一的應用程序的所有的線程邏輯上被包含在一個進程中,進程指一個應用程序所運行的操作系統單元。

    線程於進程有某些相似的地方:比如說進程通常以時間片方式與其它在電腦中運行的進程的方式與一個C#程序線程運行的方式大致相同。二者的關鍵區別在於進程彼此是完全隔絕的。線程與運行在相同程序其它線程共享(堆heap)內存,這就是線程爲何如此有用:一個線程可以在後臺讀取數據,而另一個線程可以在前臺展現已讀取的數據。

  何時使用多線程

    多線程程序一般被用來在後臺執行耗時的任務。主線程保持運行,並且工作線程做它的後臺工作。對於Windows Forms程序來說,如果主線程試圖執行冗長的操作,鍵盤和鼠標的操作會變的遲鈍,程序也會失去響應。由於這個原因,應該在工作線程中運行一個耗時任務時添加一個工作線程,即使在主線程上有一個有好的提示&ldquo;處理中...&rdquo;,以防止工作無法繼續。這就避免了程序出現由操作系統提示的&ldquo;沒有相應&rdquo;,來誘使用戶強制結束程序的進程而導致錯誤。模式對話框還允許實現&ldquo;取消&rdquo;功能,允許繼續接收事件,而實際的任務已被工作線程完成。BackgroundWorker恰好可以輔助完成這一功能。

   在沒有用戶界面的程序裏,比如說Windows Service, 多線程在當一個任務有潛在的耗時,因爲它在等待另臺電腦的響應(比如一個應用服務器,數據庫服務器,或者一個客戶端)的實現特別有意義。用工作線程完成任務意味着主線程可以立即做其它的事情。

   另一個多線程的用途是在方法中完成一個複雜的計算工作。這個方法會在多核的電腦上運行的更快,如果工作量被多個線程分開的話(使用Environment.ProcessorCount屬性來偵測處理芯片的數量)。

   一個C#程序稱爲多線程的可以通過2種方式:明確地創建和運行多線程,或者使用.NET framework的暗中使用了多線程的特性&mdash;&mdash;比如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被打印出來:

p_w_picpath

一個線程可以通過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!");
  }

p_w_picpath

  在整個例子中,編譯器自動推斷出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); }

p_w_picpath

  匿名方法打開了一種怪異的現象,當外部變量被後來的部分修改了值的時候,可能會透過外部變量進行無意的互動。有意的互動(通常通過字段)被認爲是足夠了!一旦線程開始運行了,外部變量最好被處理成只讀的&mdash;&mdash;除非有人願意使用適當的鎖。

  另一種較常見的方式是將對象實例的方法而不是靜態方法傳入到線程中,對象實例的屬性可以告訴線程要做什麼,如下列重寫了原來的例子:

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();        // 主線程&mdash;&mdash;運行 upper=false
  }
 
  void Go() { Console.WriteLine (upper ? "HELLO!" : "hello!"); }

  命名線程

  線程可以通過它的Name屬性進行命名,這非產有利於調試:可以用Console.WriteLine打印出線程的名字,Microsoft Visual Studio可以將線程的名字顯示在調試工具欄的位置上。線程的名字可以在被任何時間設置&mdash;&mdash;但只能設置一次,重命名會引發異常。

  程序的主線程也可以被命名,下面例子裏主線程通過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);
  }
}

p_w_picpath

  

  前臺和後臺線程

  線程默認爲前臺線程,這意味着任何前臺線程在運行都會保持程序存活。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任務管理器它將從應用程序欄消失不見,但卻可以在進程欄找到它。除非用戶找到並結束它,它將繼續消耗資源,並可能阻止一個新的實例的運行從開始或影響它的特性。

   對於程序失敗退出的普遍原因就是存在&ldquo;被忘記&rdquo;的前臺線程。

 

  線程優先級

  線程的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塊需要出現在每個線程進入的方法內,至少要在產品程序中應該如此。對於經常使用&ldquo;全局&rdquo;異常處理的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程序的幾乎所有代碼。雖然這看起來很完美,它使人產生一種虛假的安全感&mdash;&mdash;所有的異常都被中央異常處理捕捉到了。由工作線程拋出的異常便是一個沒有被Application.ThreadException捕捉到的很好的例外。(在Main方法中的代碼,包括構造器的形式,在Windows信息開始前先執行)

.NET framework爲全局異常處理提供了一個更低級別的事件:AppDomain.UnhandledException,這個事件在任何類型的程序(有或沒有用戶界面)的任何線程有任何未處理的異常觸發。儘管它提供了好的不得已的異常處理解決機制,但是這不意味着這能保證程序不崩潰,也不意味着能取消.NET異常對話框。

在產品程序中,明確地使用異常處理在所有線程進入的方法中是必要的,可以使用包裝類和幫助類來分解工作來完成任務,比如使用BackgroundWorker類(在第三部分進行討論)

posted on 2010-06-18 21:08 wota 閱讀(...) 評論(...)  編輯 收藏

刷新評論刷新頁面返回頂部

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章