如果上一節談的是「執行緒是什麼」,這一節就進一步看 .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 傳達一些排程意圖,例如:

  • LongRunning
  • AttachedToParent
  • DenyChildAttach
  • RunContinuationsAsynchronously

它們不是「強制命令」,而是提供給排程器的訊號。

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