深入 .NET ThreadPool 執行緒數量管理文章中,為了讓程式範例能跟 ThreadPool 緊密關聯,也避免失焦,我採用 ThreadPool.QueueUserWorkItem() 示範將作業拋到 ThreadPool 執行。這篇文章則將場景拉到時間拉到現代,看看 .NET Framework 4.5 乃至 .NET 6+,該怎麼用 ThreadPool 跑多工作業。

先說結論,.NET Framework 4.5 之後(含 .NET Core/.NET 6+),請用 Task.Run() 取代 ThreadPool.QueueUserWorkItem(),並善用 async/await 增進效能。而在非同步作業請避免使用 Thread.Sleep() (例如我以前為了同步化常寫成:while (remaining > 0) { Thread.Sleep(100); }),改用 await Task.Delay() 可減少 Thread 佔用,在一些情境能大幅改善效能,這點後面將會實測證明。

.NET 4 新增了 TPL (Task Parallel Library) 程式庫,其中 Task.Factory.StartNew() 性質與用法跟 ThreadPool.QueueUserWorkItem(),可以用一行 Task.Factory.StartNew(() => ... ) 將工作丟給 ThreadPool 多工執行。不過 StartNew() 有一堆進階選項,容易混淆或被誤用,因此 .NET 4.5 再新增 Task.Run(),如果只是想把工作丟到 Queue 裡由 ThreadPool 執行,用 Task.Run() 就對了。
延伸閱讀:Task.Run 與 Task.Factory.StartNew by m@cus

Task.Run() 的效果真的跟 ThreadPool.QueueUserWorkItem() 一樣嗎?實際跑看看就知,改寫上次文章的範例,改寫重點如下:

  1. 監看 Thread 數、待處理工作數變化原本用 while (stop) 檢查自訂布林值旗標決定何時結束,這裡改用 CancellationToken 控制,而 Task.Run() 時也可傳入 CancellationToken,若工作還在 Queue 排隊時就觸發取消,工作項目將不會執行
  2. 原本用 ThreadPool.QueueUserWorkItem() 排定工作,改為 Task.Run()
  3. Task.Run() 比 ThreadPool.QueueUserWorkItem() 更方便之處在於:排定工作後可得到一個 Task 物件,可用於掌握每件工作的執行狀態,故等待所有工作完成可簡化為 Task.WaitAll(Task集合)
  4. 待所有工作完成,呼叫 CancellationTokenSource.Cancel(),中斷監看迴圈

完整程式如下:

ThreadPool.GetMinThreads(out int minWorkerThreads, out int minCompletionPortThreads);
ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletionPortThreads);
Console.WriteLine($"ThreadPool Min: {minWorkerThreads} {minCompletionPortThreads}");
Console.WriteLine($"ThreadPool Max: {maxWorkerThreads} {maxCompletionPortThreads}");

// 100 件工作,每件工作耗時 10 秒
const int totalCount = 100;
int remaining = totalCount;
int running = 0;

var startTime = DateTime.Now;
var cts = new CancellationTokenSource();
// 監看 Thread 數、待處理工作數變化
Task.Run(() =>
    {
        Console.WriteLine("Time | Threads | Running | Pending ");
        Console.WriteLine("-----+---------+---------+---------");
        // 檢查 CancellationTokenSource 是否已經被取消
        while (!cts.Token.IsCancellationRequested)
        {
            Console.WriteLine($"{(DateTime.Now - startTime).TotalSeconds,3:n0}s | {ThreadPool.ThreadCount,7} | {running,7} | {ThreadPool.PendingWorkItemCount,7}");
            Thread.Sleep(1000);
        }
    }, 
    cts.Token //允許還沒執行前取消(例如:還在 Queue 排隊時就取消)
);

var tasks = Enumerable.Range(1, totalCount).Select(i =>
    Task.Run(() =>
    {
        Interlocked.Increment(ref running);
        //等待 10 秒,先用傳統 Thread.Sleep()
        Thread.Sleep(10000); 
        Interlocked.Decrement(ref remaining);
        Interlocked.Decrement(ref running);
    })).ToArray();
// 跟 QueueUserWorkItem 不同的是,Task.Run() 傳回 Task 物件,可掌握其執行狀態
// 不必監看處理數字,改用 Task.WaitAll() 等待所有 Task 完成
Task.WaitAll(tasks);

// 如要觀察 Thread 減少,可多等 30 秒
//Thread.Sleep(30000);

// 等待所有 Task 完成後,停止 Thread 數、待處理工作數的監看迴圈
cts.Cancel();

由測試結果,我們觀察到如上次 QueueUserWorkItem() 測試相似的 Thread 數變化,由 12 條開始,每秒 1 ~ 2 條逐步增加上升,10 秒後開始有工作完成,增加速度趨緩;32 秒時 Queue 已清空,Thread 數不再增加:

以上測試驗證 Task.Run() 能完全取代 ThreadPool.QueueUserWorkItem() 利用 ThreadPool 執行大量作業。再來談談,為何該用 await Task.Delay() 取代 Thread.Sleep()?

.NET 4.5 開始,非同步作業幾乎已是標配,官方範例程式到處都是 async、await。在 ASP.NET async 基本心法一文有提到:

非同步(Asynchronous)不在於提高效能(Performance),而是增加產能(Throughput)
非同步追求的是在相同時間內處理更多請求,而非以更快的速度處理掉一個請求。總體來看,同樣的請求量在更短時間內做完,說成「效能變好」也不算錯,但要記住,非同步的核心精神在於減少等待,讓執行緒同時處理更多作業藉以提升產能。

以下我們就來看看「用 await Task.Delay() 取代 Thread.Sleep()」會有什麼改變?何謂「讓執行緒同時處理更多作業藉以提升產能」?

稍稍改寫 Task.Run(),將 Lambda Statement 加上 async 改為非同步執行,Thread.Sleep(10000) 則改成 await Task.Delay(10000),看看有什麼效果?

var tasks = Enumerable.Range(1, totalCount).Select(i =>
    // 加上 async 改為非同步執行
    Task.Run(async () =>
    {
        Interlocked.Increment(ref running);
        //Thread.Sleep(10000); 
        //改用 Task.Delay()
        await Task.Delay(10000);
        Interlocked.Decrement(ref remaining);
        Interlocked.Decrement(ref running);
    })).ToArray();

神奇的事發生了! 我們只用了 12 條 Thread 就能同時跑 100 件工作,只花 10 秒全部完成!! 在深入 .NET ThreadPool 執行緒數量管理一文已展示過用ThreadPool.SetMinThreads(100, 1)開足 Thread 便能一次執行全部工作,但這次只用了 12 條就達到相同效果,這就是 async 心法一文所說的「讓執行緒同時處理更多作業藉以提升產能」。

關鍵在於 Task.Delay(10000) 與 Thread.Sleep(10000) 都是等待 10 秒再繼續,Task.Delay() 會先放 Thread 自由,讓它去處理其他工作,時間到了再由原 Thread 或 ThreadPool 的其他 Thread (由 SynchronizationContext 決定,延伸閱讀) 繼續執行後面程式;而 Thread.Sleep() 則會持續佔用該 Thread 直到等待時間結束,等同有一條 Thread 原地空等 10 秒什麼都不能做,就是這一點差異產生截然不同的結果。

同理,若方法同時提供同步及非同步版本,改用 await ActionSync() 版本可在等待期間釋出 Thread,讓它處理其他工作,有助於善用主機資源,提高效率。對於在本機跑的小工具小程式,少佔用 Thread 根本看不出差別,但若是每秒數百上千人存取的 ASP.NET 網站,減少 Thread 佔用,相同的 Thread 數量能消化更多 HTTP 請求、服務更多人,Throughput 將明顯提升。

這些理論很久之前我就在文件裡讀過了,實際見證過它的神效,未來為了改用 async/await 程式碼從頭改到尾時 (async 具有病毒式的感染性 - 一沾到 async,上下游都要跟著改,延伸閱讀:ASP.NET Core 練習 - 非同步化),我應該會更心甘情願一些。

This article demostrating how to use Task.Run() run tasks in .NET 4.5+ and how async/await can use threads more efficiently and improve throughput.


Comments

Be the first to post a comment

Post a comment