《深入淺出 .NET 10 非同步程式設計》第 2 章

 連載《深入淺出 .NET 10 非同步程式設計》,這是第 2 章的完整內容。

第 2 章:.NET 中的執行緒與工作



在第一章,我們透過披薩店的比喻,理解了應用程式為什麼需要同時處理多項任務,也大致看見了多執行緒和非同步程式設計如何提升效率。不過,我們也已經碰到另一面,也就是多執行緒帶來的管理複雜性,例如 context switch 的開銷和資源同步問題。

接下來,我們就正式進入 .NET 的世界,看看這些概念在程式裡到底是怎麼落實的。本章的目標,是帶你理解 .NET 中實現非同步與多工的兩個核心概念:執行緒 (thread) 與 工作 (task),並學習如何利用現代工具避開傳統執行緒管理的陷阱。

許多初學者會把執行緒與工作混為一談,但若想寫出高效、穩定的非同步程式碼,先釐清它們的區別非常重要。簡單來說:

  • 執行緒 (thread) 是一個較低階的概念,你可以把它想像成執行程式碼的「工人」。
  • 工作 (task) 是一個較高階的抽象概念,代表一個需要完成的「工作」。


接下來,本章會依序走過下面幾個重點:

  • 傳統方式的挑戰:回顧直接管理執行緒的複雜性與侷限。
  • 核心觀念:Task:理解 Task 如何作為高階抽象,解決傳統執行緒管理的痛點。
  • 現代起手式:Task.Run:學習如何利用 Task.Run 輕鬆啟動背景工作。
  • 前景執行緒 vs. 背景執行緒:了解執行緒生命週期的關鍵差異。

傳統方式的挑戰:直接管理執行緒的複雜性

在早期版本的 .NET,開發者需要更直接地與執行緒打交道。這種作法雖然提供了很大的控制權,卻也伴隨著顯著的複雜性與潛在問題。先把這些傳統方式的挑戰看清楚,後面再看現代非同步程式設計帶來的簡潔與高效,就會更有感。

先備知識:主執行緒與工作執行緒

在接下來的範例中,你會頻繁看到兩個名詞:

  • 主執行緒 (main thread):當應用程式啟動時,作業系統配給這支程式的第一個(也是預設的)執行緒。如果主執行緒被卡住(阻塞),整個程式可能就會沒有反應。
  • 工作執行緒 (worker thread):除了主執行緒以外,其他用來執行工作的執行緒都可以先這樣泛稱。它們可能是我們額外建立的執行緒,也可能是向 ThreadPool 借來的執行緒。至於它在 .NET 生命週期上究竟屬於「前景執行緒」還是「背景執行緒」,我們會在本章後段再精確說明。

註:稍後我們會在本章末尾詳細說明「背景執行緒」與「前景執行緒」在 .NET 生命週期上的具體技術差異。

直接建立執行緒:Thread.Start

我們先從較傳統的方式開始:手動建立 System.Threading.Thread 物件,然後呼叫 Start 方法,讓新的執行緒開始執行指定工作。

var msg = $"主執行緒 ID: {Environment.CurrentManagedThreadId}";
Console.WriteLine(msg);

// 建立一條新的執行緒來執行 DoWork
var newThread = new Thread(DoWork);
newThread.Start();

Console.WriteLine("主執行緒繼續執行...");

void DoWork()
{
    var msg = $"工作執行緒 ID: {Environment.CurrentManagedThreadId}";
    Console.WriteLine(msg);
    Console.WriteLine("背景工作正在進行中...");
    Thread.Sleep(2000); // 模擬耗時 2 秒的工作
    Console.WriteLine("背景工作完成。");
}

程式執行結果:

主執行緒 ID: 2
主執行緒繼續執行...
工作執行緒 ID: 10
背景工作正在進行中...
背景工作完成。

原始碼: DemoThreadStart

光看寫法,好像不算太麻煩。但只要把它放到真實應用場景,就會很快看見這種作法的問題:

  1. 成本高昂:每建立一條執行緒,系統預設都會為其分配約 1MB(在 Linux 與 macOS 等作業系統上,依其預設組態可能分配數 MB)的記憶體空間作為執行緒的堆疊(thread stack),並需要耗費額外資源進行作業系統核心(kernel)層級的設定。如果頻繁地為小任務建立新執行緒,就像是為了送一份外賣而專門聘請一位新員工,極度缺乏效率。
  2. 管理複雜:你需要手動管理執行緒的生命週期,而且難以取得回傳值或處理例外。

Note: 上述有關 1MB 執行緒堆疊的陳述,係根據微軟文件:Thread Stack Size

關於 Thread.Sleep

本書的許多範例都會用到 Thread.Sleep()。它在範例程式中的主要用途,是刻意製造一段容易觀察的延遲或阻塞效果,好讓你更清楚看見執行緒切換、等待與程式流程。

Thread.Sleep 本身並不是非同步程式設計的一環;恰恰相反,它正是我們想透過非同步技巧避免的那種「阻塞等待」。它也不代表真正的 CPU 密集型運算,因為執行緒在 Sleep 期間其實沒有做計算,只是單純停住不動。在真實的 I/O 操作中,我們追求的是在等待期間不要阻塞執行緒;而在真實的 CPU 密集型場景中,通常是執行複雜計算,而不是呼叫 Sleep

執行緒的重複利用:ThreadPool

為了解決 new Thread() 帶來的效能問題,.NET 引入了執行緒集區 (thread pool)。你可以把它想像成 .NET 先替你的應用程式聘好一群「駐點工人」(Thread)。當有「工作」(Task)需要執行時,就從這個「工人集區」中指派一位閒置的工人來處理。工作結束後,工人會回到集區待命,而不是被解僱。這樣一來,資源利用效率就能大幅提升。



接著來看第二種作法。下面這個範例,基本邏輯和前一個 DemoThreadStart 完全相同;差別只在於,這次不再手動建立執行緒,而是改由「執行緒集區」指派現有的工人來處理工作:

var msg = $"主執行緒 ID: {Environment.CurrentManagedThreadId}";
Console.WriteLine(msg);

// 將工作項目排入執行緒集區
ThreadPool.QueueUserWorkItem(_ => DoWork());

Console.WriteLine("主執行緒繼續執行...");
Thread.Sleep(3000); // 等待背景工作完成,否則主程式可能先結束

void DoWork()
{
    var msg = $"背景執行緒 ID: {Environment.CurrentManagedThreadId}";
    Console.WriteLine(msg);
    Console.WriteLine("背景工作正在進行中...");
    Thread.Sleep(2000); // 模擬耗時 2 秒的工作
    Console.WriteLine("背景工作完成。");
}

原始碼: DemoThreadPool

看到這裡,你大概會覺得:既然 ThreadPool 能重複利用執行緒,是不是問題就解決了?還不完全是。ThreadPool 雖然大幅改善了效能,但仍有兩個主要缺點:

  • 射後不理: ThreadPool 是一種「射後不理 (fire-and-forget)」的模式,所以你很難知道工作何時完成,也無法輕易取得回傳值。
  • 例外黑洞: 如果背景工作發生錯誤,由於「主執行緒」無法直接捕捉背景執行緒拋出的例外,處理起來也會相對麻煩且危險。

Note

當你把工作丟給 ThreadPool 之後,主執行緒就繼續往下走了。如果背景工作在執行過程中拋出例外,這個例外會直接在「背景執行緒」的頂層引爆,而原呼叫者(主執行緒)是無法用 try-catch 捕捉到它的。在現今的 .NET 環境下,這通常會導致整個處理序(process)崩潰當機。

若要安全處理,開發者就得在每個丟進 ThreadPool 的工作內部,自己手動包好 try-catch,然後再想辦法把錯誤狀態傳回主程式。這通常會牽涉到更複雜的狀態共用與同步機制。

核心觀念:Task - 解決執行緒管理複雜性的高階抽象

走到這裡,現代 .NET 想解決的方向就很清楚了:我們需要一個比直接管理執行緒更高階的抽象。在 C# 中,System.Threading.Tasks.Task 物件正是現代非同步程式設計的核心。當你看到一個方法的回傳型別是 Task 或 Task<T>,可以先把它理解為一個「承諾 (promise)」或「未來憑證 (future)」。

  • Task:代表一個沒有回傳值的非同步操作。它承諾「我會在未來某個時間點完成這項工作」。
  • Task<T>:代表一個有回傳值的非同步操作。它承諾「我會在未來某個時間點完成工作,並且給你一個 T 型別的結果」。

這個「承諾」最有價值的地方,在於它把「要執行的工作」與「執行工作的工人」解耦合了。你只要拿到這個 Task 物件,就能追蹤它的狀態,例如是否已完成、是否發生錯誤,並在它完成時取得結果,而不必一直關心它到底是在哪一條執行緒上執行的。



這種抽象化帶來了很大的好處。.NET 的執行環境(runtime)可以根據當下的系統負載、CPU 核心數量等因素,用更有效率的方式來排程並執行這些 Task。例如,它可以重複利用現有執行緒,避免頻繁建立與銷毀執行緒的效能開銷。換句話說,它有效解決了傳統 Thread 和 ThreadPool 在管理複雜性、回傳值取得,以及例外處理上的痛點,讓開發者能專注在「要做什麼工作」,而不是「該叫哪個工人去做,以及如何監督他」。

現代起手式:Task.Run

回想第一章的披薩店,我們的廚師主要是被「等待烤箱」這件事耽擱住,那屬於 I/O 密集型 (I/O-bound) 操作。但如果今天廚師接到的是一張極度複雜的手工訂單,需要他連續揉十分鐘麵糰呢?這種需要 CPU 全力運算的任務,就稱為 CPU 密集型 (CPU-bound) 操作。

面對這種 CPU 密集型工作,我們同樣不希望它一直佔住主廚,也就是 UI 執行緒或主執行緒的時間。一個很自然的作法,就是把它交給廚房裡的另一位幫手去處理。這正是 Task.Run 的核心使命。

接下來,終於來到現代 .NET 非同步程式設計很常見的起手式:Task.Run。這個方法把 ThreadPool 的高效能,和 Task 抽象化帶來的優點,結合在一起。

Task.Run 的作用是:將一個工作交給執行緒集區去執行,並立即回傳一個代表該工作的 Task 物件。


先看一個最基本的例子。下面這段程式會使用 Task.Run 把一段耗時工作推派到背景執行緒,然後透過回傳的 Task 物件來等待它完成:

var msg = $"主執行緒 ID: {Environment.CurrentManagedThreadId}";
Console.WriteLine(msg);

Console.WriteLine("準備使用 Task.Run 執行背景工作...");

// 將工作交給執行緒集區,並取得 Task 物件
Task task = Task.Run(() =>
{
    var msg = $"背景執行緒 ID: {Environment.CurrentManagedThreadId}";
    Console.WriteLine(msg);
    Console.WriteLine("背景工作正在進行中...");
    Thread.Sleep(2000); // 用來簡化模擬一段耗時的同步工作
    Console.WriteLine("背景工作完成。");
});

Console.WriteLine("主執行緒已呼叫 Task.Run,繼續執行其他事情...");

// 等待 Task 完成
task.Wait();

Console.WriteLine("確認 Task 已完成,主程式即將結束。");

執行結果:

主執行緒 ID: 2
準備使用 Task.Run 執行背景工作...
主執行緒已呼叫 Task.Run,繼續執行其他事情...
背景執行緒 ID: 9
背景工作正在進行中...
背景工作完成。
確認 Task 已完成,主程式即將結束。

讀到這裡,先記住一個重點:這裡使用 task.Wait(),只是為了在 Console 應用程式中確保背景工作能執行完畢。在真實的非同步程式碼中,特別是 UI 或伺服器端應用,我們通常應該使用 await 來非同步地等待 Task,而不是用 Wait() 阻塞當前執行緒。這部分我們會在下一章深入探討。

原始碼: DemoTaskRun

如果你的背景工作還需要回傳結果,Task.Run 同樣能處理。先看下面這個版本:

Console.WriteLine("準備使用 Task.Run 執行有回傳值的背景工作...");

// Task<string> 代表此工作未來會回傳一個 string
Task<string> taskWithResult = Task.Run(() =>
{
    Thread.Sleep(2000);
    return "這是背景工作的計算結果";
});

Console.WriteLine("主執行緒繼續執行...");

// 使用 .Result 屬性會阻塞當前執行緒,直到 Task 完成並取得結果
string result = taskWithResult.Result;

Console.WriteLine($"從背景工作取得的結果: {result}");

不過,這裡也剛好要再提醒一次:前面提過,呼叫 Task 的 .Wait() 方法會阻塞當前執行緒;而這個範例使用 .Result 屬性來取得非同步工作的結果,也同樣會阻塞執行緒,因而失去非同步原本該有的優勢。所以在撰寫非同步程式時,應盡量避免呼叫 .Wait() 或讀取 .Result 屬性。

更自然、也更符合現代寫法的作法,是直接使用 await 關鍵字:

// Program.cs (假設在一個 async Main 方法中)
Console.WriteLine("準備使用 async/await 非阻塞地等待背景工作...");

// ✓ 正確示範:以非阻塞方式等待 Task 完成
string resultAsync = await Task.Run(() =>
{
    Thread.Sleep(2000);
    return "這是使用 await 得到的結果";
});

Console.WriteLine($"從背景工作取得的結果: {resultAsync}");

如你所見,使用 await 之後,程式碼在保持非同步優勢的同時,讀起來仍然像同步程式碼一樣直觀、簡潔。至於這背後的魔法,我們會在下一章再深入探討。

回到本節的主角 Task,這裡可以先收一個小結:Task.Run 最適合拿來搬走 CPU 密集型工作。 例如複雜計算,就很適合從 UI 執行緒或主執行緒移開,改交給背景執行緒處理。



如何讓 Console 應用程式支援 await

為了讓 await 關鍵字能在程式的進入點(Main 方法)中使用,你需要確保專案設定正確。

  • 現代 C# (9.0 或更新版本): 當你建立新的主控台應用程式時,專案預設會使用「頂層陳述式 (top-level statements)」,你不需要定義 Main 方法,可以直接在 Program.cs 檔案的最上層使用 await
  • 舊版 C#: 你需要手動將 Main 方法的簽章修改為 public static async Task Main(string[] args)

不要用 Task.Run 呼叫非同步 I/O 方法

講到這裡,也要順手避開一個很常見的誤用。Task.Run 的核心使命,是把同步的、CPU 密集型工作卸載到背景執行緒,以避免阻塞上層呼叫者。然而,很多人會把一個本身已經是非同步的 I/O 方法,再用 Task.Run 包裝一次。

✘ 反模式 (anti-pattern):

var json = await Task.Run(async () => await httpClient.GetStringAsync(url));

✔ 正確作法:

var json = await httpClient.GetStringAsync(url);

httpClient.GetStringAsync 本身就是非同步方法。當它被 await 時,它已經會在等待網路 I/O 完成的期間避免阻塞當前執行緒。這時再用 Task.Run 把它包起來,不但沒有任何額外好處,反而只會多增加一次不必要的 ThreadPool 排程與委派包裝成本。

Task 的生命週期與狀態

當我們透過 Task.Run 或其他方式取得一個 Task 物件後,就可以透過它的 Status 屬性,也就是 TaskStatus 列舉,來了解工作目前進行到哪裡。一個 Task 的典型生命週期,大致會出現以下幾種重要狀態:

  • Created:工作已建立,但尚未被排程執行。
  • WaitingForActivation:工作正在等待被啟用。這在依賴基礎架構發出完成通知的非同步操作(如 Task.Delay 或 I/O 工作)中非常常見。
  • WaitingToRun:工作已排程,正在等待執行緒集區分配執行緒。
  • Running:工作正在執行中。
  • RanToCompletion:工作已順利執行完畢。
  • Faulted:工作因為發生未處理的例外(exception)而失敗。
  • Canceled:工作已被取消。

除了 Status 屬性,Task 還提供了幾個方便的布林屬性:IsCompletedIsFaulted 與 IsCanceled。值得留意的是,只要工作處於上列最後三種狀態之一:RanToCompletionFaulted、或 Canceled,那麼 IsCompleted 就會是 true



光看名稱有點抽象,所以我們直接用一個簡短範例來觀察狀態變化:

// Program.cs
Console.WriteLine("建立並啟動一個會故意失敗的任務...");

Task myTask = Task.Run(() =>
{
    Console.WriteLine("任務開始執行,即將拋出例外...");
    Thread.Sleep(500);
    throw new InvalidOperationException("哎呀,任務失敗了!");
});

try
{
    // 故意等待任務完成,以觀察它的最終狀態
    myTask.Wait();
}
catch (AggregateException)
{
    // 使用 Wait() 等待失敗的 Task 時,例外會被包裹在 AggregateException 中。
    // 在此先捕獲並忽略,以觀察任務的最終狀態。
}

Console.WriteLine($"任務最終狀態: {myTask.Status}");
Console.WriteLine($"IsFaulted: {myTask.IsFaulted}");
Console.WriteLine($"IsCompleted: {myTask.IsCompleted}");

執行結果:

建立並啟動一個會故意失敗的任務...
任務開始執行,即將拋出例外...
任務最終狀態: Faulted
IsFaulted: True
IsCompleted: True

這段程式碼也順便展示了前面提過的例外處理機制:當你使用傳統的阻塞方式,例如 .Wait() 或 .Result,去等待一個發生錯誤的 Task 時,原始例外會被包裹在 AggregateException 當中。至於這個機制,以及現代 await 的解包魔法,會在第 4 章詳細介紹。

原始碼: DemoTaskStatus

工作不等於執行緒:從 CPU-bound 走向 I/O-bound

看到這裡,很多人心裡通常會冒出一個疑問:「是不是每個 Task 背後,都代表有一條背景執行緒在跑?」

答案:「不是。」

Task 是一個非常抽象的概念,它只代表一個「未來的承諾(promise)」。這個承諾 可以 是由一條正在 CPU 上執行同步工作的背景執行緒來實現(例如 Task.Run);但它 也可能在等待期間不需要專門佔住任何一條執行緒

為了打破這個迷思,接下來就來看兩個很常見的例子:Task.Delay 和 Task.FromResult。它們都能建立 Task,但不需要額外消耗一條執行緒去傻等。

Task.Delay:不阻塞執行緒的等待

還記得 Thread.Sleep 嗎?它會讓當前的工人,也就是執行緒,呆站在原地睡覺,什麼事都不做。如果你在主執行緒使用它,整個應用程式就會卡在那裡。

.NET 提供了非同步版本的替代方案:Task.Delay。它代表一個「在指定時間後會完成的任務」。在這段等待期間內,通常不會有一條執行緒被專門卡住等它完成;它的底層依賴的是系統內建的計時器(Timer),時間一到,再由執行環境安排後續的接續工作(continuation)。

// 模擬一個非同步的等待,期間不會阻塞執行緒
Task delayTask = Task.Delay(2000);

這也是為什麼 Task.Delay 很常被拿來模擬 I/O 密集型的等待操作,例如等待網路封包或等待資料庫回應。

Task.FromResult:已經完成的承諾

那如果你手上其實已經有現成結果了,但某個方法的介面又要求你必須回傳一個 Task<T>,該怎麼辦?這時候,你不需要動用 Task.Run 去叫一個背景執行緒來回傳結果,直接使用 Task.FromResult 就可以了。像這樣:

// 建立一個 "剛出生就已經完成" 且帶著結果 42 的 Task
Task<int> completedTask = Task.FromResult(42);

Console.WriteLine($"狀態: {completedTask.Status}"); // 狀態會直接是 RanToCompletion
Console.WriteLine($"結果: {completedTask.Result}"); // 立刻取得 42,不需要等待

Task.FromResult 的存在,再次證明了一件事:Task 代表的主要是狀態與承諾。它甚至可以代表一個在建立瞬間就已經完成的工作,連排程都不需要。

換句話說,理解「Task 不等於 Thread」,是往更高階非同步思維前進的一個關鍵轉折。當我們面對 I/O 密集型工作時,拿到的 Task 往往只是在等待一個硬體或作業系統層級的完成通知;此時通常不會有任何一條執行緒在背景「專門」傻等這個結果。

底層運作

從作業系統與硬體的底層原理來說,當你發起一個真正的非同步 I/O 操作(例如:發送網路請求、要求儲存裝置讀取檔案),在等待資料傳輸的這段期間,通常不會有一條 CPU 執行緒被專門阻塞在原地傻等結果。

這背後的機制是依賴硬體中斷(hardware interrupts)與作業系統的 I/O completion 機制(例如 Windows 的 IOCP、Linux 的 epoll、或 macOS 的 kqueue):

  1. 你的程式發出 I/O 請求給作業系統。
  2. 作業系統把請求交給硬體設備(網卡、磁碟控制器),然後 CPU 就可以立刻去執行別的執行緒了。
  3. 當硬體設備完成傳輸時,會發出一個硬體中斷。
  4. 作業系統收到中斷,將結果放進一個佇列(queue)裡面。
  5. .NET 執行環境接收到完成通知後,才會安排適當的執行緒去把結果接回來,然後將你程式中的 Task 標記為完成,並接續執行後面的 await 程式碼。

參閱微軟文件:同步與非同步 I/O

前景執行緒 vs. 背景執行緒

接著,我們再把視角拉回執行緒本身。在 .NET 中,執行緒分為兩種模式:前景 (foreground) 與背景 (background)。這個區別非常重要,因為它直接影響應用程式的生命週期。

  • 前景執行緒:只要還有任何一條前景執行緒在執行,應用程式的處理序(process)就會保持存活。
  • 背景執行緒:一旦所有的前景執行緒都結束了,.NET 執行環境就會自動終止所有仍在執行的背景執行緒,並關閉應用程式。

這樣設計的邏輯是:前景執行緒執行的是應用程式的核心工作;而背景執行緒執行的,則是輔助性、可以隨時被中斷的任務,例如定時存檔、記錄日誌等等。

一個關鍵的知識點是:

  • 使用 new Thread() 建立的執行緒,預設是前景執行緒。
  • 從 ThreadPool 取得的執行緒(因此也包括 Task.Run 所使用的執行緒),永遠都是背景執行緒。

這裡直接用一個簡單範例來驗證這個行為:

// 範例一:使用 Thread (前景執行緒)
var foregroundThread = new Thread(() =>
{
    Thread.Sleep(3000);
    Console.WriteLine("前景執行緒完成。");
});
// foregroundThread.IsBackground = true; // 可以手動設為背景
foregroundThread.Start();
Console.WriteLine("Main 函式 (前景) 即將結束,但程式會等待前景執行緒完成。");


// 範例二:使用 Task.Run (背景執行緒)
_ = Task.Run(() =>
{
    Thread.Sleep(5000);
    // 這行可能永遠不會被執行
    Console.WriteLine("背景執行緒完成。");
});
Console.WriteLine("Main 函式 (前景) 即將結束,程式不會等待背景執行緒。");

原始碼: DemoForegroundBackgroundThreads

當你執行這段程式碼時,會看到主控台先印出 Main 函式結束的訊息,然後等待約 3 秒,再印出「前景執行緒完成。」,程式才真正關閉。至於那條由 Task.Run 啟動的背景執行緒,也就是註解標示「範例二」的程式碼,因為故意睡得更久,通常會在主程式結束時被強制終止,來不及印出完成訊息。

另外要注意,這個範例刻意採用 fire-and-forget 的寫法,只是為了觀察「前景 / 背景執行緒」對處理序生命週期的影響;在一般應用程式中,除非你非常確定後果,否則不應把重要工作寫成這種不等待完成的形式。

結語

本章先從傳統執行緒管理的挑戰談起,讓我們看見直接使用 Thread 和 ThreadPool 所帶來的複雜性與侷限。接著,我們引入了 Task 這個高階抽象,讓開發者能把注意力放回「工作本身」,而把底層執行緒管理交給 .NET 執行環境處理。

讀到這裡,你已經掌握了建立與管理非同步操作的基本工具,也知道如何使用 Task.Run 把 CPU 密集型工作交給背景執行緒處理。不過,你可能也注意到了:本章範例中用到的 task.Wait() 和 task.Result,其實都會「阻塞」程式,這似乎又把我們帶回第一章想解決的問題。

而這也正是 async 與 await 真正要出場的地方。下一章,我們就會學習如何使用這對語法糖,以一種截然不同、而且非阻塞的方式來等待 Task 完成,進一步釋放非同步程式設計真正的威力。


本文摘自 《深入淺出 .NET 10 非同步程式設計》的第 2 章完整內容。

 

👉 繼續閱讀第 3 章:async 與 await


沒有留言:

技術提供:Blogger.
回頂端⬆️