1. 程式人生 > >.NET面試題系列[17] - 多線程概念(2)

.NET面試題系列[17] - 多線程概念(2)

nbsp 靜態方法 工作方法 顯式 進程和線程 優先 輸入參數 間隔 聲明變量

線程概念

線程和進程的區別

  1. 進程是應用程序的一個實例要使用的資源的一個集合。進程通過虛擬內存地址空間進行隔離,確保各個進程之間不會相互影響。同一個進程中的各個線程之間共享進程擁有的所有資源。
  2. 線程是系統調度的基本單位。時間片和線程相關,和進程無關。
  3. 一個進程至少要擁有一個前臺線程。

線程開銷

當我們創建了一個線程後,線程裏面主要包括線程內核對象、線程環境塊、1M大小的用戶模式棧和內核模式棧。

  1. 線程內核對象:如果是內核模式構造的線程,則存在一個線程內核對象,包含一組對線程進行描述的屬性,以及線程上下文(包含了CPU寄存器中的數據,用於上下文切換)。
  2. 線程環境塊:用戶模式中分配和初始化的一個內存塊。
  3. 用戶模式棧:對於用戶模式構造的線程,應用程序可以直接和用戶模式棧溝通。
  4. 內核模式棧:如果是內核模式構造的線程進行上下文切換和其他操作時,需要調用操作系統的函數。此時需要使用內核模式棧向操作系統的函數傳遞參數。應用程序代碼無法直接訪問內核模式棧,它需要借助用戶模式的代碼。

線程有自己的線程棧,大小為1M,所以它可以維護自己的變量。線程是一個新的對象,它會增加系統上下文切換的次數,所以過多的線程將導致系統開銷很大。例如outlook會創建38個線程,但大部分時候他什麽都不做。所以我們白白浪費了38M的內存。

單核CPU一次只能做一件事,所以系統必須不停的進行上下文切換,且所有的線程(邏輯CPU)之間共享物理CPU。在某一時刻,系統只將一個線程分配給一個CPU。然後,該線程可以運行一個時間片(大約30毫秒),過了這段時間,就發生上下文切換到另一個線程。

假設某個應用程序的線程進入無限循環,系統會定期搶占他(不讓他再次運行)而允許新線程運行一會。如果新線程恰好是任務管理器的線程(此時將會發現任務管理器可以響應,而任務管理器之外屏幕其他地方則仍然無響應),則用戶可以利用任務管理器殺死包含了其他已經凍結的線程的進程。通過這種做法,上下文切換開銷並不會帶來任何性能增益,但換來了好得多的用戶體驗(很難死機,用戶可以用任務管理器殺死其他的進程)。

當某個線程一直空閑(例如一個開啟的記事本但長時間無輸入)時,他可以提前終止屬於他的時間片。線程也可以進入掛起狀態,此時之後任何時間片,都不會分配到這個線程,除非發生了某個事件(例如用戶進行了輸入)。節省出來的時間可以讓CPU調度其他線程,增強系統性能。

線程的狀態

可以用下圖表示:

技術分享

線程的主要狀態有四種:就緒(Unstarted),運行(Running),阻塞(WaitSleepJoin)和停止(Stopped),還有一種Aborted就是被殺死了。通常,強制獲得線程執行任務的結果,或者通過鎖等同步工具,會令線程進入阻塞狀態。當得到結果之後,線程就解除阻塞,回到就緒狀態。

當建立一個線程時,它的狀態為就緒。使用Start方法令線程進入運行狀態。此時線程就開始執行方法。如果沒有遇到任何問題,則線程執行完方法之後,就進入停止狀態。

阻塞(WaitSleepJoin),顧名思義,是使線程進入阻塞狀態。當一個線程被阻塞之後,它立刻用盡它的時間片(即使還有時間),然後CPU將永遠不會調度時間片給它直到它解除阻塞為止(在未來的多少毫秒內我不參與CPU競爭)。主要方式有:Thread.Join(其他線程都運行完了之後就解除阻塞),Thread.Sleep(時間到了就解除阻塞),Task.Result(得到結果了就解除阻塞),遭遇鎖而拿不到鎖的控制權(等到其他線程釋放鎖,自己拿到鎖,就解除阻塞)等。當然,自旋也是阻塞的一種。

Thread類中的方法對線程狀態的影響

Start:使線程從就緒狀態進入運行狀態

Sleep:使線程從運行狀態進入阻塞狀態,持續若幹時間,然後阻塞自動解除回到運行狀態

Join:使線程從運行狀態進入阻塞狀態,當其他線程都結束時阻塞解除

Interrupt:當線程被阻塞時,即使阻塞解除的要求還沒有達到,可以使用Interrupt方法強行喚醒線程使線程進入運行狀態。這將會引發一個異常。(例如休息10000秒的線程可以被立刻喚醒)

Abort:使用Abort方法可以強行殺死一個處於任何狀態的線程

時間片

當我們討論多任務時,我們指出操作系統為每個程序分配一定時間,然後中斷當前運行程序並允許另外一個程序執行。這並不完全準確。處理器實際上為進程分配時間。進程可以執行的時間被稱作“時間片”或者“限量”。時間片的間隔對程序員和任何非操作系統內核的程序來說都是變化莫測的。程序員不應該在他們的程序中將時間片的值假定為一個常量。每個操作系統和每個處理器都可能設定一個不同的時間。

進程和線程優先級

Windows是一個搶占式的操作系統。在搶占式操作系統中,較高優先級的進程總是搶占(preempt較低優先級的進程(即使時間片沒有用完)。用戶不能保證自己的線程一直運行,也不能阻止其他線程的運行。

每一個進程有一個優先級類,每一個線程有一個優先級(0-31)。較高優先級的進程中的較高優先級的線程獲得優先分配時間片的權利。

只要存在可以調度的高優先級的線程,系統就永遠不會將低優先級的現場分配給CPU,這種情況稱為饑餓。饑餓應該盡量避免,可以使用不同的調度方式,而不是僅僅看優先級的高低。在多處理器機器上饑餓發生的可能性較小些,因為這種機器上,高優先級的線程和低優先級的線程可以同時運行。

Thread類中的Priority允許用戶改變線程的優先級(但不是直接指定1-31之間的數字,而是指定幾個層級,每個層級最終mapping到數字,例如層級normal會映射到4)

前臺和後臺線程

一個進程可以有任意個前臺和後臺線程。前臺線程使得整個進程得以繼續下去。一個進程的所有前臺線程都結束了,進程也就結束了。當該進程的所有前臺線程終止時,CLR將強制終止該進程的所有後臺線程,這將會導致finally可能沒來得及執行(從而導致一些垃圾回收的問題)。解決的方法是使用join等待。例如你在main函數中設置了一個後臺線程,然後讓其運行,假設它將運行較長的時間,而此後main函數就沒有代碼了,那麽程序將立刻終止,因為main函數是後臺線程。

使用thread類創建的線程默認都是前臺線程。Thread的IsBackground類允許用戶將一個線程置為後臺線程。

多線程有什麽好處和壞處?

好處:

  1. 更大限度的利用CPU和其他計算機資源。
  2. 當一條線程凍結時,其他線程仍然可以運行。
  3. 在後臺執行長任務時,保持用戶界面良好的響應。
  4. 並行計算(僅當這麽做的好處大於對資源的損耗時)

壞處:

  1. 線程的創建和維護需要消耗計算機資源。(使用線程池,任務來抵消一部分損失)。一條線程至少需要耗費1M內存。
  2. 多個線程之間如果不同步,結果將會難以預料。(使用鎖和互斥)
  3. 線程的啟動和運行時間是不確定的,由系統進行調度,所以可能會造成資源爭用,同樣造成難以預料的結果。(使用鎖和互斥,或者進行原子操作)

為了避免2和3,需要開發者更精細的測試代碼,增加了開發時間。

System.Threading類的基本使用

創建線程

可以使用Thread的構造函數創建線程。我們要傳遞一個方法作為構造函數的參數。通常我們可以傳遞ThreadStart委托或者ParameterizedThreadStart委托。後者是一個可以傳遞輸入參數的委托。兩個委托都沒有返回值。ThreadStart委托的簽名是:public delegate void ThreadStart();

1 基本例子:通過Thread構造函數建立一個線程。傳遞的方法WriteY沒有返回值,也沒有輸入。之後使用Start方法使線程開始執行任務WriteY。

技術分享
class ThreadTest
{
  static void Main()
  {
    Thread t = new Thread (WriteY);          
    t.Start();                            
 
    for (int i = 0; i < 1000; i++) Console.Write ("x");
  }
 
  static void WriteY()
  {
    for (int i = 0; i < 1000; i++) Console.Write ("y");
  }
}
View Code

這個例子中,主線程和次線程同時訪問一個靜態方法(靜態方法是類級別的)。此時系統調度使得主線程和次線程輪流運行(但運行的順序是隨機的)。所以結果可能是

xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

2 主線程和次線程分別維護各自的局部變量

技術分享
static void Main()
{
  new Thread (Go).Start();    
  Go();                         
}
 
static void Go()
{
  // Declare and use a local variable - ‘cycles‘
  for (int cycles = 0; cycles < 5; cycles++) Console.Write (?);
}
View Code

次線程有自己的線程棧(大小1兆),所以主線程和次線程分別擁有各自的局部變量cycles。結果將是十個問號。這十個問號出自主線程和次線程,順序不定。

3 主線程和次線程分享全局變量

技術分享
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"); }
  }
}
View Code

變量done是全局的,被所有線程共享。此時,次線程開始任務,並在Go方法中將done設為真。最後只會打印一個done。

何時考慮創建一個線程?

  1. 當創建線程的代價比線程池要小(例如只打算創建一個線程時)
  2. 當希望自己管理線程的優先級時(線程池自動管理)
  3. 需要一個前臺線程(線程池創建的線程都是後臺的)

向次線程傳遞數據

1. 使用Lambda表達式。此時仍然使用的是ThreadStart委托。

技術分享
static void Main()
{
  Thread t = new Thread ( () => Print ("Hello from t!") );
  t.Start();
}
 
static void Print (string message) 
{
  Console.WriteLine (message);
}
View Code

2. 使用Thread的另一個構造函數傳入一個ParameterizedThreadStart委托

ParameterizedThreadStart委托的簽名是:public delegate void ParameterizedThreadStart (object obj);

所以它只能傳遞object類型的數據並且不能有返回值。

技術分享
static void Main()
{
  Thread t = new Thread (Print);
  t.Start ("Hello from t!");
}
 
static void Print (object messageObj)
{
  string message = (string) messageObj;   // We need to cast here
  Console.WriteLine (message);
}
View Code

捕獲變量問題

由於lambda表達式形成閉包,導致有機會出現捕獲變量。

技術分享
for (int i = 0; i < 10; i++)
  new Thread (() => Console.Write (i)).Start();
View Code

上例的捕獲變量:全世界只有一個i,所以被十條線程共用。

上面的代碼形成了閉包,導致i成為捕獲變量被十個匿名函數共享。出來的結果將是無法預料的。解決方法是在表達式內部聲明變量,這將是匿名函數自己的變量。(此時循環增加一次就有一個temp所以每個線程有自己的變量)

技術分享
for (int i = 0; i < 10; i++)
{
  int temp = i;
  new Thread (() => Console.Write (temp)). Start();
}
View Code

Join:阻塞的是呼叫的線程

封鎖呼叫的線程,直到其他線程結束為止。定義十分費解,看看例子。

例子1:Join阻塞的是呼叫的線程,在這個例子中呼叫的線程就是主線程。此時主線程將不會運行最後一行,直到次線程打印完了1000個y為止。

如果沒有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");
}
View Code

例子2:等待

技術分享
static void Main(string[] args)
        {
            Thread t1 = new Thread(PrintOne);
            Thread t2 = new Thread(PrintTwo);
            Thread t3 = new Thread(PrintThree);
            t1.Start();
            t2.Start();
            t2.Join(); //等待其他線程運行完畢(這裏只有t1需要等待)
            t1.Join();
            t3.Start();
            Console.ReadKey();
        }

        static void PrintOne()
        {
            Console.WriteLine("One");
        }
        static void PrintTwo()
        {
            Console.WriteLine("Two");
        }
        static void PrintThree()
        {
            Console.WriteLine("Three");
        }
View Code

將按順序打印One, Two, Three。t2.Join()阻塞呼叫的線程t2,於是等待t1運行完畢。T1.Join()則沒有要等待的線程。

Join可以設置一個timeout時間。

Sleep

讓線程停止一段時間。呼叫Sleep或Join將阻塞線程,系統將不會為其分配時間片,所以不會耗費系統性能。特別的,Sleep(0)會將線程現在的時間片立刻用盡(即使還有剩余的時間)。

線程池

線程池是由CLR自動管理的,包含若幹線程的集合。CLR利用線程池自動進行多線程中線程的創建,執行任務和銷毀。利用任務或委托,可以隱式的和線程池發生關聯。

線程池是如何管理線程的?

線程池的工作方法和普通的線程有所不同。他維護一個隊列QueueUserWorkItem,當程序想執行一個異步操作時,線程池將這個操作追加到隊列中,並派遣給一個線程池線程。線程池創建伊始是沒有線程的。如果線程池中沒有線程,就創建一個新線程。

相對於普通的使用Threading類創建線程,線程池的好處有:

  1. 線程池中創建的線程不會在執行任務之後銷毀,而是返回線程池等待下一個響應,這樣我們可以最大限度的重用線程。
  2. 線程池會盡量用最少的線程處理隊列中的所有請求,只有在隊列增加的速度超過了請求處理的速度之後,線程池才會考慮創建線程。
  3. 如果線程池中的線程空閑了一段時間,它會自己醒來終止自己以釋放資源。
  4. 當同時運行的線程超過閾值時,線程池將不會繼續開新的線程,而是等待現有的線程運行完畢。

線程池的缺點:

  1. 你不能為線程命名
  2. 線程池創建的線程一定是後臺線程

C#運用了線程池的類和操作有:

  1. 任務並行庫
  2. 委托
  3. BackgroundWorker

等等。

使用線程池:通過任務

我們可以通過創建一個任務來隱式的使用線程池:

技術分享
static void Main()    // The Task class is in System.Threading.Tasks
{
  Task.Factory.StartNew (Go);
}
 
static void Go()
{
  Console.WriteLine ("Hello from the thread pool!");
}
View Code

任務方法可以有返回值,我們可以通過訪問Task.Result(會阻塞)來得到這個返回值。當訪問時,如果任務執行中出現了異常,則我們可以將訪問Task.Result寫入try塊來捕捉異常。

使用線程池:顯式操作

我們可以通過顯式操作ThreadPool.QueueUserWorkItem隊列來操縱線程池,為它添加任務。我們還可以使用其的重載為任務指派輸入變量。

技術分享
static void Main()
{
  ThreadPool.QueueUserWorkItem (Go);
  ThreadPool.QueueUserWorkItem (Go, 123);
  Console.ReadLine();
}
 
static void Go (object data)   
{
  Console.WriteLine ("Hello from the thread pool! " + data);
}
View Code

和任務有所不同,ThreadPool.QueueUserWorkItem的方法無法有返回值。而且,必須在方法的內部進行異常處理,否則將會出現執行時異常。

使用線程池:異步委托

異步委托是一種解決ThreadPool.QueueUserWorkItem沒有返回值的方法。

技術分享
static void Main()
{
  Func<string, int> method = Work;
  IAsyncResult cookie = method.BeginInvoke ("test", null, null);
  //
  // ... here‘s where we can do other work in parallel...
  //
  int ret = method.EndInvoke (cookie);
  Console.WriteLine ("String length is: " + ret);
}
 
static int Work (string s) { return s.Length; }
View Code

異步調用一個方法也相當於給線程池派了一個新的任務。我們可以通過訪問method.EndInvoke來獲得訪問結果。

.NET面試題系列[17] - 多線程概念(2)