如果上一節談的是「執行緒是什麼」,這一節就進一步看 .NET 實際上怎麼安排工作。
現代 .NET 程式很少直接大量建立 Thread。更常見的做法是把短工作交給 ThreadPool,把可組合、可等待、可延續的工作模型交給 Task。
ThreadPool 是什麼
ThreadPool 可以視為一組由 CLR 管理的工作執行緒。它的目的不是讓你「擁有更多執行緒」,而是重複利用既有執行緒,避免每次都建立和銷毀。
它特別適合:
- 短時間背景工作
- 非 UI 的一般非同步工作
- 大量、細碎、可拆分的任務
不適合的情況則包括:
- 長時間獨占執行緒的工作
- 需要明確 thread affinity 的工作
- 永久阻塞型工作
因為你一旦把 ThreadPool 執行緒卡死,本質上就是在搶整個程序共用的執行資源。
QueueUserWorkItem
最基礎的 ThreadPool API 是 ThreadPool.QueueUserWorkItem。它會把工作排進佇列,之後由池中的工作執行緒取出執行。
ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine($"Running on thread {Environment.CurrentManagedThreadId}");
});
這種方式很輕量,但能力也有限:
- 不方便取得回傳值
- 不好表達錯誤與取消
- 不利於後續工作組合
所以在今天的程式碼裡,除非你刻意使用低階 API,否則更常直接寫 Task.Run(...)。
ExecutionContext 為什麼存在
當工作從一條執行緒切到另一條執行緒時,.NET 往往還需要把部分執行環境一起帶過去。這些資訊由 ExecutionContext 承載。
它通常包含:
- 安全性相關上下文
- 區域性或文化資訊
AsyncLocal<T>這類邏輯呼叫上下文資料
因此你把工作排進 ThreadPool,不代表它會在「完全乾淨」的上下文中執行。預設情況下,呼叫端的一部分執行環境會流向工作端。
這能讓高階框架運作更自然,但也表示:
- 上下文流動是有成本的
- 某些極致效能場景會選擇抑制它
- 如果你不理解這個機制,除錯時會很困惑
協作式取消
.NET 的取消模型不是強制把執行緒殺掉,而是讓工作自己檢查取消訊號,決定何時安全停止。這就是協作式取消(cooperative cancellation)。
核心型別有兩個:
CancellationTokenSource:發出取消訊號CancellationToken:傳給工作端讀取狀態
var cts = new CancellationTokenSource();
Task.Run(() =>
{
for (var i = 0; i < 10; i++)
{
if (cts.Token.IsCancellationRequested)
{
Console.WriteLine("Canceled");
return;
}
Console.WriteLine(i);
Thread.Sleep(200);
}
});
cts.Cancel();
這種設計比粗暴終止執行緒安全得多,因為它保留了:
- 釋放資源的機會
- 完成 finally 區塊的機會
- 維持資料一致性的機會
Task 是比 ThreadPool 更高階的抽象
Task 可以把它理解成「一份工作」的描述,而不是「一條執行緒」本身。
它比 QueueUserWorkItem 多了幾個很重要的能力:
- 可等待
- 可回傳結果
- 可傳播例外
- 可串接 continuation
- 可與取消、排程器整合
最常見寫法:
Task<int> task = Task.Run(() => Sum(10));
int result = await task;
這裡你關心的是工作何時完成、完成後得到什麼值,而不是哪一條執行緒替你做。
TaskCreationOptions 與延續工作
建立 Task 時,可以用 TaskCreationOptions 傳達一些排程意圖,例如:
LongRunningAttachedToParentDenyChildAttachRunContinuationsAsynchronously
它們不是「強制命令」,而是提供給排程器的訊號。
Task 完成後還能接續下一段工作:
Task.Run(() => Sum(10))
.ContinueWith(t => Console.WriteLine($"Result = {t.Result}"));
ContinueWith 很靈活,但在現代程式碼裡,若沒有特殊需求,async / await 通常更直觀、更不容易寫出難懂的控制流程。
Wait、Result 與阻塞成本
Task.Wait() 與 task.Result 會同步阻塞目前執行緒,直到工作完成。
var task = Task.Run(() => Sum(10));
task.Wait();
Console.WriteLine(task.Result);
這麼做有幾個風險:
- 浪費執行緒,因為它只是停在那裡等
- 在 UI 或特定同步內容下可能造成死結
- 會把原本可非同步串接的流程硬轉回同步模型
如果呼叫端本來就能非同步,優先考慮 await,而不是 Wait()。
Parent / Child Task 與 TaskFactory
當一組工作共享相同設定時,TaskFactory 可以減少重複配置:
var cts = new CancellationTokenSource();
var factory = new TaskFactory<int>(
cts.Token,
TaskCreationOptions.AttachedToParent,
TaskContinuationOptions.None,
TaskScheduler.Default);
Task<int[]> parent = Task.Run(() =>
{
var children = new[]
{
factory.StartNew(() => Sum(5)),
factory.StartNew(() => Sum(10)),
factory.StartNew(() => Sum(15))
};
Task.WaitAll(children);
return children.Select(t => t.Result).ToArray();
});
這套模型可以表達父子任務關係,但也更複雜。若只是一般業務程式,很多時候直接用 Task.WhenAll 會更清楚。
TaskScheduler 在做什麼
TaskScheduler 決定了 task 最後如何被排程執行。預設情況下,多數工作會落到 ThreadPool 上。
理解這層的意義在於:
Task不等於 ThreadPool,但預設常常由它承載- 排程器可以根據情境決定實際執行策略
- 某些框架會提供自訂 scheduler,限制並行度或切回特定上下文
因此我們平常說「Task 會在線程池跑」,嚴格來說是大多數預設情況如此,而不是語言層面的絕對規則。
Parallel、PLINQ 與 Timer
Parallel.For / Parallel.ForEach
這組 API 適合處理可平行拆分的 CPU-bound 工作:
Parallel.For(0, 1000, i =>
{
DoCpuBoundWork(i);
});
但前提很嚴格:
- 每次迭代之間最好互不干擾
- 單次工作量要夠大,才值得平行化
- 共享資料越多,鎖競爭越嚴重,收益越低
如果每次迭代只做很小的事,平行化開銷反而可能比工作本身更大。
PLINQ
PLINQ 是 LINQ 的平行版,透過 AsParallel() 把查詢拆到多核心上跑:
var result = numbers
.AsParallel()
.Where(n => IsExpensive(n))
.Select(n => Transform(n))
.ToArray();
適合資料量大、每個元素處理成本高、順序不敏感的情境。若你硬要保留輸出順序,則需要額外合併成本,收益會下降。
System.Threading.Timer
Timer 會在到期時把回呼丟到 ThreadPool 執行:
using var timer = new Timer(_ =>
{
Console.WriteLine("tick");
}, null, 1000, 1000);
要注意幾件事:
- 回呼如果執行太久,下一次 tick 可能重疊進來
Dispose()才是完整取消- 它適合背景排程,不適合精準即時控制
ThreadPool 管理的重點
ThreadPool 內部會根據工作量、阻塞情況與 CPU 狀態動態調整工作執行緒數量。這部分設計很複雜,實務上最重要的原則不是「手動控制它」,而是不要故意和它對抗。
應避免:
- 把長時間阻塞作業塞進去
- 在池中工作上做大量同步等待
- 依賴大量 thread-local 狀態
- 以為開更多 work item 就一定更快
ThreadPool 的優勢來自整體調度,而不是單一工作獨占資源。
實務上該記住什麼
- 短工作交給 ThreadPool,長期專用工作才考慮獨立執行緒
- 現代 .NET 以
Task為主,不要再把Task理解成「語法比較新的 Thread」 - 可非同步就用
await,少用Wait()與.Result - 取消是協作式設計,不是強制終止
- 平行化只對特定類型的 CPU 工作有利,不是所有迴圈都值得開
Parallel
參考資料
- 《CLR via C#》第四版,Jeffrey Richter