3

深入 .NET ThreadPool 執行緒數量管理

 1 year ago
source link: https://blog.darkthread.net/blog/threadpool-thread-management/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

深入 .NET ThreadPool 執行緒數量管理-黑暗執行緒

.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 條

Fig1_638180255257091573.png

Fig2_638180255259217563.png

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

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

Fig3_638180255261036760.png

再補充一點:設定 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 條,與預期相符,結案。

Fig4_638180806368811605.png


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK