.NET 有個效能調校技巧是透過 ThreadPool.SetMinThreads() 設定 ThreadPool 的最小工作 Thread 數,這個做法為什麼能改善效能?何時有效?這篇文章會用實驗來理解與驗證。

當程式需要多工執行大量工作,除了自己弄 Queue 建執行緒,更簡便的方法使用 ThreadPool.QueueUserWorkItem()Parallel.For,開發者不必設計 Queue 容納待辦工作,不用煩惱該開幾條 Thread (開太少處理太慢、太多則 Context Switch 會拖累效能),交給 .NET 幫你聰明管理,盡可能善用資源實現多工處理。

ThreadPool 如何決定該開幾條工作 Thread?.NET 的做法是設定下限與上限 (可透過 ThreadPool.GetMinThreads() 及 ThreadPool.GetMaxThreads() 查詢),下限預設為 CPU Core 數(含 Hyperthreading),上限預設為 32767 條。程式開始執行時,ThreadPool 只準備最少的 12 條 Thread (我的 i5 是 12 核),當發現 Thread 不夠用,再以每秒一條的速度加入更多 Thread;遇到 ThreadPool 閒置時,則會減少 Thread 數量減少資源消耗。

那 ThreadPool 要怎麼決定何時該增加 Thread 數呢?規則比「如果大於就增加」的 if 邏輯複雜一些,依據官方文件:.NET Parallel Tasks / Thread Injection,.NET ThreadPool 使用兩個機制自動調節 Thread 數:

  • Starvation-Avoidance Mechanism - 當發現 Queue 中等待項目沒減少,則增加工作 Thread 數
    思路為「避免 Deadlock」,預防執行中工作互相等待對方的資源,加入新的工作 Thread 有助解決問題
  • Hill-Climbing Heuristic 爬山捷思法 - 設法用較少 Thread 達到最大 Throughput
    思路為「當 Thread 被 I/O 阻擋時改善 CPU 使用率」,由於無法判斷 Thread 在等待 I/O 或是執行吃 CPU 耗時工作,採用判斷標準是當 Queue 還有待辦工作且執行中工作持續一段時間(超過 0.5 秒)就觸發建立新工作 Thread

縮短每件執行作業的完成時間,有助於避開 Starvation 偵測,同時也能讓 ThreadPool 更頻繁調整 Thread 數量因應最新狀態。例如:假設有 500 件吃 CPU 的工作,每件平均需要 10 分鐘,實測可觀察到,工作 Thread 數會一路上升到 500 條。原因是 ThreadPool 發現等待 Queue 沒減少,判定它們都被 Block,將以每秒兩條左右的速度加入新的 Thread。

一次開 500 條 Thread 將帶來負面影響:一來是會耗用大量記憶體,二來 CPU 核數有限,頻繁 Thread 間切換,Context Switching 成本可觀(每次切換要花費 6,000 - 8,000 CPU 週期),新建 Thread 則需要 1MB 以上的堆疊記憶體空間、200,000 CPU 週期,這些都會讓效能不升反降。(延伸閱讀:從 ThreadPool 翻船談起)。

若每項作業耗時不到數分鐘,Hill-Climbing 演算法則會意識到 Thread 太多並逐步減少。

故要更有效率執行,可慮將大作業拆解成多個小作業,讓每次執行時間縮短,或改用自建工作排程,自己管理 Thread 。

看完運作原理,寫段程式來驗證。我安排了 200 個要執行一分鐘的作業(用 Thread.Sleep(60000) 模擬),使用 ThreadPool.QueueUserWorkItem() 交給 ThreadPool 處理。依上面說的理論,初期會因為作業沒結束,Queue 有東西,Thread 數穩定成長,待有工作完成時成長趨緩。

bool stop = false;

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}");

const int totalCount = 200;
int remaining = totalCount;
int running = 0;

var startTime = DateTime.Now;
Task.Factory.StartNew(() => {
    Console.WriteLine("Time | Threads | Running | Pending ");
    Console.WriteLine("-----+---------+---------+---------");
    while (!stop) {        
        Console.WriteLine($"{(DateTime.Now - startTime).TotalSeconds,3:n0}s | {ThreadPool.ThreadCount,7} | {running,7} | {ThreadPool.PendingWorkItemCount,7}");
        Thread.Sleep(1000);
    }
});

Enumerable.Range(1, totalCount).ToList().ForEach(i => {
    ThreadPool.QueueUserWorkItem((state) => {
        Interlocked.Increment(ref running);
        Thread.Sleep(60000);
        Interlocked.Decrement(ref remaining);
        Interlocked.Decrement(ref running);
    });
});

while (remaining > 0) {
    Thread.Sleep(100);
}
stop = true;

由實測結果,由執行時間、ThreadPool Thread 數、執行中作業數、Qeueue 等待數數據,我們可觀察到:

  1. 由於每項作業會佔用工作 Thread 60 秒才結束,符合 Queue 有待辦工作且無工作完成的 Starvation 偵測條件,因此從一開始 Thread 數以每秒鐘 1 ~ 2 條的速度新增 (符合文件說每秒兩條的增加速率)
  2. 滿 60 秒後,開始有工作完成,此時 Thread 增加速度趨緩
  3. 107 秒左右,Thread 數已緩緩累積到 122,此時 Queue 已空,Thread 數不再增加
  4. 大約 20 秒後,Thread 數開始減少
  5. Thread 減少的速度比增加快,最多一秒減少 12 條

實驗結果可印證前面說的理論。而由此我們也可推論:ThreadPool.SetMinThreads() 的最大用處在於縮短 ThreadPool 遞增 Thread 數到足夠數量的時間。若事先已預期可能一次湧入 200 件任務又希望儘快完成,可預先開好足夠的 Thread,省下一秒兩條慢慢提高產能緩不濟急的期間,降低作業在 Queue 中等待及總完成時間。而在網頁等待 Queue 長度有限的情境中,提高 ThreadPool 基本 Thread 數量,面對瞬間爆大量的情境,能改善 Thread 來不及增加導致 Queue 塞爆噴 503 的狀況。

最後用本案例示範,若我們將最小 Thread 數提升到 200 (在程式一開頭呼叫ThreadPool.SetMinThreads(200, 1);),全部處理完的時間預期可由 167 秒縮短到 60 秒多一點點:

再補充一點:設定 ThreadPool.SetMinThreads() 設 200 條工作 Thread 並不代表 ThreadPool 的數量永遠都在 200 條以上,閒置時 Thread 數仍會調節到 200 以下,待需求來臨一次回到 200 條。實證一下,一樣 200 件工作,等待時間縮短到 2 秒,每隔 30 秒塞 200 筆工作,看 ThreadPool 如何調控 Thread 數:

Action InsertQueue = () => {
    Enumerable.Range(1, totalCount).ToList().ForEach(i => {
        ThreadPool.QueueUserWorkItem((state) => {
            Interlocked.Increment(ref running);
            Thread.Sleep(2000);
            Interlocked.Decrement(ref remaining);
            Interlocked.Decrement(ref running);
        });
    });
};

bool done = false;
Task.Factory.StartNew(()=> {
    InsertQueue();
    Thread.Sleep(28000);
    InsertQueue();
    Thread.Sleep(28000);
    done = true;
});

while (!done || remaining > 0) {
    Thread.Sleep(100);
}
stop = true;

實測結果如下,200 件工作做完後約 20 秒,Thread 數由 202 降到 4,第 30 秒又來 200 件工作時,Thread 數瞬問回到 200 條,與預期相符,結案。

Introduce to the work thread management for .NET ThreadPool.


Comments

# by Anonymous

好文來簽到~~

Post a comment