2019年7月5日 星期五

為什麼需要使用非同步程式設計,真的可以提升整體應用程式的執行效能嗎?

為什麼需要使用非同步程式設計,真的可以提升整體應用程式的執行效能嗎?

大家對於為什麼需要使用非同步程式設計都存在著許多問題,難道真的使用非同步程式設計真的可以提升整體系統效能嗎?這個可以說是的,不過,這需要對於用戶呼叫端與提供服務的被呼叫端,最好都能夠全面的實作出具有使用非同步的設計,否則,將會有可能因為執行緒集區內因為預設的執行緒被長時間占用,有新的需求展生的時候,執行緒集區需要產生出新的背景執行緒,此時,將會造成這個系統會變成不好的執行效能,因為系統突然間會產生出大量額外的執行緒,這些執行的建立將會耗用額外的系統資源,每個新執行緒在建立的時候,需要額外的 1MB 的記憶體,而且建立這些記憶體所需要的各個執行緒需要用到的內容,也需要花費 CPU 的時間,另外,這些大量的執行緒也會造成作業系統存在著內容交換 Content Switch 的效能問題。
在這篇文章將會需要撰寫出使用同步方法的來呼叫遠端 Web API,這個 API 是使用 ASP.NET Core 所撰寫的,而且將會有兩個 Action,一個是使用同步方式執行的 Action,另外一個是採用非同步方式執行的 Action;而且在測試端將會使用 .NET Core 的專案建立起一個 Console 應用專案,執行一個 20 次的迴圈,呼叫同一個 API,該 API 將會需要 1.2 秒的時間才能夠得到執行結果。
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

後端的 Web API : 同步 Action

首先來看看在後端 ASP.NET Core 專案中的兩個 Web API (可以在這個 LargeWebConnectionAPIServer 專案中看到這裡描述的程式碼) Action ( AddSync ),在底下的程式碼中,將會使用同步等待的方式, Thread.Sleep 的方式,造成該 Http 請求可以將會造成停留指定 delay 的 ms 時間,而且在這裡也會看到這個 Action 的方法使用 public string AddSync(int value1, int value2, int delay) 這樣方式的宣告。
C Sharp / C#
[HttpGet("AddSync/{value1}/{value2}/{delay}")]
public string AddSync(int value1, int value2, int delay)
{
    DateTime Begin = DateTime.Now;
    int workerThreadsAvailable;
    int completionPortThreadsAvailable;
    int workerThreadsMax;
    int completionPortThreadsMax;
    int workerThreadsMin;
    int completionPortThreadsMin;
    ThreadPool.GetAvailableThreads(out workerThreadsAvailable, out completionPortThreadsAvailable);
    ThreadPool.GetMaxThreads(out workerThreadsMax, out completionPortThreadsMax);
    ThreadPool.GetMinThreads(out workerThreadsMin, out completionPortThreadsMin);
    Thread.Sleep(delay);
    var fooUrl = Url.Action("ResponAndAwait2");
    DateTime Complete = DateTime.Now;
    return $"AW:{workerThreadsAvailable} AC:{completionPortThreadsAvailable}" +
        $" MaxW:{workerThreadsMax} MaxC:{completionPortThreadsMax}" +
        $" MinW:{workerThreadsMin} MinC:{completionPortThreadsMin} ({Begin.TimeOfDay} - {Complete.TimeOfDay})";
}

後端的 Web API : 非同步 Action

接著,在這個後端 ASP.NET Core 專案內,還有另外一個 Action (AddAsync ),如同底下的程式碼中,將會採用非同步等待的方式, await Task.Delay 的方式,使用非同步的方式來造成該 Http 請求可以將會造成停留指定 delay 的 ms 時間,所以,當執行 await 敘述之後,將會立即將該執行緒歸還給執行緒集區,若等待的執行到了,將會從執行緒集區內取得一個新執行緒,繼續後續的工作;而且在這裡也會看到這個 Action 的方法使用 public async Task<string> AddAsync(int value1, int value2, int delay) 這樣方式的宣告。這個 AddAsync 與 AddSync 的運作方式將會表現出不同的執行效果。
等下,將會在用戶端(也就是在 .NET Core 專案內),就算使用的是非同步方式來執行遠端 Web API 的呼叫,若後端的 Action 沒有使用非同步方式的設計,對於有大量的需求發生的時候,例如,若有 100 或者 1000 個 HTTP Request 請求發生的話,將會造成效能表現出不好。
C Sharp / C#
[HttpGet("AddAsync/{value1}/{value2}/{delay}")]
public async Task<string> AddAsync(int value1, int value2, int delay)
{
    DateTime Begin = DateTime.Now;
    int workerThreadsAvailable;
    int completionPortThreadsAvailable;
    int workerThreadsMax;
    int completionPortThreadsMax;
    int workerThreadsMin;
    int completionPortThreadsMin;
    ThreadPool.GetAvailableThreads(out workerThreadsAvailable, out completionPortThreadsAvailable);
    ThreadPool.GetMaxThreads(out workerThreadsMax, out completionPortThreadsMax);
    ThreadPool.GetMinThreads(out workerThreadsMin, out completionPortThreadsMin);
    await Task.Delay(delay);
    var fooUrl = Url.Action("ResponAndAwait2");
    DateTime Complete = DateTime.Now;
    return $"AW:{workerThreadsAvailable} AC:{completionPortThreadsAvailable}" +
        $" MaxW:{workerThreadsMax} MaxC:{completionPortThreadsMax}" +
        $" MinW:{workerThreadsMin} MinC:{completionPortThreadsMin} ({Begin.TimeOfDay} - {Complete.TimeOfDay})";
}
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

使用同步方式來進行大量的遠端 Web API 呼叫

在用戶端專案 (WhyNeedAsynchronous) 內,將會有 ConnectWebAPI() 這個測試方法,其程式碼如下所示,在這裡可以看到將會執行 MaxTasks 次的迴圈,每次迴圈執行的時候,將會產生一個 HttpClient 物件,並且使用 client.GetStringAsync(APIEndPoint).Result; 敘述進行遠端 Web API 呼叫,這個時候的 URL (APIEndPoint) 將會是 https://localhost:5001/api/values/AddSync/8/9/1200 ,這裡將會呼叫遠端 Web API 的同步 Action。
C Sharp / C#
public static void ConnectWebAPI()
{
    APIEndPoint = "https://localhost:5001/api/values/AddSync/8/9/1200";
    for (int i = 1; i <= MaxTasks; i++)
    {
        int idx = i;
        DateTime begin = DateTime.Now;
        Console.WriteLine($"Task{idx} Begin");
        HttpClient client = new HttpClient();
        string result = client.GetStringAsync(
            APIEndPoint).Result;
        DateTime complete = DateTime.Now;
        TimeSpan total = complete - begin;
        Console.WriteLine($"Task{idx} Completed ({total.TotalMilliseconds} ms)");
    }
}
這裡將會是執行結果,這裡將會執行 10 次的呼叫,也就是 MaxTasks 為 10
從底下的執行結果可以看出,由於用戶端這裡每呼叫一次 Web API,將需要等候這 Web API 執行結束之後,才能夠繼續執行下去;另外,在後端這個 Action 中,將會暫停 1.2 秒鐘的時間,並且這個測試 API 伺服器是在本地端,所以,可以看出除了第一個 API 呼叫,其他的呼叫幾乎 1.3 秒的時間,就會完成 API 的呼叫。
這10次的 API 呼叫,總共花費了 14.2 秒的時間
Task1 Begin
Task1 Completed (2796.4031 ms)
Task2 Begin
Task2 Completed (1267.8894 ms)
Task3 Begin
Task3 Completed (1275.1012 ms)
Task4 Begin
Task4 Completed (1283.1228 ms)
Task5 Begin
Task5 Completed (1259.7341 ms)
Task6 Begin
Task6 Completed (1264.8557 ms)
Task7 Begin
Task7 Completed (1257.8372 ms)
Task8 Begin
Task8 Completed (1257.6333 ms)
Task9 Begin
Task9 Completed (1267.7973 ms)
Task10 Begin
Task10 Completed (1271.3545 ms)
花費時間: 14208 ms

使用非同步方式來進行大量的遠端同步 Web API 呼叫

現在將原先使用同步方式呼叫遠端同步 Web API 的方法,改寫成為 public static async Task ConnectWebAPIAsync() ,如底下程式碼所示,在這裡將會使用 Task.Run 方法一次產生出 8 個 (請修正 static int MaxTasks = 8; 靜態變數為 8) 非同步工作,最後使用 await Task.WhenAll(tasks); 敘述來等候這些 8 個工作都完全結束執行,並且看看執行結果。
C Sharp / C#
public static async Task ConnectWebAPIAsync()
{
    APIEndPoint = "https://localhost:5001/api/values/AddSync/8/9/1200";
    List<Task> tasks = new List<Task>();

    for (int i = 1; i <= MaxTasks; i++)
    {
        int idx = i;
        tasks.Add(Task.Run(async () =>
        {
            DateTime begin = DateTime.Now;
            Console.WriteLine($"Task{idx} Begin");
            HttpClient client = new HttpClient();
            string result = await client.GetStringAsync(
                APIEndPoint);
            DateTime complete = DateTime.Now;
            TimeSpan total = complete - begin;
            Console.WriteLine($"Task{idx} Completed ({total.TotalMilliseconds} ms)");
        }));
    }

    await Task.WhenAll(tasks);
}
底下將會是同時啟動 8 個 Web API 的呼叫的執行結果,可以看出每個呼叫 Web API 將會花費掉 3.0 秒左右的時間,可是,因為採用非同步並行方式的執行,所以,全部8過程都執行完畢之後,將會需要 3.2 秒的時間,這就是非同步並行的好處。
Task1 Begin
Task4 Begin
Task7 Begin
Task3 Begin
Task8 Begin
Task5 Begin
Task2 Begin
Task6 Begin
Task8 Completed (2985.2111 ms)
Task2 Completed (2985.2325 ms)
Task1 Completed (2985.2408 ms)
Task3 Completed (2985.2731 ms)
Task7 Completed (3015.5964 ms)
Task5 Completed (3036.1804 ms)
Task4 Completed (3057.7806 ms)
Task6 Completed (3082.6267 ms)
花費時間: 3163 ms
你也可以自行測試看看,若同時啟動 4個 Web API 呼叫,整個過程的完成大約是 3.0 秒的時間。
Task1 Begin
Task2 Begin
Task4 Begin
Task3 Begin
Task3 Completed (2899.5366 ms)
Task4 Completed (2899.5434 ms)
Task2 Completed (2899.5468 ms)
Task1 Completed (2899.5584 ms)
花費時間: 2979 ms
現在,來做個測試,若同時啟動大量的非同步工作,在這裡將會啟動 20 個工作,那麼,你認為整個工作的完成大約需要多久的時間呢?底下是執行結果:
結果是整體大約需要 5.8 秒的時間才能夠完成,比起前面的測試,多花了許多時間,而且單一非同步工作耗用最多的時間約 5644.7863 ms,每個非同步工作的執行完成時間都不盡相同,似乎執行品質差太多了。
Task7 Begin
Task3 Begin
Task4 Begin
Task5 Begin
Task2 Begin
Task6 Begin
Task1 Begin
Task8 Begin
Task10 Begin
Task11 Begin
Task9 Begin
Task16 Begin
Task13 Begin
Task15 Begin
Task12 Begin
Task14 Begin
Task17 Begin
Task18 Begin
Task19 Begin
Task20 Begin
Task19 Completed (3048.035 ms)
Task7 Completed (4536.7249 ms)
Task4 Completed (4536.7274 ms)
Task5 Completed (4536.7361 ms)
Task9 Completed (3128.5916 ms)
Task11 Completed (3128.6298 ms)
Task14 Completed (3128.3731 ms)
Task6 Completed (4536.6977 ms)
Task1 Completed (4536.6914 ms)
Task12 Completed (3152.7945 ms)
Task8 Completed (4578.8109 ms)
Task2 Completed (4612.1355 ms)
Task10 Completed (3240.7134 ms)
Task16 Completed (3241.6604 ms)
Task17 Completed (3161.3379 ms)
Task13 Completed (3241.8153 ms)
Task18 Completed (3820.7351 ms)
Task20 Completed (4091.6754 ms)
Task3 Completed (5644.7863 ms)
Task15 Completed (4270.5756 ms)
花費時間: 5778 ms
若是設定為 100 個工作,這樣的實行結果約 18168 ms,底下是片段執行結果,從底下的執行結果可以看到幾乎每次呼叫 Web API 都需要約 15 秒以上的時間才能夠執行完成,而且,要等到 100 個非同步工作全部都完成,竟然需要 18 秒以上的時間。可是,在後端的 Web API ,不是每次都僅會停留 1.2 秒的時間,就會完成這次 HTTP Request 請求的呼叫,這裡竟然卻需要超過 15 秒以上的時間。
相信大家都有遇到這樣的情境,平常運作好好的後端 Web API 服務,突然間湧入了大量的 HTTP 請求,就會造成整體執行效能都全面的降低;可是,在用戶端這裡,不是有使用 Task.Run 來產生出大量的非同步工作,進行非同步的 Web API 呼叫,但是,似乎還是會造成效能嚴重延遲的缺陷。
原則上,這樣的問題將會發生在當要進行非同步呼叫的時候,必須用戶端與伺服器端都需要全面的實作出非同步作業,而且在非同步方法內呼叫的各類別庫 API 或者自己寫的 API,也都需要全面採用非同步方式的呼叫,這樣才會有效果。
.
.
.
Task88 Completed (15671.8551 ms)
Task81 Completed (15694.0615 ms)
Task80 Completed (15689.4539 ms)
Task82 Completed (15677.0617 ms)
Task78 Completed (15734.1956 ms)
Task84 Completed (15744.3717 ms)
Task96 Completed (15520.643 ms)
Task99 Completed (15540.1246 ms)
Task97 Completed (15691.7009 ms)
Task10 Completed (16636.2705 ms)
Task95 Completed (15691.5936 ms)
Task85 Completed (15827.9553 ms)
Task93 Completed (15731.722 ms)
Task45 Completed (16217.36 ms)
Task66 Completed (16009.9087 ms)
花費時間: 18168 ms
若此時使用同樣的用戶端呼叫程式碼,遠端的 Web API URL 改成佈署在 Azure 上的 Web 伺服器,此時,此時,同樣會產生 100 個非同步呼叫遠端 Web API,不過會呼叫遠端的同步 Web API,會得到這樣的結果:
在這個時候將會得到總共花費了 22 秒的時間才完成 100 個 API 呼叫
.
.
.
Task27 Completed (20247.6625 ms)
Task41 Completed (20240.1331 ms)
Task68 Completed (19881.8897 ms)
Task51 Completed (19995.4965 ms)
Task63 Completed (19999.0567 ms)
Task40 Completed (20270.6105 ms)
Task80 Completed (19810.503 ms)
Task39 Completed (20201.4586 ms)
Task44 Completed (20241.756 ms)
Task49 Completed (20182.8585 ms)
Task86 Completed (19773.1069 ms)
Task66 Completed (20001.9611 ms)
Task8 Completed (21899.3644 ms)
Task14 Completed (20485.2145 ms)
Task98 Completed (19729.4884 ms)
Task72 Completed (19983.916 ms)
Task73 Completed (20003.6746 ms)
Task92 Completed (19807.5306 ms)
花費時間: 22206 ms

使用非同步方式來進行大量的遠端非同步 Web API 呼叫

現在將用戶端要呼叫的 Web API,從 https://localhost:5001/api/values/AddSync/8/9/1200 改寫成為 https://localhost:5001/api/values/AddASync/8/9/1200 ,因此將建立起另外一個方法 public static async Task ConnectAsyncWebAPIAsync(),如底下程式碼所示,在這裡切換成為呼叫遠端非同步的 API,其他的程式碼將維持不變,如同上一個方法相同。
C Sharp / C#
public static async Task ConnectAsyncWebAPIAsync()
{
    APIEndPoint = "https://localhost:5001/api/values/AddASync/8/9/1200";
    List<Task> tasks = new List<Task>();

    for (int i = 1; i <= MaxTasks; i++)
    {
        int idx = i;
        tasks.Add(Task.Run(async () =>
        {
            DateTime begin = DateTime.Now;
            Console.WriteLine($"Task{idx} Begin");
            HttpClient client = new HttpClient();
            string result = await client.GetStringAsync(
                APIEndPoint);
            DateTime complete = DateTime.Now;
            TimeSpan total = complete - begin;
            Console.WriteLine($"Task{idx} Completed ({total.TotalMilliseconds} ms)");
        }));
    }

    await Task.WhenAll(tasks);
}
現在已經切換使用了後端的非同步 Action,所以,當啟動 100 非同步工作同時呼叫遠端的非同步API,全部 API 呼叫完成僅需 7780 ms 的時間,比起前一個測試,雖然也同時產生 100 非同步工作,但是呼叫的卻是遠端的同步 API,而全部完成 100 API 呼叫,將需要 18168 ms;所以前端與後端都全部使用非同步的 API,整體執行上快了 10 秒鐘的時間。
.
.
.
Task50 Completed (5148.6828 ms)
Task38 Completed (5360.4139 ms)
Task25 Completed (5587.0058 ms)
Task37 Completed (5434.9276 ms)
Task63 Completed (5197.6284 ms)
Task65 Completed (5211.5087 ms)
Task64 Completed (5308.2167 ms)
Task62 Completed (5322.1625 ms)
Task52 Completed (5470.9549 ms)
Task61 Completed (5403.1796 ms)
Task59 Completed (5465.9516 ms)
Task82 Completed (5298.9463 ms)
Task55 Completed (5599.2855 ms)
Task71 Completed (5514.2504 ms)
Task72 Completed (5543.5817 ms)
Task78 Completed (5544.1085 ms)
Task67 Completed (5633.3116 ms)
花費時間: 7780 ms
現在,同樣的使用遠端非同步 API 來呼叫,一樣的改為 Web API URL 改成佈署在 Azure 上的 Web 伺服器,此時,此時,同樣會產生 100 個非同步呼叫遠端非同步 Web API,會得到這樣的結果:
在這個時候將會得到總共花費了 5.2 秒的時間才完成 100 個 API 呼叫,可以看出雖然有大量的 HTTP 請求產生了,但是,卻不會造成要花費 22 秒才能夠完成的窘境。
.
.
.
Task57 Completed (3013.9469 ms)
Task1 Completed (4921.3941 ms)
Task38 Completed (3297.1535 ms)
Task19 Completed (3424.446 ms)
Task91 Completed (2711.7554 ms)
Task30 Completed (3401.6785 ms)
Task16 Completed (3613.8872 ms)
Task53 Completed (3172.1048 ms)
Task86 Completed (2924.069 ms)
Task15 Completed (3700.262 ms)
Task84 Completed (2964.108 ms)
Task76 Completed (3021.8329 ms)
Task59 Completed (3200.5844 ms)
Task6 Completed (5106.9847 ms)
花費時間: 5205 ms

其他參考

對於當使用了 HttpClient 工廠方法 Factory 來取得一個 HttpClient 執行個體 或者 單一靜態 HttpClient 物件 又或者 每次都會產生出一個新的 HttpClient 執行個體 做多次遠端 同步或者非同步 Web API 存取之效能比較,可以參考 分別使用 HttpClient Factory / 靜態 HttpClient / HttpClient 執行個體 做多次遠端 同步或者非同步 Web API 存取之效能比較 這篇文章






2019年7月4日 星期四

何謂 C# 執行緒集區 Thread Pool,以及其運作方式探討

何謂 C# 執行緒集區 Thread Pool,以及其運作方式探討

在使用 C# 來開發應用程式的時候,若想要能夠做到多樣工作可以並行或平行方式的來運作,這個時候就需要建起多個執行緒來完成這樣的需求。通常,都不會建議直接使用 Thread 類別來建立起一個新的執行緒,透過該執行緒來執行所指定的委派方法,而是採用透過 執行緒集區 TreadPool 來取得一個背景執行緒,透過該背景執行緒來執行要併行/平行執行的委派方法;最明顯的例子就是 Task.Run 這個產生出一個非同步工作的方法,就是透過執行緒集區來取得一個執行緒,在這個執行緒內來執行這個非同步的工作。
不過,執行緒集區也不是萬能的,他也會有使用上的限制與瓶頸,執行緒集區設計的目的不是要降低延遲性,而是要提供整體系統運作效能,程式設計師不要專注在執行緒上的管理,就可以使用到執行緒帶來的好處;那麼,什麼是 執行緒集區 Thead Pool 呢?
這篇文章所使用到的程式碼,可以從 GitHub 取得
執行緒集區 Thead Pool 是一個類別,可以透過該類別指派一個委派方法,讓這個方法在集區內的任一個執行緒內來執行;當執行完成之後,就會把這個執行緒歸還到執行緒集區內。因此,執行緒集區 Thead Pool 可以視為一個類似計程車行的觀念,在這個裡面擁有許多的計程車,當你需要計程車的時候,就可以打電話給 執行緒集區 Thead Pool,這樣,執行緒集區就會派送一台計程車給你,待送你到想要的地方;當你抵達到目的地之後,該計程車就會回到車行,等候下一個使用者來叫車。
不過,執行緒集區內究竟要準備多少個執行緒數量,才能夠滿足多工設計上的需求?這就有點像是到底要準備多少台計程車,才能夠滿足客戶叫車的需求呢?說實在的,這沒有一定的標準,畢竟清晨的時候叫車的人可能會比較少,可是,上下班、下雨天的時候,就會有可能會有很多的人需要叫車;因此,當大家都要叫計程車的時候,可是當時的計程車都去載客人的時候,車行的電話就有可能一直在饗,就算接起電話,也只能和客人說對不起,因為,現在已經沒有車子可以派車了,畢竟一次準備太多的車子,若沒有人充分利用這些車子,也會造成成本的浪費。
那麼,在處理程序 Process 啟動執行的時候,執行緒集區究竟會產生多少預設的執行緒數量呢?答案是每台電腦所產生的數量都不進相同,原則上會產生出這台電腦上邏輯處理器相同數量的執行緒數量,例如,在我這台電腦上有八顆邏輯處理器,因此,執行緒集區一起動的時候,將會建立起八個執行緒在該集區內。
要怎麼看出這台電腦有多少邏輯處理器呢?請使用工作管理這個程式,切換到 效能 標籤頁次,在該頁次右下方,將會看到這台電腦上總共有 4 核心的處理器,因為具有 Hyper-Threading 的技術,所以,總共會擁有 8 顆邏輯處理。此時,可以使用 Thread 這個類別內提供的 ThreadPool.GetMinThreads(Int32, Int32) 方法,查詢出該執行緒集區內預設會建立起多少個數量的執行緒。
從這個 GetMinThreads 方法中需要傳入兩個 out int 型別的參數,分別會得到 [執行緒集區視需要建立的背景工作執行緒最小數目] 與 [執行緒集區視需要建立的非同步 I/O 執行緒最小數目] 也就是分別會建立起 CPU Bound 與 I/O 會使用到的許多執行緒到執行緒集區中。
還有,當執行緒集區內的執行緒數量不夠用的時候,執行緒集區究竟可以產生出最多多少的執行緒到執行緒集區內呢?這個時候,就可以使用 ThreadPool.GetMaxThreads(Int32, Int32) 方法來進行查詢,一旦呼叫這個 API,則會得到兩個整數數值,分別表示了 執行緒集區中的背景工作執行緒最大數目 與 執行緒集區中的非同步 I/O 執行緒最大數目;這裡的數量並不表示執行緒集區內現在擁有的執行緒數量,而是當執行緒集區內的執行緒數量不夠用的時候,執行緒集區額外產生的執行緒數量,最大的上限值為多少。
現在可以執行這個範例程式將會看到底下的輸出結果,這表示該執行緒集區內在應用程式一啟動之後,就會產生出 8 個背景工作執行緒,也就是底下列出的 WorkItem Thread 那一列,與 8 個非同步 I/O 會用到的執行緒,也就是底下列出的 IOPC Thread 那一列資訊。從這台電腦的執行結果,因為該電腦有 8 顆邏輯處理器,因此,將會預設建立起 8 個背景工作會使用到的執行緒與 8 個非同步 I/O 會使用到的執行緒,而這兩類的執行緒分別最大可以產生到 32767 個執行緒數量
執行緒集區的相關設定資訊
邏輯處理器數目 : 8
WorkItem Thread : (Busy:0, Free:32767, Min:8, Max:32767)
IOPC Thread : (Busy:0, Free:1000, Min:8, Max:1000)
在底下的範例程式碼中,將會宣告兩個整數,並且分別呼叫了 GetAvailableThreads , GetMaxThreads , GetMinThreads 這三個方法,其中第一個方法 GetAvailableThreads 呼叫之後,將會得到了該執行緒內還有多少執行緒的數量可以用來產生出新的執行緒之用,因此,把 GetMaxThreads 呼叫這個方法所得到的整數值 減去 呼叫 GetAvailableThreads 方法所得到的數值,就可以知道現在執行緒集區內,已經產生出多少個新的額外執行緒數量了。
C Sharp / C#
int workerThreads;
int completionPortThreads;

// 傳回之執行緒集區的現在還可以容許使用多少的執行緒數量大小
ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads);
threadPoolInformation.AvailableWorkerThreads = workerThreads;
threadPoolInformation.AvailableCompletionPortThreads = completionPortThreads;

// 擷取可並行使用之執行緒集區的要求數目。 超過該數目的所有要求會繼續佇列,直到可以使用執行緒集區執行緒為止
ThreadPool.GetMaxThreads(out workerThreads, out completionPortThreads);
threadPoolInformation.MaxWorkerThreads = workerThreads;
threadPoolInformation.MaxCompletionPortThreads = completionPortThreads;

// 在切換至管理執行緒建立和解構的演算法之前,擷取執行緒集區隨著提出新要求,視需要建立的執行緒最小數目。
ThreadPool.GetMinThreads(out workerThreads, out completionPortThreads);
threadPoolInformation.MinWorkerThreads = workerThreads;
threadPoolInformation.MinCompletionPortThreads = completionPortThreads;

// 如果目前電腦包含多個處理器群組,則這個屬性會傳回可供 Common Language Runtime (CLR) 使用的邏輯處理器數目
threadPoolInformation.ProcessorCount= System.Environment.ProcessorCount;
在這篇文章中,將僅會針對非 非同步 I/O 類型,也就是背景工作執行緒來進行各種不同的測試與評估,了解到當執行緒集區遇到這樣的情境之後,將會發生甚麼樣的問題,已經日後該如何針對這樣的問題來進行處理呢?

執行緒集區運作方式的測試方法說明

在這裡個範例測試程式,將會使用 testLoop 來設定同時會有多少的背景工作執行緒會同時產生出了,這裡使用迴圈來使用 ThreadPool.QueueUserWorkItem 方法來建立起這些大量的背景執行緒,每次建立起一個背景執行緒,將會暫時休息 10 ms 時間,為的是讓輸出結果會比較整齊與漂亮些;在這些背景執行緒內,將會先取得該執行緒的受管理的執行緒 ID,接著,將使用 ShowCurrentThreadUsage 方法來顯示現在執行緒集區內的執行緒數量相關資訊,接著將會休息 1000 * backgroundExecuteTimes ms 時間 (這裡將會設定每個執行緒將會耗用 8 秒種的時間來模擬執行長時間的工作),這裡是用來模擬一個長時間等候非同步或者是大量運算的執行過程,也就是說該執行緒因為正在執行或者正在等候結果,所以,該執行緒是無法歸還給執行緒集區的;最後當這些處理程序都完成之後,將會再度顯示出執行緒集區內的相關資訊與準備要結束該執行緒的時間點。
透過這些資訊將會用來觀察當有不同數量的執行緒同時產生的時候,執行緒集區將會如何針對這些不同的執行情況來提供各種不同的服務;不過,在此先要了解,執行緒集區存在的目的使要降地直接使用執行緒的麻煩與不用去管理這些執行緒,也就是說不是要降低系統運作低延遲性,而是要提升整體執行緒的使用效率。

當要產生出低於執行緒集區預設建立的執行緒數量

在這台測試電腦上,執行緒集區將會預設建立起 8 個執行緒在該集區內,因此,請修正這個 testLoop 變數值介於 1~8 之前,要求測試程式能夠同時產生出這些數量的執行緒;一旦開始執行測試之後,因為執行緒集區內還有足夠的執行緒可以使用,因此每次呼叫 ThreadPool.QueueUserWorkItem 方法之後,會先將需求存放到執行緒集區內的 Queue 佇列內,若執行緒集區內有剩餘可用的執行緒,就可以取得這些執行緒來執行所指定委派方法。在這裡將會進行最大的可用執行緒測試,也就是同時要求 8 個執行緒(不過,若你的電腦內的執行緒集區預設僅有 4 個可用執行緒,請記得要修正 testLoop 的值為 4),請確認 testLoop 的變數值為 8,在此將會得到底下的執行結果
從底下的執行結果可以看出,執行緒[1] ~ [8] 分別於 09:34:49.0166567 ~ 09:34:49.0928839 = 0.076 秒的時間內,就從執行緒集區內獲得執行緒,並且開始執行,而這 8 個執行緒也成功地於 09:34:57.0179431 ~ 09:34:57.0939583 結束執行,也就是都花費了 8 秒的時間結束執行,可以看出這樣的執行效能相當的優異。
執行緒集區的相關設定資訊
邏輯處理器數目 : 8
WorkItem Thread : (Busy:0, Free:32767, Min:8, Max:32767)
IOPC Thread : (Busy:0, Free:1000, Min:8, Max:1000)

準備產生出 8 個執行緒
請按下任一按鍵,進行執行緒集區的使用模擬
 執行緒[1]開始: ID=4, time=09:34:49.0166567
   WorkItem Thread : (Busy:1, Free:32766, Min:8, Max:32767)
執行緒[2]開始: ID=5, time=09:34:49.0269256
   WorkItem Thread : (Busy:3, Free:32764, Min:8, Max:32767)
執行緒[3]開始: ID=6, time=09:34:49.0379935
   WorkItem Thread : (Busy:4, Free:32763, Min:8, Max:32767)
執行緒[4]開始: ID=7, time=09:34:49.0489234
   WorkItem Thread : (Busy:5, Free:32762, Min:8, Max:32767)
執行緒[5]開始: ID=8, time=09:34:49.0599068
   WorkItem Thread : (Busy:6, Free:32761, Min:8, Max:32767)
執行緒[6]開始: ID=9, time=09:34:49.0708600
   WorkItem Thread : (Busy:7, Free:32760, Min:8, Max:32767)
執行緒[7]開始: ID=10, time=09:34:49.0819126
   WorkItem Thread : (Busy:8, Free:32759, Min:8, Max:32767)
執行緒[8]開始: ID=11, time=09:34:49.0928839
   WorkItem Thread : (Busy:8, Free:32759, Min:8, Max:32767)
執行緒[1]結束: ID=4, time=09:34:57.0179431
   WorkItem Thread : (Busy:8, Free:32759, Min:8, Max:32767)
執行緒[2]結束: ID=5, time=09:34:57.0279241
   WorkItem Thread : (Busy:7, Free:32760, Min:8, Max:32767)
執行緒[3]結束: ID=6, time=09:34:57.0389303
   WorkItem Thread : (Busy:6, Free:32761, Min:8, Max:32767)
執行緒[4]結束: ID=7, time=09:34:57.0499451
   WorkItem Thread : (Busy:5, Free:32762, Min:8, Max:32767)
執行緒[5]結束: ID=8, time=09:34:57.0609511
   WorkItem Thread : (Busy:4, Free:32763, Min:8, Max:32767)
執行緒[6]結束: ID=9, time=09:34:57.0719209
   WorkItem Thread : (Busy:3, Free:32764, Min:8, Max:32767)
執行緒[7]結束: ID=10, time=09:34:57.0829550
   WorkItem Thread : (Busy:2, Free:32765, Min:8, Max:32767)
執行緒[8]結束: ID=11, time=09:34:57.0939583
   WorkItem Thread : (Busy:1, Free:32766, Min:8, Max:32767)

當要產生出高於執行緒集區預設建立的執行緒數量

現在要來模擬當有高於執行緒集區預設建立的執行緒數量情境之下,會發生甚麼樣的情況,在這裡會將 testLoop 變數值設定為 12 也就是說,若同時要求 12 個執行緒,執行緒集區內最多僅能夠提供出 8 個可用執行緒可以立即使用,那麼,對於另外 4 個需要執行緒要求,執行緒集區該如何處理呢?
從底下的執行結果可以看到 執行緒[1]~執行緒[8] 幾乎在相同的時間點 (09:45:22) 獲得了執行緒來執行所指定的委派方法,而 執行緒[9]~執行緒[12] 則會分別於 09:45:23.7050644 , 09:45:24.7040167 , 09:45:25.7051190 , 09:45:26.7052975 的時間點才會取得所需要的執行緒;會有這樣的結果這是因為當執行緒集區發現到可用的執行緒不夠用的時候,而且執行緒集區的佇列 Queue 內還有需求在等候執行緒的時候,將會每個 0.5 ~ 1 秒鐘 (這樣的時間點,將會視當時電腦硬體的情況與所使用的 CLR 版本而定,並不會固定不變),持續的產生出新的執行緒,這是因為原先的 8 個執行緒持續霸佔著執行緒,而沒有歸還給執行緒集區,而在執行緒集區內又不斷有新的要求執行緒集區能夠配置出執行緒出來,因此,將會產生出新執行緒出來。
可是,你一定會很納悶的,同一個時間點,在執行緒集區佇列內有 4 個需求在等待執行緒要求,而執行緒集區卻是慢慢的產生出新的執行緒出來,這是故意設計這樣的機制,為的是要避免產生 執行緒醒來風暴 thread pool wakeup storms 的問題。而當額外產生出來的執行緒執行完畢之後,將會歸還給執行緒集區,而執行緒集區也會觀察,若在一定時間內,若這些新產生出來的執行緒沒有被使用的話,將會歸還給系統,而原先執行緒集區所建立的預設 8 個執行緒,不論多久,也不會歸還給作業系統,除非這個處理程序結束執行。
執行緒集區的相關設定資訊
邏輯處理器數目 : 8
WorkItem Thread : (Busy:0, Free:32767, Min:8, Max:32767)
IOPC Thread : (Busy:0, Free:1000, Min:8, Max:1000)

準備產生出 12 個執行緒
請按下任一按鍵,進行執行緒集區的使用模擬
 執行緒[1]開始: ID=4, time=09:45:22.7045868
   WorkItem Thread : (Busy:1, Free:32766, Min:8, Max:32767)
執行緒[2]開始: ID=5, time=09:45:22.7139011
   WorkItem Thread : (Busy:3, Free:32764, Min:8, Max:32767)
執行緒[3]開始: ID=6, time=09:45:22.7248548
   WorkItem Thread : (Busy:4, Free:32763, Min:8, Max:32767)
執行緒[4]開始: ID=7, time=09:45:22.7358487
   WorkItem Thread : (Busy:5, Free:32762, Min:8, Max:32767)
執行緒[5]開始: ID=8, time=09:45:22.7468800
   WorkItem Thread : (Busy:6, Free:32761, Min:8, Max:32767)
執行緒[6]開始: ID=9, time=09:45:22.7578943
   WorkItem Thread : (Busy:7, Free:32760, Min:8, Max:32767)
執行緒[7]開始: ID=10, time=09:45:22.7689058
   WorkItem Thread : (Busy:7, Free:32760, Min:8, Max:32767)
執行緒[8]開始: ID=11, time=09:45:22.7798759
   WorkItem Thread : (Busy:8, Free:32759, Min:8, Max:32767)
執行緒[9]開始: ID=12, time=09:45:23.7050644
   WorkItem Thread : (Busy:9, Free:32758, Min:8, Max:32767)
執行緒[10]開始: ID=13, time=09:45:24.7040167
   WorkItem Thread : (Busy:10, Free:32757, Min:8, Max:32767)
執行緒[11]開始: ID=14, time=09:45:25.7051190
   WorkItem Thread : (Busy:11, Free:32756, Min:8, Max:32767)
執行緒[12]開始: ID=15, time=09:45:26.7052975
   WorkItem Thread : (Busy:12, Free:32755, Min:8, Max:32767)
執行緒[1]結束: ID=4, time=09:45:30.7067440
   WorkItem Thread : (Busy:12, Free:32755, Min:8, Max:32767)
執行緒[2]結束: ID=5, time=09:45:30.7147327
   WorkItem Thread : (Busy:11, Free:32756, Min:8, Max:32767)
執行緒[3]結束: ID=6, time=09:45:30.7257740
   WorkItem Thread : (Busy:10, Free:32757, Min:8, Max:32767)
執行緒[4]結束: ID=7, time=09:45:30.7367508
   WorkItem Thread : (Busy:9, Free:32758, Min:8, Max:32767)
執行緒[5]結束: ID=8, time=09:45:30.7477686
   WorkItem Thread : (Busy:8, Free:32759, Min:8, Max:32767)
執行緒[6]結束: ID=9, time=09:45:30.7587406
   WorkItem Thread : (Busy:7, Free:32760, Min:8, Max:32767)
執行緒[7]結束: ID=10, time=09:45:30.7697294
   WorkItem Thread : (Busy:6, Free:32761, Min:8, Max:32767)
執行緒[8]結束: ID=11, time=09:45:30.7807757
   WorkItem Thread : (Busy:5, Free:32762, Min:8, Max:32767)
執行緒[9]結束: ID=12, time=09:45:31.7057277
   WorkItem Thread : (Busy:4, Free:32763, Min:8, Max:32767)
執行緒[10]結束: ID=13, time=09:45:32.7048023
   WorkItem Thread : (Busy:3, Free:32764, Min:8, Max:32767)
執行緒[11]結束: ID=14, time=09:45:33.7057667
   WorkItem Thread : (Busy:2, Free:32765, Min:8, Max:32767)
執行緒[12]結束: ID=15, time=09:45:34.7077598
   WorkItem Thread : (Busy:1, Free:32766, Min:8, Max:32767)

當要產生出高於執行緒集區最大允許的執行緒數量

在這裡將會模擬一個情況,若執行緒集區已經產生出執行緒集區所允許的最大可用執行緒數量,此時,會發生甚麼情況,在這台電腦上,執行緒集區最多的執行緒數量為 32767 個,每次執行 ThreadPool.QueueUserWorkItem 並且取得一個執行緒之後,這個數值將會減一,這樣的結果可以從上一個執行範例結果中看到,一開始該執行緒集區內可以產生最大執行緒數量為 32767,而當 12 個執行緒都產生出來的時候,可用執行緒數量將會降低到 32755 個。
為了要能夠模擬這樣的情境,要產生出 32767 以上的執行緒數量,似乎有點麻煩,因為,在這裡將會使用 ThreadPool.SetMaxThreads(10, 10); 這個方法,來設定執行緒集區僅能夠最多產生出 10 個執行緒,若接下來執行緒佇列內有新的執行緒產生需求,這個時候,很無奈的,只能夠無限制地等待,直到有執行緒執行完畢之後,歸還給執行緒集區,這樣,在執行緒集區佇列內等候者,才能夠再度獲得執行緒。會有這樣結果,這是因為執行緒集區所產生的實行續數量已經到達設定上限,將不會持續無限制的產生出新的執行緒出來,而這樣的情境將稱之為 執行緒飢餓 thread starvation。
所以,請將該測試範例程式 ThreadPool.SetMaxThreads(10, 10); 敘述解除註解,來看看執行結果
從底下的執行結果可以看到 執行緒[1]~執行緒[8] 幾乎在相同的時間點 (10:12:01.9361442 ~ 10:12:02.0111697) 獲得了執行緒來執行所指定的委派方法,而 執行緒[9]~執行緒[10] 則會分別於 10:12:02.0011936 , 10:12:03.9363546 的時間點才會取得所需要的執行緒,這樣的執行結果原則上符合上面測試結果,不過,對於 執行緒[11] 與 執行緒[12] 則會在 10:12:09.9479320 與 10:12:09.9587256 這兩個時間點才會獲得所需要的執行緒,比起前面的 10 個執行緒,幾乎慢了 8 秒鐘的時間還能夠取得所需要的執行緒。對於這樣的情境大家一定不陌上,那就是當有大量需求要連線到 IIS 主機上的時候,因為每個連線都會需要耗用到一個執行緒來處理,若該連線處理的時間要很久,將會導致這台 IIS 主機上的執行緒集區,因為不斷有新的要求加入到執行緒佇列內,而執行緒集區一直不斷的也為了滿足需求而建立起新的執行緒,最後的解果就是已經抵達執行緒集區所設定的最大上限數量,結果,就是後面來的要求將會還是停留在執行緒佇列內,痴痴等待執行緒集區內是否有執行緒已經執行完畢,才能夠獲得此執行緒才能夠執行相關程式碼。
執行緒集區的相關設定資訊
邏輯處理器數目 : 8
WorkItem Thread : (Busy:0, Free:10, Min:8, Max:10)
IOPC Thread : (Busy:0, Free:10, Min:8, Max:10)

準備產生出 12 個執行緒
請按下任一按鍵,進行執行緒集區的使用模擬
 執行緒[1]開始: ID=4, time=10:12:01.9361442
   WorkItem Thread : (Busy:1, Free:9, Min:8, Max:10)
執行緒[2]開始: ID=5, time=10:12:01.9461668
   WorkItem Thread : (Busy:3, Free:7, Min:8, Max:10)
執行緒[3]開始: ID=6, time=10:12:01.9571457
   WorkItem Thread : (Busy:4, Free:6, Min:8, Max:10)
執行緒[4]開始: ID=7, time=10:12:01.9681630
   WorkItem Thread : (Busy:5, Free:5, Min:8, Max:10)
執行緒[5]開始: ID=8, time=10:12:01.9792659
   WorkItem Thread : (Busy:6, Free:4, Min:8, Max:10)
執行緒[6]開始: ID=9, time=10:12:01.9902236
   WorkItem Thread : (Busy:7, Free:3, Min:8, Max:10)
執行緒[7]開始: ID=10, time=10:12:02.0011936
   WorkItem Thread : (Busy:8, Free:2, Min:8, Max:10)
執行緒[8]開始: ID=11, time=10:12:02.0111697
   WorkItem Thread : (Busy:8, Free:2, Min:8, Max:10)
執行緒[9]開始: ID=12, time=10:12:02.9363821
   WorkItem Thread : (Busy:9, Free:1, Min:8, Max:10)
執行緒[10]開始: ID=13, time=10:12:03.9363546
   WorkItem Thread : (Busy:10, Free:0, Min:8, Max:10)
執行緒[1]結束: ID=4, time=10:12:09.9380808
   WorkItem Thread : (Busy:10, Free:0, Min:8, Max:10)
執行緒[2]結束: ID=5, time=10:12:09.9470285
   WorkItem Thread : (Busy:9, Free:1, Min:8, Max:10)
執行緒[11]開始: ID=5, time=10:12:09.9479320
   WorkItem Thread : (Busy:9, Free:1, Min:8, Max:10)
執行緒[3]結束: ID=6, time=10:12:09.9580669
   WorkItem Thread : (Busy:9, Free:1, Min:8, Max:10)
執行緒[12]開始: ID=6, time=10:12:09.9587256
   WorkItem Thread : (Busy:9, Free:1, Min:8, Max:10)
執行緒[4]結束: ID=7, time=10:12:09.9690581
   WorkItem Thread : (Busy:9, Free:1, Min:8, Max:10)
執行緒[5]結束: ID=8, time=10:12:09.9800361
   WorkItem Thread : (Busy:8, Free:2, Min:8, Max:10)
執行緒[6]結束: ID=9, time=10:12:09.9910460
   WorkItem Thread : (Busy:7, Free:3, Min:8, Max:10)
執行緒[7]結束: ID=10, time=10:12:10.0020466
   WorkItem Thread : (Busy:6, Free:4, Min:8, Max:10)
執行緒[8]結束: ID=11, time=10:12:10.0120523
   WorkItem Thread : (Busy:5, Free:5, Min:8, Max:10)
執行緒[9]結束: ID=12, time=10:12:10.9370610
   WorkItem Thread : (Busy:4, Free:6, Min:8, Max:10)
執行緒[10]結束: ID=13, time=10:12:11.9370721
   WorkItem Thread : (Busy:3, Free:7, Min:8, Max:10)
執行緒[11]結束: ID=5, time=10:12:17.9500835
   WorkItem Thread : (Busy:2, Free:8, Min:8, Max:10)
執行緒[12]結束: ID=6, time=10:12:17.9600530
   WorkItem Thread : (Busy:1, Free:9, Min:8, Max:10)

提升執行緒集區預設產生執行緒緒數量

很多人想說,要解決幾乎每一秒種才能夠獲得一個新的執行緒方法很簡單呀,當處理程序一啟動之後,就預設產生出大量的執行緒出來,有人要一個執行緒,不就馬上就可以得到一個執行緒嗎?其實,問題沒有這麼簡單來解決,若產生出預設大量的執行緒,每個執行緒將會耗用至少 1MB 的記憶體空間,而且大量的執行緒在系統中執行,也會造成系統上因為這些大量正在執行中的執行緒存在,而會耗費系統資源來處理內容交換 Content Switch 的問題。
不過,不管如何,還是來體驗看看這樣的設計效果,請先註解這個敘述 ThreadPool.SetMaxThreads(10, 10); ,並且解除 ThreadPool.SetMinThreads(12, 12); 這個敘述的註解。
請按下 Ctrl + F5 來執行這個測試程式,現在將會發現到執行緒集區在處理程序啟動的時候,就已經準備好 12 可用執行緒,因此,這個測試程式突然間要求 12 個執行緒,執行緒集區就可以馬上提供這些執行緒以便執行相關並行工作。
執行緒集區的相關設定資訊
邏輯處理器數目 : 8
WorkItem Thread : (Busy:0, Free:32767, Min:12, Max:32767)
IOPC Thread : (Busy:0, Free:1000, Min:12, Max:1000)

準備產生出 12 個執行緒
請按下任一按鍵,進行執行緒集區的使用模擬
 執行緒[1]開始: ID=4, time=10:29:55.1252398
   WorkItem Thread : (Busy:1, Free:32766, Min:12, Max:32767)
執行緒[2]開始: ID=5, time=10:29:55.1354606
   WorkItem Thread : (Busy:3, Free:32764, Min:12, Max:32767)
執行緒[3]開始: ID=6, time=10:29:55.1464298
   WorkItem Thread : (Busy:4, Free:32763, Min:12, Max:32767)
執行緒[4]開始: ID=7, time=10:29:55.1574602
   WorkItem Thread : (Busy:5, Free:32762, Min:12, Max:32767)
執行緒[5]開始: ID=8, time=10:29:55.1684434
   WorkItem Thread : (Busy:6, Free:32761, Min:12, Max:32767)
執行緒[6]開始: ID=9, time=10:29:55.1793864
   WorkItem Thread : (Busy:7, Free:32760, Min:12, Max:32767)
執行緒[7]開始: ID=10, time=10:29:55.1904425
   WorkItem Thread : (Busy:8, Free:32759, Min:12, Max:32767)
執行緒[8]開始: ID=11, time=10:29:55.2014103
   WorkItem Thread : (Busy:9, Free:32758, Min:12, Max:32767)
執行緒[9]開始: ID=12, time=10:29:55.2125127
   WorkItem Thread : (Busy:10, Free:32757, Min:12, Max:32767)
執行緒[10]開始: ID=13, time=10:29:55.2234850
   WorkItem Thread : (Busy:10, Free:32757, Min:12, Max:32767)
執行緒[11]開始: ID=14, time=10:29:55.2344311
   WorkItem Thread : (Busy:12, Free:32755, Min:12, Max:32767)
執行緒[12]開始: ID=15, time=10:29:55.2453406
   WorkItem Thread : (Busy:12, Free:32755, Min:12, Max:32767)
執行緒[1]結束: ID=4, time=10:30:03.1263397
   WorkItem Thread : (Busy:12, Free:32755, Min:12, Max:32767)
執行緒[2]結束: ID=5, time=10:30:03.1363742
   WorkItem Thread : (Busy:11, Free:32756, Min:12, Max:32767)
執行緒[3]結束: ID=6, time=10:30:03.1473493
   WorkItem Thread : (Busy:10, Free:32757, Min:12, Max:32767)
執行緒[4]結束: ID=7, time=10:30:03.1583797
   WorkItem Thread : (Busy:9, Free:32758, Min:12, Max:32767)
執行緒[5]結束: ID=8, time=10:30:03.1693653
   WorkItem Thread : (Busy:8, Free:32759, Min:12, Max:32767)
執行緒[6]結束: ID=9, time=10:30:03.1803739
   WorkItem Thread : (Busy:7, Free:32760, Min:12, Max:32767)
執行緒[7]結束: ID=10, time=10:30:03.1913785
   WorkItem Thread : (Busy:6, Free:32761, Min:12, Max:32767)
執行緒[8]結束: ID=11, time=10:30:03.2023760
   WorkItem Thread : (Busy:5, Free:32762, Min:12, Max:32767)
執行緒[9]結束: ID=12, time=10:30:03.2133287
   WorkItem Thread : (Busy:4, Free:32763, Min:12, Max:32767)
執行緒[10]結束: ID=13, time=10:30:03.2244469
   WorkItem Thread : (Busy:3, Free:32764, Min:12, Max:32767)
執行緒[11]結束: ID=14, time=10:30:03.2353793
   WorkItem Thread : (Busy:2, Free:32765, Min:12, Max:32767)
執行緒[12]結束: ID=15, time=10:30:03.2473591
   WorkItem Thread : (Busy:1, Free:32766, Min:12, Max:32767)



2019年7月1日 星期一

C# 非同步作業中執行緒集區使用到大量的執行緒的設計考量

C# 非同步作業中執行緒集區使用到大量的執行緒的設計考量

不論是直接使用執行緒或者透過執行緒集區取得執行緒,甚至透過 C# TPL 的 Task 工作物件來設計非同步的應用程式,在許多時候會有可能遇到突然間需要用到大量的執行緒來處理相關的作業需求,不過,卻會造成執行上產生許多問題,例如,執行上會變得更加緩慢等等。
在這篇文章中,將會模擬要執行 200 非同步的作業,這些非同步的作業內都是相同的,首先,顯示出現在的執行 ID 是多少,接著會模擬一個非同步作業,在這裡會使用 Thread.Sleep 方法來模擬休息 2 秒鐘,使用同步的方式來直接等候這個作業完成。最後,當所有的作業都完成後,將會計算出總共花費了多少時間以及這次計算過程總共使用到多少的執行緒集區內的背景執行緒。

了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式
了解更多關於 [Thread Class] 的使用方式
了解更多關於 [Task Class] 的使用方式




理論上,整個程式若使用同步方式來執行,將會花費 200 x 2 = 400 秒的時間,現在,將會使用執行緒集區的背景執行緒、Task.Run方法、Task.Run方法 並且增加執行緒集區的預設執行緒數量、Task.Factory.StartNew 並指定產生新的執行緒方式、與 await 等候非同步的工作方式,比較看看這些設計方式哪個比較好。
為了要能夠知道這些執行緒是否都已經正常結束,在這裡將會透過 CountdownEvent 同步處理原始物件,一開始執行,將會設定該 CountdownEvent 的數量為 200 (也就是要執行背景執行緒的數量,當所有的執行緒都已將建立完成後,將會透過 CountdownEvent.Wait() 來等候所有的執行緒執行完成,當計數到達零時收到訊號,CountdownEvent.Wait() 將會解除封鎖,繼續執行,此時,就會列印出總共花費時間與使用到多少的背景執行緒數量。
在這篇文章中的範例程式碼,可以透過 GitHub 取得

使用執行緒集區的背景執行緒

首先,將會透過迴圈,執行 200 工作,使用 ThreadPool.QueueUserWorkItem 方法,透過執行緒集區 Thread Pool 取得一個背景執行緒,用來執行所需要的作業,底下為此方法的執行結果:
執行緒集區中的背景工作執行緒最大數目 : 2047 / 執行緒集區中的非同步 I/O 執行緒最大數目 : 1000
需要建立的背景工作執行緒最小數目 : 8 / 需要建立的非同步 I/O 執行緒最小數目 : 8

此次使用到 29 個背景執行緒
此次共花費 00:00:23.0056464 時間
將會發現到雖然透過 ThreadPool 物件,送出了 200 要求背景執行緒的請求,可是,卻僅使用到 29 個執行緒來執行這個程式,這是因為這台電腦為四核心的 CPU,總共的邏輯處理器共有 8 個邏輯處理器,因此,在程式啟動的時候,將會預設在執行緒集區內建立 8 個可用的執行緒。
而這個 ThreadPool.QueueUserWorkItem 方法並不表示要立即啟動一個執行緒,來執行所指定的委派 delegate 方法,而是先將這個請求放到執行緒集區 Thread Pool 佇列 Queue 中;若 ThreadPool 發現到集區內有可用的執行緒,將會指派該執行緒來執行從佇列中取得的委派物件,並且開始執行該委派方法。
然而,現在執行緒集區內僅有最多 8 個可用執行緒可以使用,很快地就會被分派完了,並且每個執行緒將會需要 2 秒鐘的時間才會執行完畢,這個時候,對於其他的 196 在執行緒集區佇列內等候執行的委派物件該怎麼辦呢?這樣的情況,可以稱作為 執行緒執行緒飢餓 thread starvation
執行緒集區會動態的增加集區內可用的執行緒數量,智慧型的因應這樣的情況,不過,Thread Pool 並不是立即、馬上的產生出所有佇列內需要用到的執行緒數量,而是大約每 500ms 來產生出一個新的背景執行緒出來,直到把佇列等候者都消化完畢為止。會有這樣的設計,這是因為要避免 執行緒醒來風暴 thread wakeup storms 情況的產生。所以,在執行 Case1() 方法的時候,將會看到執行緒是陸陸續續的產生出來,而不是一次有 200 個執行緒來執行這個程式的需求。
C Sharp / C#
class Program
{
    public static int MaxThreads;
    static int MaxLoop = 200;
    static int SimulateTaskTime = 2000;
    static CountdownEvent  countdownEvent = new CountdownEvent(MaxLoop);
    static void Main(string[] args)
    {
        Case1();
    }

    private static void Case1()
    {
        int CurrentAvailableThreads = GetAvailableThreads();
        List<Task> allTask = new List<Task>();
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        for (int i = 0; i < MaxLoop; i++)
        {
            ThreadPool.QueueUserWorkItem(x =>
            {
                int tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
                if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
                Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}");
                Thread.Sleep(SimulateTaskTime);
                tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
                if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
                countdownEvent.Signal();
            });
        }

        countdownEvent.Wait();
        stopwatch.Stop();
        Console.WriteLine($"此次使用到 {MaxThreads} 個背景執行緒");
        Console.WriteLine($"此次共花費 {stopwatch.Elapsed} 時間");
        Console.WriteLine("Press any key for continuing...");
        Console.ReadKey();
    }

    public static int GetAvailableThreads()
    {
        int workerThreads;
        int completionPortThreads;
        ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads);
        return workerThreads;
    }
    public static void PrintThreadPoolInformation()
    {
        int workerThreads;
        int completionPortThreads;
        ThreadPool.GetMaxThreads(out workerThreads, out completionPortThreads);
        Console.WriteLine($"執行緒集區中的背景工作執行緒最大數目 : {workerThreads} / 執行緒集區中的非同步 I/O 執行緒最大數目 : { completionPortThreads}");
        ThreadPool.GetMinThreads(out workerThreads, out completionPortThreads);
        Console.WriteLine($"需要建立的背景工作執行緒最小數目 : {workerThreads} / 需要建立的非同步 I/O 執行緒最小數目 : { completionPortThreads}");
        Console.WriteLine($"");
    }
}

使用 Task.Run方法

在這個測試中,將會把測試過程寫在 Case2 方法內。
在這裡將會把剛剛用的 ThreadPool.QueueUserWorkItem 方法,改寫成為 Task.Run 方法,使用非同步工作的方式來執行同樣的需求,底下是執行結果
執行緒集區中的背景工作執行緒最大數目 : 2047 / 執行緒集區中的非同步 I/O 執行緒最大數目 : 1000
需要建立的背景工作執行緒最小數目 : 8 / 需要建立的非同步 I/O 執行緒最小數目 : 8

此次使用到 29 個背景執行緒
此次共花費 00:00:23.0241135 時間
你將會發現到 Case2 的方法與 Case1 的方法執行起來結果是差不多的,當然,這樣的結果一點都不意外,因為,當使用 Task.Run 方法來指派一個委派方法,其實會透過 執行緒集區 來取得一個背景執行緒,用來執行 Task.Run 所指定的委派方法,所以,整體執行過程與會遇到的問題,將會與使用 ThreadPool.QueueUserWorkItem 的情況下相同的。
但是要如何解決這樣的問題呢?因為理論上同時要讓 200 作業進行,這些作業都只要 2 秒鐘就會執行完畢,應該是只需要 2 秒種就會執行完成了,可是,剛剛的兩個測試,卻花費了 23 秒鐘的時間。
C Sharp / C#
private static void Case2()
{
    int CurrentAvailableThreads = GetAvailableThreads();
    List<Task> allTask = new List<Task>();
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    for (int i = 0; i < MaxLoop; i++)
    {
        allTask.Add(Task.Run(() =>
        {
            int tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
            if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
            Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}");
            Thread.Sleep(SimulateTaskTime);
            tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
            if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
            countdownEvent.Signal();
        }));
    }

    countdownEvent.Wait();
    stopwatch.Stop();

    PrintThreadPoolInformation();
    Console.WriteLine($"此次使用到 {MaxThreads} 個背景執行緒");
    Console.WriteLine($"此次共花費 {stopwatch.Elapsed} 時間");
    Console.WriteLine("Press any key for continuing...");
    Console.ReadKey();
}

使用 Task.Run方法 並且增加執行緒集區的預設執行緒數量

既然在這台測試電腦上,預設執行緒集區建立好的執行緒數量為 8 個,那麼,是否可以把它加大呢?(在此,先不考量將預設執行緒數量加大後所帶來的後遺症與副作用)這裡可以使用 ThreadPool.SetMinThreads(16, 16); 方法來增加執行緒集區內預設可用執行緒的最小數量,現在修正為 16 個預設執行緒,底下為執行結果:
執行緒集區中的背景工作執行緒最大數目 : 2047 / 執行緒集區中的非同步 I/O 執行緒最大數目 : 1000
需要建立的背景工作執行緒最小數目 : 16 / 需要建立的非同步 I/O 執行緒最小數目 : 16

此次使用到 32 個背景執行緒
此次共花費 00:00:18.0309260 時間
從執行結果中,可以看到當查詢 Thread Pool,確實已經有 16 個預設執行緒隨時可以使用,不過,這樣的執行結果卻發現到這使將會使用到做多 32 個執行緒,比起沒有設定 ThreadPool.SetMinThreads(16, 16); 之前多了三個執行緒,而且執行完成時間也降為 18 秒。
現在,再來把可用執行緒數量調整到 32 個可用執行緒,現在來看看會有甚麼結果產生。從底下的執行結果,發現到這次將會總共使用到 42 個執行緒,比起前一次又多了 10 個執行緒,而且,完成時間降低到 12 秒。
執行緒集區中的背景工作執行緒最大數目 : 2047 / 執行緒集區中的非同步 I/O 執行緒最大數目 : 1000
需要建立的背景工作執行緒最小數目 : 32 / 需要建立的非同步 I/O 執行緒最小數目 : 32

此次使用到 42 個背景執行緒
此次共花費 00:00:12.0712832 時間
那麼,若將可用執行緒數量調整成為 200 的話,會有甚麼執行結果呢?哇,採用預設 200 執行緒的方式,整體執行結果將僅需要 2.84 秒 就可以執行完成了,真是太神奇了。
執行緒集區中的背景工作執行緒最大數目 : 2047 / 執行緒集區中的非同步 I/O 執行緒最大數目 : 1000
需要建立的背景工作執行緒最小數目 : 200 / 需要建立的非同步 I/O 執行緒最小數目 : 200

此次使用到 200 個背景執行緒
此次共花費 00:00:02.8351719 時間
C Sharp / C#
private static void Case3()
{
    ThreadPool.SetMinThreads(16, 16);
    int CurrentAvailableThreads = GetAvailableThreads();
    List<Task> allTask = new List<Task>();
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    for (int i = 0; i < MaxLoop; i++)
    {
        allTask.Add(Task.Run(() =>
        {
            int tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
            if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
            Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}");
            Thread.Sleep(SimulateTaskTime);
            tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
            if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
            countdownEvent.Signal();
        }));
    }

    countdownEvent.Wait();
    stopwatch.Stop();

    PrintThreadPoolInformation();
    Console.WriteLine($"此次使用到 {MaxThreads} 個背景執行緒");
    Console.WriteLine($"此次共花費 {stopwatch.Elapsed} 時間");
    Console.WriteLine("Press any key for continuing...");
    Console.ReadKey();
}

使用 Task.Factory.StartNew 並指定產生新的執行緒方式

既然加大執行緒數量會有這麼大的明顯執行效能的改善,那麼,為什麼不把每一個 Task 都配備一個專屬執行緒,而不是透過 ThreadPool 內來取得,這樣不是會跑得更快嗎?在這裡將原先的 Task.Run 方法,修改使用 Task.Factory.StartNew 這個工廠方法來產生出一個 Task 物件,不過,在這裡將會加入了 TaskCreationOptions.LongRunning 參數,告知 StartNew 方法,幫這個工作產生一個獨立、專屬的背景執行緒。底下是這樣修改方式的執行結果:
執行緒集區中的背景工作執行緒最大數目 : 2047 / 執行緒集區中的非同步 I/O 執行緒最大數目 : 1000
需要建立的背景工作執行緒最小數目 : 8 / 需要建立的非同步 I/O 執行緒最小數目 : 8

此次使用到 0 個背景執行緒
此次共花費 00:00:03.1899241 時間
雖然看到了 [此次使用到 0 個背景執行緒] 文字,不用太緊張,這裡顯示的是量代表是有使用到執行緒集區內的執行緒數量,不過,在這裡將會是直接產生出一個背景執行緒來處理 Task 物件所需要的事情;另外,將會看到這樣的修正將會使用到約 3 秒鐘的時間就完成了所有 200 作業,可謂效能極其優異呀~~
C Sharp / C#
private static void Case4()
{
    int CurrentAvailableThreads = GetAvailableThreads();
    List<Task> allTask = new List<Task>();
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    for (int i = 0; i < MaxLoop; i++)
    {
        allTask.Add(Task.Factory.StartNew(() =>
        {
            int tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
            if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
            Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}");
            Thread.Sleep(SimulateTaskTime);
            tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
            if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
            countdownEvent.Signal();
        }, TaskCreationOptions.LongRunning));
    }

    countdownEvent.Wait();
    stopwatch.Stop();

    PrintThreadPoolInformation();
    Console.WriteLine($"此次使用到 {MaxThreads} 個背景執行緒");
    Console.WriteLine($"此次共花費 {stopwatch.Elapsed} 時間");
    Console.WriteLine("Press any key for continuing...");
    Console.ReadKey();
}

使用 await 等候非同步的工作

最好的解決方式那就是妥善使用執行緒集區內的執行緒,並且不要長期把佔執行緒集區的背景執行緒,當借用從執行緒集區內借用了一個執行緒,若需要一個等候長期時間的方法,那麼,就使用 awaite 關鍵字,當要等候一個非同步工作的時候,立即歸還該執行緒,當非同步工作完成之後,再從執行緒集區內取回一個執行緒,繼續來執行後續的工作。底下是將原先 Task.Run 內的委派方法,改寫成為使用 async await 的做法的執行結果:
執行緒集區中的背景工作執行緒最大數目 : 2047 / 執行緒集區中的非同步 I/O 執行緒最大數目 : 1000
需要建立的背景工作執行緒最小數目 : 8 / 需要建立的非同步 I/O 執行緒最小數目 : 8

此次使用到 9 個背景執行緒
此次共花費 00:00:02.2039664 時間
從執行結果中可以看到,這裡總共用到了最多 9 個執行緒,比起原先執行緒集區內的預設 8 個執行緒,僅僅多出一個執行緒。不過,整體執行時間將僅需要 2.2 秒鐘的時間。
還記得甚麼時候要將方法改寫成為一個非同步作業方法嗎?原則上,當一個方法執行時間超過了 50ms 時間以上,建議將這個方法改寫成為非同步作業處理方式,因此,在這個範例中,執行緒內需要花費 2 秒鐘的時間來使用同步方式等候執行結果,所以,把原先使用同步作業方法 Thread.Sleep 改寫成為非同步工作的 Task.Delay 方法,並且加上 await 來等候這個非同步工作;因此,當執行非同步工作的時候,因為該執行緒沒有任何事情可以繼續來處理 (因為要等候非同步工作完成之後,才能繼續處理) ,所以,使用 await 關鍵字,讓非同步工作使用非同步的方式來執行,並且立即 return,也就是把這個執行緒歸還給執行緒集區,當非同步工作完成之後,在從執行緒集區內取得一個執行緒,完成非同步工作後的方法。
那麼,為什麼這樣的設計方式會比較好呢?原因在於這個方法不會使用過多的執行緒數量來完成所需要的工作,因為,當有大量的執行緒產生的時候,將會造成記憶體需求增加 (每個執行緒預設需要 1MB 的記憶體空間),而且這些大量的執行緒,也會造成系統的內容交換 Content Switch 次數增加,當然,也會影響到該系統內的其他程式的運作與效能的降低。
C Sharp / C#
private static void Case5()
{
    int CurrentAvailableThreads = GetAvailableThreads();
    List<Task> allTask = new List<Task>();
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    for (int i = 0; i < MaxLoop; i++)
    {
        allTask.Add(Task.Run(async () =>
        {
            int tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
            if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
            Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}");
            await Task.Delay(SimulateTaskTime);
            tmpThreadCC = CurrentAvailableThreads - GetAvailableThreads();
            if (MaxThreads < tmpThreadCC) MaxThreads = tmpThreadCC;
            countdownEvent.Signal();
        }));
    }

    countdownEvent.Wait();
    stopwatch.Stop();

    PrintThreadPoolInformation();
    Console.WriteLine($"此次使用到 {MaxThreads} 個背景執行緒");
    Console.WriteLine($"此次共花費 {stopwatch.Elapsed} 時間");
    Console.WriteLine("Press any key for continuing...");
    Console.ReadKey();
}


了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式
了解更多關於 [Thread Class] 的使用方式
了解更多關於 [Task Class] 的使用方式