2019年9月1日 星期日

C# 執行緒 Thread / ThreadPool 的建立、執行、傳遞參數、結束、回傳值、取消、例外異常 之使用教學

C# 執行緒 Thread / ThreadPool 的建立、執行、傳遞參數、結束、回傳值、取消、例外異常 之使用教學

在這篇文章中,將會來說明如何在 .NET C# 開發環境之下(不論是在 .NET Framewor or .NET Core or Windows Form or WPF or Xamarin or ASP.NET or ASP.NET MVC or ASP.NET Core 下),能夠建立與使用 Thread 執行緒的操作過程教學文章。首先將會先建立兩個方法,這兩個方法分別是執行迴圈 500 次,分別在螢幕上輸出 * 與 呼叫端所傳入的 - 這兩個符號,程式碼如下所示,其中在 MyMethod2 這個方法中,將會接收一個型別為 object 的 message 參數,這裡要設定參數型別為 object,這是因為等下在建立執行緒物件的時候,要傳入一個執行緒要執行的委派方法,其參數型別必須是 object 型別,因此,最後會使用 Console.Write(message.ToString()); 敘述將這個參數物件值顯示在螢幕上。
C Sharp / C#
static void MyMethod1()
{
    for (int i = 0; i < 500; i++)
    {
        Console.Write("*");
    }
}
static void MyMethod2(object message)
{
    for (int i = 0; i < 500; i++)
    {
        Console.Write(message.ToString());
    }
}
接著,將會使用這兩個方法,開始說明與進行教學,讓大家了解到如何在 C# 程式語言內,操作與使用執行緒這個系統資源,進行非同步程式設計。
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

使用同步程式設計來呼叫這兩個方法

首先,將先使用同步程式設計方式,呼叫這兩個方法,在 使用同步方式呼叫() 方法內,將會看到會先執行 MyMethod1() 方法,接著在執行 MyMethod2() 方法。
C Sharp / C#
static void 使用同步方式呼叫()
{
    MyMethod1();
    MyMethod2("-");
}
由於這樣的程式碼是使用同步方式來設計與執行,因此,將會依照 使用同步方式呼叫() 方法內所撰寫的敘述逐一執行下去,所以,當執行這個程式碼的時後,將會看到螢幕輸出如同下面的內容。也就是說,在螢幕上會先看到有 500 個 * 字元出現,接著會有 500 - 字元出現,不過,因為這是採用同步程式設計,所以,這樣的執行結果將會滿足與符合當初設計上的需要。
********************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
請注意,在這裡先提出一個需求,若使用同步程式方法設計,當在呼叫這兩個方法的時候,要怎麼做到中止這兩個方法的繼續執行需求呢?這樣的需求在後面的使用執行緒 Thread 的非同步執行方式中,也會有著同樣的需求。

建立執行緒 - 使用 Thread 類別來建立一個執行緒物件與傳遞參數用法

若你的電腦具有多核心處理器或者具有多個處理器的設備,使用多執行緒的程式設計,可以讓你享受到不只是併行處理的多工好處,也會享受到可以改善執行效能的平行處理優點,因為在平行處理的環境下,多核心處理器或者多處理器的電腦,可以同時執行許多執行緒,也就是說,若該電腦總共具 8 顆邏輯處理器,那就代表這台電腦可以同時執行 8 個執行緒內的程式碼。
不過,請不要以為將你的程式碼切割成為可以在 8 個執行緒執行的程式碼,如此,就可以在有 8 個邏輯處理器的作業統下,提升 8 倍的執行速度
想要建立一個執行緒物件,必須要先產生一個 Thread 類別的執行個體,而當使用 new 運算子要產生一個 Thread 類別的執行個體,對於 Thread 類別的建構函式,需要提供一個 ThreadStart 委派型別的委派方法 或者 ParameterizedThreadStart 委派型別的委派方法;前者的委派型別宣告為 public delegate void ThreadStart() ,只要一個方法沒有回傳值與沒有任何參數使用,都可以作為建立該 Thread 類別物件的委派方法,後者的委派型別宣告為 public delegate void ParameterizedThreadStart(object obj) 也就是需要一個參數,其型別必須為 object 型別,這也就是為什麼對於剛剛定義的 MyMethod2 方法,需要宣告該方法需要傳送一個 object 的引數到該方法內。
在底下,將會看到使用 Thread thread1 = new Thread(MyMethod1);Thread thread2 = new Thread(MyMethod2); 敘述,分別建立兩個執行緒物件,在這裡要特別注意的是,當建立起一個 Thread 物件,該執行緒是在尚未啟動執行的狀態,若想要命令該執行緒開始執行,則需要取得該執行緒物件,呼叫該執行個體方法 Start(),就可以讓該執行緒開始執行。對於當初所建立的執行緒所呼叫的建構函式,所指派的委派方法,是屬於 ThreadStart 委派型別,可以直接呼叫 Start() 方法即可,可是,對於所指定的執行緒要使用的委派方法,也就是符合 ParameterizedThreadStart 委派型別,就需要使用 public void Start(object parameter); 這個多載方法,提供一個物件到 Start 方法引數內,這樣,所指定的委派方法,這裡是 MyMethod2 就會接收到這個參數值,當然,在這個方法 MyMethod2 就會把這個參數值顯示在螢幕上。
由於無法得知這兩個執行緒何時會都完成運算,所以,在這裡將會使用 Thread.Sleep(2000) 敘述,讓主執行緒暫時休息 2 秒鐘,等候這兩個執行緒執行完成;底下是自行建立執行緒物件並併行執行的結果內容。
請注意,若將敘述 Thread.Sleep(2000) 從 建立執行緒_使用Thread類別來建立一個執行緒物件() 方法內移除之後,請問會發生甚麼問題?另外,究竟要休息多久的時間,才是一個好的設計,例如,是要休息一秒鐘還是三秒鐘呢?
C Sharp / C#
private static void 建立執行緒_使用Thread類別來建立一個執行緒物件()
{
    Thread thread1 = new Thread(MyMethod1);
    Thread thread2 = new Thread(MyMethod2);
    thread1.Start();
    thread2.Start("-");

    Thread.Sleep(2000);
}
從執行結果可以看出與使用同步程式設計方式所得到的執行結果是不同的,在這裡可以看出執行緒1與執行緒2似乎每次僅會獲得 CPU 一小片段的執行時間,當該執行緒的CPU使用時間用完之後,將會強制退出暫時無法使用 CPU,作業系統會將 CPU 使用權力分配給其他的執行緒,不過,這個執行緒也是僅能夠執行一小片段的時間;因此,可以看出執行緒1與執行緒2是分別交錯的在運行中,由於每次使用 CPU 的時間都很短暫,而且馬上就可以分配到 CPU 使用權限,這樣的過程讓使用者覺得這兩個執行緒是同時在執行中,形成一個多工執行的樣貌。
***********---------------------------***********************************************************************************************************************************************************************************************************--------------------------------------------------------------------------------------------------------------------------------------------------------------****************************************************************************************************************************************************************************----------------------------------------------------------------------------------------------------------------------------------------------******************************************************************************************************************-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
這裡所提到的多執行緒的程式設計,是最基本的使用方式:建立一個執行緒物件,傳送一個委派方法,呼叫該執行緒物件的 Start 方法,如此,該執行緒就已經開始在系統中執行了;想要傳遞參數給該執行緒,請使用 public void Start(object parameter); 這個多載方法

透過 執行緒集區 ThreadPool 取得一個執行緒與傳遞參數用法

由於臨時要系統產生出一個執行緒物件出來,是相當耗費系統資源成本的,為了要讓執行緒物件可以達到最大可重複使用效率,避免過多的執行緒在系統中,造成其他的副作用影響,因此,有了執行緒集區這樣的解決方案。當 .NET 程式啟動之後,執行緒集區內就已經預設產生了許多執行緒,至於是有多少執行緒,這要看當時執行程式的電腦硬體上有多少個邏輯處理器而定。所以,有了執行緒集區之後,當在 .NET 程式中想要取得一個執行緒進行多工、非同步運算,就不再需要直接 new 一個執行緒執行個體出來,可以呼叫靜態方法 ThreadPool.QueueUserWorkItem 即可。
根據這個函式的宣告,有兩種使用方式,分別是 public static bool QueueUserWorkItem(WaitCallback callBack) 與 public static bool QueueUserWorkItem(WaitCallback callBack, object state)。前者適用於指定一個執行緒要執行的委派方法,後者則是同樣要指定一個執行緒要執行的委派方法,不過,可以指定一個物件要傳入到該執行緒委派方法內。而對於 WaitCallback 的委派型別宣告為 public delegate void WaitCallback(object state),因此,當要透過執行緒集區取得一個可用執行緒的時候,所傳送過去的委派方法,必須是一個具有可以讀取一個 object 型別參數的函式,因此,對於原先的 MyMethod1 因為沒有任何參數的定義,無法直接用於 ThreadPool.QueueUserWorkItem 方法內;在這裡將會轉個彎來使用這個方法,在底下的程式碼中,在呼叫 ThreadPool.QueueUserWorkItem 的時候,所提供的委派方法是一個 Lambda 匿名委派方法,在這個 Lambda 方法內會進一步的呼叫 MyMethod1,這樣就可以解決此一問題。
使用執行緒集區取得一個執行緒與使用 Thread 類別建立起一個執行緒物件最大的差異將會是,當透過執行緒集區取得的執行緒,該執行緒將會立即開始執行,而對於後者,使用 Thread 類別建立起來的物件,就需要呼叫該 Thread 物件的 Start() 方法,這樣,該執行緒才會開始執行起來。底下是使用執行緒集區取得兩個執行緒,螢幕輸出結果。
C Sharp / C#
private static void 透過執行緒集區ThreadPool取得一個執行緒與傳遞參數用法()
{
    // 要從執行緒集區內取得一個執行緒,需要提供一個
    //   public delegate void WaitCallback(object state);
    // 委派函式簽章,也就是該委派方法必須要有一個參數
    // 因此,在這裡透過 Lambda 匿名委派方法來呼叫 MyMethod1() 方法
    ThreadPool.QueueUserWorkItem(_ => MyMethod1());
    // 使用執行緒集區取得的執行緒,若要傳遞引數,請接著委派方法之後傳入
    ThreadPool.QueueUserWorkItem(MyMethod2, "-");

    Thread.Sleep(2000);
}
因為 MyMethod1 與 MyMethod2 分別在兩個執行緒內執行,因此,他們會同時在系統中執行,每個執行緒僅能夠使用一小片段的 CPU 時間 time slicing,將會形成這樣的執行結果,而且,重複執行幾次,將會看到每次執行的輸出結果都不會相同
*************----------------------------------------------------------------------------------------------------------------------------------------------------------------*****************************************************************************************************************************************************************************-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------*****************************************************************************************************************************************************************************-------------------------------------------------------------------------------------------------*********************************************************************************************************************************************--------------------------------------------------

當使用執行緒類別建立執行個體,如何等待該執行緒執行完成

在前面的程式碼中,因為無法確定執行緒需要多久的時間才能夠執行完畢,因此,會在啟動兩個執行緒執行之後,在主執行緒內休息兩秒鐘,才會結束這個應用程式的執行,可是,在正常的開發程式中是無法使用這樣的方式來進行程式碼的設計;因此,想要知道該執行緒是否已經成功執行完畢,需要使用 Thread.Join() 這個執行個體方法來進行封鎖 Block 等候,也就是說,當執行這個方法之後,若所等待的執行緒一時之間無法結束執行,呼叫 Join() 方法的執行緒也無法繼續往下執行,必須等到該執行執行完畢之後,才能夠繼續往下執行。
在底下的程式碼中,將會呼叫 thread1.Join();thread2.Join(); 方法來依序等候執行緒1與執行緒2執行完畢。
C Sharp / C#
private static void 當使用執行緒類別建立執行個體_如何等待該執行緒執行完成()
{
    Thread thread1 = new Thread(MyMethod1);
    Thread thread2 = new Thread(MyMethod2);
    thread1.Start();
    thread2.Start("-");

    thread1.Join();
    thread2.Join();
}

透過執行緒集區取得的執行緒,如何得知該執行緒已經結束執行了

對於透過執行緒集區來使用 ThreadPool.QueueUserWorkItem 取得一個可用執行緒,開始執行一個委派方法,這個 ThreadPool.QueueUserWorkItem 方法是不會回傳一個執行緒物件的,那麼,要如何得知這個從執行緒集區取得的執行緒,已經執行完成了呢?因為,無法回傳一個執行緒物件,就無法呼叫該執行緒執行個體的 Join() 方法。
對於從執行緒集區取得的執行緒,當然可以在該執行緒的委派方法內使用 Thread.CurrentThread 屬性取得當前的執行緒物件,可是,這並不是一個很好的做法,在此,將會展示使用 WaitHandle 類別來做到執行緒同步的需求;WaitHandle類別會封裝原生作業系統同步處理控制碼, 並用來代表執行時間中允許多個等候作業的所有同步物件,而 衍生自WaitHandle的類別包括:Mutex 類別、類別EventWaitHandle和其衍生的類別, AutoResetEvent 以及ManualResetEvent、Semaphore 類別。當然,也可以選擇其他的執行同步處理機制做到相同的效果。
首先,對於原先的 MyMethod1, MyMethod2 兩個委派方法,需要稍作修改,在這裡將會使用底下兩個新寫的兩個委派方法, MyMethod21, MyMethod22 這兩個方法,這兩個方法都會需要接收一個參數,這個參數將會是一個 AutoResetEvent 的物件,當在這個委派方法執行完畢之後,將會呼叫 AutoResetEvent 執行個體的 Set() 方法 AutoResetEvent.Set,該方法代表 將事件的狀態設定為收到信號,允許一個或多個等待中的執行緒繼續執行;經過這樣的設計,就可以透過 WaitHandle 得知是否已經有任何執行緒完成的訊號送出,以便判斷是否要繼續往下執行下去。
另外,在這裡將會把 MyMethod21 的迴圈設定為 800 次,模擬兩個方法需要花費不同的時間長度才會執行完畢
C Sharp / C#
static void MyMethod21(object state)
{
    AutoResetEvent are = (AutoResetEvent)state;
    for (int i = 0; i < 800; i++)
    {
        Console.Write("*");
    }
    are.Set();
}
static void MyMethod22(object state)
{
    AutoResetEvent are = (AutoResetEvent)state;
    for (int i = 0; i < 500; i++)
    {
        Console.Write("-");
    }
    are.Set();
}
另外,需要建立一個 AllWaitHandles 陣列集合物件,用來儲存 WaitHandle 的物件,這裡將會使用 AutoResetEvent 這個物件,當在產生這個物件的時候,在建構函式內傳入該訊號的預設值,這裡傳入 false,表示尚未發出任何訊號。
另外,當透過執行緒集區 ThreadPool.QueueUserWorkItem 取得兩個執行緒的時候,需要傳送 WaitHandle 物件到該執行緒的委派方法內,也就是 MyMethod21, MyMethod22 方法內,這樣,當執行緒分別執行這兩個方法的時候,一旦執行完成之後,將會使用 AutoResetEvent.Set 方法發出一個訊號,通知這個執行緒已經執行完成了。
而在主執行緒,使用了 WaitHandle.WaitAll(AllWaitHandles) 這個靜態方法來等候這兩個 AutoResetEvent 都已經執行過 Set 方法了,也就是說這兩個執行緒都已經執行完畢了,這樣,主執行緒就可以繼續往下執行下去。
C Sharp / C#
static WaitHandle[] AllWaitHandles = new WaitHandle[]
{
    new AutoResetEvent(false),new AutoResetEvent(false)
};
private static void 透過執行緒集區取得的執行緒_如何得知該執行緒已經結束執行了()
{
    ThreadPool.QueueUserWorkItem(MyMethod21, AllWaitHandles[0]);
    ThreadPool.QueueUserWorkItem(MyMethod22, AllWaitHandles[1]);
    WaitHandle.WaitAll(AllWaitHandles);
}
底下將會是該程式碼的執行結果。在此,將會看到當呼叫了 WaitHandle.WaitAll(AllWaitHandles) 方法之後,主執行緒進入封鎖 Block 狀態,必須等到兩個執行緒都執行完畢之後,才會繼續執行。
-----------*****************************************************************************************************************************************----------------------------------------------------------------------------------------------------------------------------------------------------------------------****************************************************************************************************************************************************************---------------------------------------------------------------------------------------------------------------------------------------------------------------------------******************************************************--------------------------------------------------------------------------------------------------------------------------------------------------------*****************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************

如何取得執行緒執行完成之後的執行結果回傳值

不論使用自己建立一個執行緒或者透過執行緒集區來取得一個執行緒,若想要得到該執行緒執行的回傳結果,不像同步程式設計那樣的簡單。
首先,要建立兩個新的委派方法, MyMethod23, MyMethod24 ;在這兩個方法內同樣的是使用 AutoResetEvent 物件來送出一個該執行緒結束執行的訊號,並且在送出訊號之前,將該執行緒的計算結果指定到一個靜態變數內,使用靜態變數的原因是因為無法很容易地在不同執行緒內存取不同物件值,當然,也可以使用其他的方式,例如,設計一個 callback 方法,當該執行緒執行完畢之後,透過 callback 方法,將執行完成的結果送出去。
在底下的程式碼中,可以看到在 MyMethod23 方法執行完成前,會將回傳的物件值設定到 Result1 靜態變數內,而在 MyMethod24 方法執行完成前,會將回傳的物件值設定到 Result2 靜態變數內,最後透過 AutoResetEvent.Set() 方法送出訊號,告知用戶端的執行緒,該執行緒已經執行完畢了。
C Sharp / C#
static void MyMethod23(object state)
{
    AutoResetEvent are = (AutoResetEvent)state;
    for (int i = 0; i < 800; i++)
    {
        Console.Write("*");
    }
    Result1 = 800;
    are.Set();
}
static void MyMethod24(object state)
{
    AutoResetEvent are = (AutoResetEvent)state;
    for (int i = 0; i < 500; i++)
    {
        Console.Write("-");
    }
    Result2 = 500;
    are.Set();
}
在用戶端這裡,使用執行緒集區來取得兩個執行緒,並且開始執行這兩個執行緒委派方法,之後使用 WaitHandle.WaitAll(AllWaitHandles); 方法來進行 Block 封鎖等候,當兩個執行緒執行完畢且都送出訊號之後,主執行緒後面的程式碼將會繼續執行,在這裡會把兩個執行緒的執行結果顯示在螢幕上。
C Sharp / C#
static int Result1, Result2;
private static void 如何取得執行緒執行完成之後的執行結果回傳值()
{
    ThreadPool.QueueUserWorkItem(MyMethod23, AllWaitHandles[0]);
    ThreadPool.QueueUserWorkItem(MyMethod24, AllWaitHandles[1]);
    WaitHandle.WaitAll(AllWaitHandles);
    Console.WriteLine();
    Console.WriteLine($"MyMethod23 的執行結果為 {Result1}");
    Console.WriteLine($"MyMethod24 的執行結果為 {Result2}");
}
底下是這個範例程式碼執行後的螢幕輸出內容。
*****---------------------------------------------------------------------------------------------------------------------------------------------------------******************************************************************************************************************************************************************----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------*************************************************************************************************************************************************************************************************---------------------------------------------------------------------------------------------------------------------------------------------------************************************************************************************************************************************************************************************----------------------********************************************************************************************************************************************************************************************************************************************************************
MyMethod23 的執行結果為 800
MyMethod24 的執行結果為 500

當使用執行緒類別建立執行個體,如何取消正在執行的執行緒

當想要取消一個正在執行的執行緒,相信很多人都會使用 Thread.Abort() 方法,這將會於被叫用的所在執行緒中引發 ThreadAbortException,開始處理執行緒的結束作業;當使用這個 Abort 方法的時候,可能會導致靜態的執行程式無法執行或防止非受控資源的釋放,在這裡來看看如何使用這樣的做法。
想要使用 Thread.Abort 方法,第一個先決條件當人是需要先取得該執行緒物件,才能夠呼叫該執行緒執行個體上的 Abort 方法,對於執行緒是透過建立一個 Thread 類別物件的作法,當然是可以取得這個執行緒物件,不過,對於執行緒集區取得的執行緒,就需要透過其他的方式來實作出取消執行緒執行的功能。
在此先建立兩個執行緒會用到的委派方法,在這裡將會建立一個無窮迴圈,列印出傳送進來的參數值,接著休息 30ms 的時間。
C Sharp / C#
static void MyMethod25(object message)
{
    while (true)
    {
        Console.Write("*");
        Thread.Sleep(30);
    }
}
static void MyMethod26(object message)
{
    while (true)
    {
        Console.Write("-");
        Thread.Sleep(30);
    }
}
在主執行緒,呼叫端,將會建立兩個執行緒物件,接著啟動執行這兩個執行緒,然後主執行緒先休息 1 秒鐘,對執行緒2物件呼叫 Abort() 方法,此時,執行緒2 將會終止執行,也就是螢幕上再也不會顯示出 - 這個字元;主執行緒接著休息 3 秒鐘,此時,螢幕上會持續只顯示 * 字元,在 3 秒鐘之後,會呼叫執行緒1 的 Abort() 方法,這樣,執行緒1也終止執行了,螢幕上再也顯示不出任何內容了。
C Sharp / C#
private static void 當使用執行緒類別建立執行個體_如何取消正在執行的執行緒()
{
    Thread thread1 = new Thread(MyMethod25);
    Thread thread2 = new Thread(MyMethod26);
    thread1.Start("*");
    thread2.Start("-");

    Thread.Sleep(1000);
    thread2.Abort();
    Thread.Sleep(3000);
    thread1.Abort();
}
底下是執行結果
*--*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-**************************************************************************************************

當執行緒內的程式碼拋出例外異常,會有甚麼問題產生

最後,在設計多執行緒非同步程式碼的時候,需要特別注意在執行緒內執行的委派方法內,是不能夠拋出任何例外異常的,因為,在任何執行緒下若有拋出任何例外異常(只有少數幾個是例外),將會造成這個應用程序終止執行;當發生這樣的問題時候,通常是最難進行除錯的時候,因為,想要抓出問題是在哪個執行緒下產生問題,不是這麼容易的。
現在,這裡僅會建立一個執行緒,在這個範例中,將會透過執行緒集區取得一個執行緒,該執行緒將會執行底下的 MyMethod27 委派方法,該方法將會顯示 @ 字元,當顯示到第 301 個字元的時候,將會拋出一個例外異常,在這裡將會使用 throw new Exception("喔喔,系統拋出例外異常") 來手動拋出例外異常。
C Sharp / C#
static void MyMethod27(object message)
{
    for (int i = 0; i < 1000; i++)
    {
        Console.Write(message.ToString());
        if (i == 300)
        {
            throw new Exception("喔喔,系統拋出例外異常");
        }
    }
}
在主執行緒端,會透過背景執行緒執行 MyMethod27 方法,緊接著休息 5 秒鐘,沒有多久時間,MyMethod27 將會拋出例外異常,這個時候,將會造成整個處理程序異常終止,而在主執行緒的任何程式碼將再也無法繼續執行了。
C Sharp / C#
private static void 當執行緒內的程式碼拋出例外異常_會有甚麼問題產生()
{
    ThreadPool.QueueUserWorkItem(MyMethod27, "@");
    Thread.Sleep(5000);
    Console.WriteLine("因為執行緒拋出例外異常,這行文字永遠無法顯示在螢幕上");
}
這裡是執行結果
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
未處理的例外狀況: System.Exception: 喔喔,系統拋出例外異常
   於 ThreadUsage.Program.MyMethod27(Object message) 於 D:\Vulcan\GitHub\CSharp2019\ThreadUsage\ThreadUsage\Program.cs: 行 179
   於 System.Threading.QueueUserWorkItemCallback.WaitCallback_Context(Object state)
   於 System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   於 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   於 System.Threading.QueueUserWorkItemCallback.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem()
   於 System.Threading.ThreadPoolWorkQueue.Dispatch()
   於 System.Threading._ThreadPoolWaitCallback.PerformWaitCallback()