「因為處理太慢而建立執行緒做成並行,結果偶爾統計結果會偏差」「加了背景處理之後,應用程式每個月會當機一次」「即使說偵錯執行時無法重現,但在客戶端確實發生了」── 多執行緒程式設計可怕的地方,在於剛寫完的當下看起來運作正常。競爭的臭蟲是時序依賴的,會躲過測試,只在正式環境中才露出真面目。
另一方面,如今多核心已是理所當然,業務應用程式也確實存在「不讓 UI 卡住、同時執行繁重處理」「並行處理多個裝置或檔案」這類無法迴避多執行緒的場景。重要的是在增加執行緒之前,先決定好設計的原則。因為多執行緒的臭蟲不是靠除錯來消滅的,而是要靠設計來杜絕它產生的空間。
本文是多執行緒實務系列的.NET篇。以在 Windows 上開發業務應用程式、需要導入多執行緒的開發者為對象,整理不分語言或作業系統都通用的設計原則,以及 C#/.NET 上具體的工具,內容以 2026 年 8 月時間點的第一手資訊為依據。原則本身在 Linux 或 C++ 上也不會改變。若要以原生程式碼撰寫,請參考將同樣原則落實到各語言工具的「C++篇」「C語言篇」;若使用 Java 撰寫,請參考「Java篇」。
1. 先講結論
- 不自行建立執行緒是第一個最佳實踐。不使用
new Thread,而是搭乘 Task、執行緒集區、Parallel類別等更上層的 API,把執行緒數量的管理交給執行環境。12 - 並行化最先該削減的是「共享的可變狀態」。多個執行緒寫入同一個變數的地方,正是競爭的發生源。與其先用鎖保護,不如先透過資料的分割、不可變化、傳遞來減少共享本身。3
- 讓鎖具備紀律。明確決定「哪個資料由哪個鎖保護」,兩者一對一對應,鎖定對象使用不對外公開的專用物件。禁止使用
lock(this)或lock(typeof(X))。.NET 9 以後請使用專用的System.Threading.Lock型別。4 - 執行緒之間的資料傳遞應集中到佇列上。使用
System.Threading.Channels或並行集合的生產者/消費者架構,比起到處張開鎖,設計更單純,邊界也更清楚。56 - 停止方式要最先設計。停止唯一正確的做法是透過
CancellationToken的協調式取消,Thread.Abort在 .NET(Core 系)中會擲出執行期例外。78 - UI 是 UI 執行緒的專屬物。無論是 WinForms 的控制項還是 WPF 的元素,都不能由建立它的執行緒以外的其他執行緒觸碰。從其他執行緒操作時,要透過
Control.Invoke/Dispatcher委託處理。910 - 「並行就一定快」並不成立。單次工作量很小的迴圈,會因並行化的額外負擔而反而變慢。務必先測量再採用。3
2. 為什麼多執行緒很難 ── 競爭狀態與死結
多執行緒帶來的問題,追根究柢分成兩種。4
競爭狀態(race condition)是指結果會因多個執行緒到達特定程式碼的順序而改變的臭蟲。經典的例子是共享計數器的遞增:count++ 這一行程式,實際上分成「讀取 → 加總 → 寫回」三個步驟。若兩個執行緒同時執行這三個步驟,其中一方的加總會被另一方的寫回覆蓋,導致一次加總遺失。每次執行結果都會改變,而且無法預測會得到哪個執行結果。4
sequenceDiagram
participant A as 執行緒A
participant M as 共享變數 count
participant B as 執行緒B
Note over M: count = 10
A->>M: 讀取(10)
B->>M: 讀取(10)
A->>A: 在自己手上加總(11)
B->>B: 在自己手上加總(11)
A->>M: 寫回(11)
B->>M: 寫回(11)
Note over M: 明明加總了兩次,count 卻是 11<br/>執行緒A的加總遺失了
圖1:共享計數器加總遺失的典型競爭狀態。在 count++ 的三個步驟之間,一旦有其他執行緒插入,後寫回的一方就會覆蓋掉先前的結果
死結(deadlock)是指兩個執行緒互相等待對方持有的鎖,導致雙方都無法繼續前進的狀態。執行緒A持有鎖1、等待鎖2,執行緒B持有鎖2、等待鎖1 ── 光是這樣,雙方就會永遠停住。4
flowchart LR
A["執行緒A<br/>持有鎖1"] -->|"等待鎖2釋放"| B["執行緒B<br/>持有鎖2"]
B -->|"等待鎖1釋放"| A
圖2:死結的循環等待。等待的箭頭一旦形成環圈,環圈中的所有執行緒都會永遠停止
麻煩的是,這兩者都依賴時序。開發機上數萬次才會踩中一次的交錯(執行順序的組合),在核心數與時序都不同的客戶端機器上,每天都發生也不足為奇。「掛上偵錯器就不再重現」「加了記錄就消失了」,這也是因為觀測本身改變了時序,是競爭臭蟲的典型行為。
正因如此,接下來的原則全都指向同一個方向。在「正確地同步」之前,先「減少需要同步的地方」 ── 這是多執行緒設計的骨幹。
3. 原則1:不自行建立執行緒
3.1. 搭乘 Task 與執行緒集區
用 new Thread(...) 直接建立執行緒的寫法,在今日的 .NET 中已是例外性的最後手段。自 .NET Framework 4 以後,多執行緒/並行程式碼的推薦手段是 TPL(Task Parallel Library),也就是以 Task 為核心的一系列 API。TPL 會依可用的處理器動態調整並行度,並攬下工作切分、排入執行緒集區、因應取消、狀態管理等低階的麻煩事。1
執行緒集區是 .NET 本身廣泛用於執行 Task、完成非同步 I/O、計時器回呼等基礎設施,只要投遞的是短工作,開發者就不需要自行管理執行緒的生命週期。2
// 在背景執行耗用 CPU 的繁重處理
var result = await Task.Run(() => HeavyCalculation(input));
// 讓多個獨立處理並行執行,並全部等待完成(件數較少時)
// ※ 這種寫法適用於 ProcessAsync 是以 I/O 為主的非同步方法的情況。
// WhenAll 只是「等待已經在跑的 Task」,若想讓 CPU 計算並行執行,
// 需要把各個處理用 Task.Run(() => Calc(x)) 包起來,投入執行緒集區
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));
// 件數較多時,為同時執行數設上限再流入
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
async (x, ct) => await ProcessAsync(x, ct));
// 重點有兩個:把呼叫端的 token 接到 ParallelOptions 上
// (忘記這一步的話,主體的 ct 永遠是 None),以及把該 ct 也傳進主體(不要捨棄)
有一點需要注意。Task.WhenAll(items.Select(...)) 會在列舉的當下一次啟動所有元素的處理。若是數件到數十件這種固定的工作量沒有問題,但若用在件數很多的集合上,就會一口氣耗盡 Socket、DB 連線和記憶體。件數無法預估的處理,請像上面的 Parallel.ForEachAsync 一樣為同時執行數設上限,或用後述的 bounded channel 控制流量。
自行建立執行緒之所以正當,幾乎僅限於「需要專屬的訊息迴圈」「需要指定執行緒的 Apartment(STA)」「要在應用程式整個生命週期內持續執行」這類執行緒本身的性質成為需求時。
3.2. 資料並行使用 Parallel.For / ForEach
「對集合的每個元素做相同處理,想讓整體變快」這種資料並行,不要自己把迴圈分配給執行緒,而是使用 Parallel.For / Parallel.ForEach。資料來源的切分(分割)與負載的重新分配由 TPL 負責,基本的迴圈甚至不需要鎖。11
不過,官方文件明確指出了兩個陷阱。3
- 不要以為並行一定比較快。反覆次數少、或單次處理很輕量的迴圈,並行化的額外負擔會超過本體,反而變慢。效能受很多因素影響,務必先測量再判斷。
- 不要讓反覆之間互相等待。
Parallel.For的各個反覆並不保證真的會並行執行。若程式碼讓某個反覆等待另一個反覆設定事件,就會依排程情況而發生死結。
3.3. 「等待」的處理不應交給執行緒,而是非同步 I/O
檔案、網路、DB 等以等待 I/O 為主的處理,不是增加執行緒的對象。等待期間白白占用一條執行緒只是浪費,若用 async/await 做非同步 I/O,等待期間就不會消耗執行緒。這種區分(CPU-bound 就並行化、I/O-bound 就非同步化)是多執行緒設計入口處最先該畫的界線。
flowchart TB
S["有想要並行處理的工作"] --> Q1{"處理的主體是什麼?"}
Q1 -->|"以等待I/O為主<br/>檔案・網路・DB"| ASYNC["async/await 的非同步I/O<br/>不增加執行緒"]
Q1 -->|"耗用CPU的計算"| Q2{"工作的形態是?"}
Q2 -->|"對集合的所有元素<br/>套用相同處理"| PAR["Parallel.For / ForEach"]
Q2 -->|"獨立的一整塊<br/>背景處理"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"訊息迴圈或指定STA等<br/>執行緒本身的性質是需求"| TH["new Thread<br/>(例外性的最後手段)"]
圖3:「建立執行緒」之前的分支。多數業務處理會落在上面三個出口之一,只有例外情況才會走到 new Thread
關於 async/await 的實務判斷,請參考「C# async/await 實務判斷表」;至於在其底層,執行緒集區與非同步 I/O 是如何串接起來的,則在「IOCP 與 .NET 執行緒集區」中有詳細說明。
4. 原則2:讓共享可變狀態降到最低
競爭只會在「多個執行緒」與「共享的可變資料」同時齊備時發生。執行緒的數量由需求決定,所以能靠設計削減的是共享的部分。手段有三種。
4.1. 分割 ── 讓每個執行緒只碰自己的資料
最單純也最有力的做法,是把資料按執行緒分開。以並行迴圈的統計為例,與其每次都寫入共享的合計變數,不如使用 Parallel.For 接受執行緒本地狀態的多載,讓各執行緒在自己手上先做小計,最後只合流一次。共享寫入的次數會從「每次反覆」降到「每個執行緒一次」,同步的成本與競爭的視窗都會大幅縮小。3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // 執行緒本地的初始值
(i, state, local) => local + Weigh(items[i]), // 每次反覆只加總到自己的local
local => Interlocked.Add(ref total, local)); // 合流是每個執行緒一次
flowchart TB
SRC["資料陣列(處理對象)"] --> T1["執行緒1<br/>處理自己負責的部分<br/>只加總到手上的小計"]
SRC --> T2["執行緒2<br/>處理自己負責的部分<br/>只加總到手上的小計"]
SRC --> T3["執行緒3<br/>處理自己負責的部分<br/>只加總到手上的小計"]
T1 --> M["合流: 用 Interlocked.Add<br/>每個執行緒只反映一次到合計"]
T2 --> M
T3 --> M
圖4:執行緒本地統計。處理過程中每個執行緒只碰自己的資料,沒有競爭的餘地,對共享狀態的寫入只在合流時每個執行緒發生一次
4.2. 不可變化 ── 不會被改寫的東西可以放心共享
只會被讀取的資料,無論有多少執行緒同時讀取都是安全的。設定值、主要資料、計算的輸入等,只要在建構完成後不再改寫(不可變化),就能不需同步就自由共享。C# 的話,record 型別或 init 屬性都能支持這種設計。只要決定「需要變更時,不是改寫,而是建立新的實例來替換」,需要保護的可變狀態就會少一個。
不過「看起來是唯讀」和「真的不可變」是兩回事。像 IReadOnlyList<T> 這類唯讀介面,只是「透過該介面無法改寫」而已,並不能阻止背後的 List<T> 被另一個參照改寫。record / init 的保證也很淺,不會保護屬性所參照的物件本身。真的想在執行緒之間安全共享的資料,要使用 System.Collections.Immutable 底下 ImmutableArray<T> 這類不可變集合,或者在共享的當下傳遞複本,徹底斷絕改寫的路徑。此時元素型別 T 本身也必須不可變才成立。不可變集合保護的只有「排列」,可變元素物件的參照仍與原始物件共享,只要能從另一條路徑改寫元素內容,競爭就依然存在。請讓物件圖一路到底都不可變,或是傳遞深層複本。
4.3. 傳遞 ── 用佇列傳送,而不是共享
即便如此,執行緒之間依然需要移動資料。這時不要「兩邊都去碰共享變數」,而是採用一方寫、一方讀,中間放一個佇列的生產者/消費者架構。
.NET 上的第一選擇是 System.Threading.Channels。這是生產者非同步寫入資料、消費者非同步讀出的 FIFO,同步的麻煩事全部由 channel 負責管理。5
var channel = Channel.CreateBounded<WorkItem>(100); // 容量100,施加背壓
// 生產者端
await channel.Writer.WriteAsync(item, ct); // 若已滿,等到有空位為止
// …所有生產者都寫完之後:
channel.Writer.Complete(); // 宣告「不會再有了」。少了這一步,讀取端的迴圈就無法結束
// 消費者端
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
Process(item);
}
flowchart LR
P1["生產者1<br/>WriteAsync"] --> CH["bounded channel(容量100)<br/>FIFO佇列<br/>同步由channel管理"]
P2["生產者2<br/>WriteAsync"] --> CH
CH --> C1["消費者1<br/>ReadAllAsync"]
CH --> C2["消費者2<br/>ReadAllAsync"]
CH -.->|"滿了就讓寫入等待<br/>(背壓)"| P1
CH -.->|"空了就讓讀取等待"| C1
圖5:夾著 channel 的生產者/消費者架構。兩邊都不直接碰共享變數,等待與容量控制都交給 channel
實務上重要的是選擇容量有上限的(bounded)channel。達到上限時的預設行為是「寫入端等待空位」,這自然就形成了背壓(backpressure)。若在生產速度快於消費的架構中使用無限制的佇列,雖然表面上還在動,但記憶體會持續增加,等於埋下一顆定時炸彈。5
在同步的世界裡,扮演與 bounded channel 相同角色的是指定容量的 BlockingCollection<T>。它靠容量限制防止生產端過度超前消費端,並在空的時候讓消費端阻塞等待,兼具阻塞與容量控制。12 另一方面,ConcurrentQueue<T> / ConcurrentStack<T> 是不使用鎖、只靠 Interlocked 操作實現執行緒安全的高速集合6,但沒有容量限制,也沒有「空了就等待」的機制,只是單純的執行緒安全佇列。請把它們當成工作傳遞的零件,而不是主角。另外,BlockingCollection<T> 並非為非同步存取而設計,若要與 async/await 搭配,請選用 Channel<T>。12
還有,「把字典換成 ConcurrentDictionary 就等於執行緒安全」這種想法要小心。即使個別操作是執行緒安全的,像「先確認存在再新增」這種複合操作仍然會發生競爭(請使用 GetOrAdd 等專為複合操作設計的方法)。而 GetOrAdd 本身也有但書:儲存的值雖然只會有一個確定結果,但用來建立值的工廠函式在發生競爭時可能會被呼叫多次。若工廠函式含有副作用(開啟連線、建立檔案等),重複執行就會造成外洩,因此請使用沒有副作用的函式,或是把想確保只執行一次的初始化改成把 Lazy<T> 存進值裡。更換集合的型別,不能替代減少共享可變狀態這件事。
5. 原則3:讓鎖具備紀律
即使減少了共享可變狀態,多半也無法歸零。剩下的共享部分要用互斥鎖(lock)處理,但鎖不是「暫且把可疑的地方用 lock 圈起來」的工具。紀律有四項。
5.1. 決定「要保護什麼」,用專用物件上鎖
鎖的單位不是以「程式碼區間」,而是以「資料」來考量。為想要保護的可變資料集合各自對應一個鎖物件,並在所有碰到該資料的地方都取用同一個鎖 ── 這個對應表一旦崩壞,就是競爭臭蟲的真實面貌。
鎖定對象的物件,要用不對外公開的專用實例。lock(this) 會和能參照自己實例的外部程式碼共享鎖,lock(typeof(X)) 則會和整個應用程式定義域共享鎖,兩者都是死結的溫床。在 .NET 9 / C# 13 以後,建議將專用型別 System.Threading.Lock 的實例作為鎖定物件。4
public class OrderBook
{
private readonly Lock _gate = new(); // .NET 9+(之前用 readonly object)
private readonly List<Order> _orders = []; // _gate 保護的資料
public void Add(Order order)
{
lock (_gate) { _orders.Add(order); }
}
}
C# 的 lock 陳述式,保證即使發生例外也一定會釋放鎖。展開後的形式會依鎖定物件的型別而不同:一般物件會展開成在 finally 中呼叫 Monitor.Exit 的形式,Lock 型別則會展開成使用 EnterScope() 及其釋放(dispose)的形式。413 也就是說 Lock 型別的欄位,是與 Monitor 不同的另一套機制,如果只有部分程式碼手寫 Monitor.Enter(_gate),就無法與 lock (_gate) 成立互斥。不論是哪一種型別,都不要手寫 Monitor.Enter / Exit,統一使用 lock 語法才安全。4
5.2. 持有鎖時不做「耗時的事」「外部的事」
持有鎖的時間愈短愈好,鎖定期間應該只做讀寫所保護資料的動作。持有鎖的同時進行 I/O,或透過事件、回呼呼叫外部程式碼,這種寫法不僅會拉長持有時間,還會製造出被呼叫的一方又試圖取另一個鎖、進而導致死結的路徑。在鎖外做準備,鎖內只做替換 ── 這是基本形式。
另外,lock 內無法 await(會編譯錯誤)。這不是限制,而是一種保護:因為 Monitor 具有「取得鎖的執行緒必須自己釋放」的執行緒親和性,這與 await 前後執行緒可能替換的非同步程式碼並不相容。非同步程式碼中的互斥,請用初始計數為 1 的 SemaphoreSlim。14
private readonly SemaphoreSlim _asyncGate = new(1, 1);
public async Task SaveAsync(Data data, CancellationToken ct)
{
await _asyncGate.WaitAsync(ct);
try { await WriteToFileAsync(data, ct); }
finally { _asyncGate.Release(); }
}
5.3. 多個鎖時,永遠以相同順序取得
有兩個以上的鎖時,取得順序因執行緒而異,正是死結的經典模式。對策很單純,把所有執行緒都以相同順序取得鎖訂為規則。無法保證順序的地方,可以使用 Monitor.TryEnter 帶逾時的多載,取不到就放手重來(或記錄異常),把永遠的 Hang 轉換成可偵測的失敗。4
5.4. 單純更新用 Interlocked,讀取較多就用 ReaderWriterLockSlim
像計數器的增減或旗標替換這種單一變數的原子更新,Interlocked 類別(Increment / Add / CompareExchange)比 lock 更快。在沒有競爭的情況下,僅需一個 CPU 指令前綴就能完成。4 反過來說,Interlocked 能做到的也僅止於此,無法用於需要一併整合多個變數的用途。搭配 volatile 自行打造的無鎖結構,是需要深入理解記憶體模型的高階工具,不是業務應用程式該寫的東西。
對於「讀取頻繁、寫入罕見」的共享資料,也有 ReaderWriterLockSlim 這個選項,它只排斥寫入,讀取則可以同時通過。13
6. 原則4:最先設計停止方式
多執行緒設計審查時最先該問的問題是「這個要怎麼停止」。能寫出動起來的程式碼,卻寫不出能安全停止的程式碼,因為後者不設計就不會自動出現。
6.1. 協調式取消(CancellationToken)是唯一正解
.NET 的停止模型統一為協調式取消。要停止的一方建立 CancellationTokenSource,把它的 Token 傳給各個處理。想停止時呼叫 Cancel()。處理端監看該 token,在自己方便的時間點善後後結束 ── 因為不是強制而是協調,處理端才能在維持一致狀態的情況下結束。7
private CancellationTokenSource? _cts;
private Task? _worker;
public void Start()
{
if (_worker is { IsCompleted: false }) // 拒絕在運作期間重複Start
throw new InvalidOperationException("Worker 已在執行中。");
if (_worker is { IsFaulted: true }) // 不在吞掉上次失敗的情況下重新建立
throw new InvalidOperationException("上一個 Worker 已失敗。", _worker.Exception);
_cts = new CancellationTokenSource();
var token = _cts.Token; // 先捕捉到區域變數,避免與停止後的再Start競爭
_worker = Task.Run(() => WorkLoop(token), token);
}
private void WorkLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // 用輪詢監看
{
ProcessNextItem(ct); // 對會阻塞的呼叫傳入ct以便立即中斷
}
}
public async Task StopAsync()
{
var cts = _cts; // 即使在等待期間欄位被替換,
var worker = _worker; // 也用區域變數固定住要停止的對象,避免弄錯
if (cts is null || worker is null) return;
Exception? cancelFailure = null;
try { cts.Cancel(); } // 註冊在token上的回呼可能會擲出例外
catch (Exception ex) { cancelFailure = ex; } // 先接住,等合流完成後再回報
try
{
try { await worker; } // 不論Cancel是否成功都一定要合流,途中的失敗也要能觀測到
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // 只把自己要求的停止視為「正常」
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // 兩邊的失敗都不遺漏
}
}
finally
{
cts.Dispose(); // 合流完畢的source要釋放(釋放WaitHandle等OS資源)。
if (ReferenceEquals(_cts, cts))
{
_cts = null; // 不讓已釋放的source被後續的StopAsync使用
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
另外,這個 Start / StopAsync 是假設由同一個執行緒(如UI執行緒)依序呼叫的最小組態。若有多個執行緒可能同時操作生命週期,請用 SemaphoreSlim 等把 Start / StopAsync 本身序列化 ── 在保護 worker 之前,若管理 worker 的操作本身就會競爭,那就本末倒置了。
這個小範例裡也放進了實務上有效的巧思。首先 Start 會拒絕執行中的重複呼叫。若無條件覆寫 _cts 與 _worker,前一個 worker 的參照就會遺失,變成既無法停止也無法合流的「迷途執行緒」與新的並行執行。生命週期 API(Start/Stop)基本上要自己確保「同時只有一個」。此外還有三點。第一,停止 API 會等待完成。Cancel() 只是「要求」取消,回傳的那一瞬間 worker 可能還在 ProcessNextItem 途中。若做成只要求不等待的 Stop(),就會在呼叫端開始善後時 worker 卻還在動,製造出新的競爭。第二,不丟棄 Task,而是保留它。若用 _ = Task.Run(...) 丟出去,即使 worker 因例外而死掉也沒人會發現。第三,token 不是在 lambda 內參照 _cts.Token,而是先捕捉到區域變數再傳遞。若在 lambda 內參照,求值的時機是在執行時,一旦在停止後立刻重新 Start,就會發生舊 worker 抓到新 token 的錯置。連同把該 token 也傳進 Task.Run 的第二個引數,當處理端以 ThrowIfCancellationRequested 或支援取消的 API 擲出 OperationCanceledException 結束時,Task 就會被分類為「已取消(Canceled)」而不是「失敗(Faulted)」(像本例這樣單純以迴圈條件跳出的情況則視為正常完成)。還有一點,StopAsync 的 catch 是用 when 篩選器只接住源自自己 token 的取消。因為若無條件吞掉 OperationCanceledException,處理內部另一個 token(例如各元素的逾時)所擲出的真正失敗,也會被誤判成「因為停止所以正常」。另外要注意,這種以 token 一致性來識別的做法,如果在 WorkLoop 內部使用了連結 token(6.1 節所述的連結合成),就會失效,因為飛出來的例外帶的是連結端的 token。在那種架構下,請在 WorkLoop 的出口呼叫 ct.ThrowIfCancellationRequested() 把它「翻譯」成外側的 token 後再跳出,或是把篩選器放寬成 when (cts.IsCancellationRequested),把「停止要求中的取消」直接視為正常 ── 這兩者請在設計上明確選擇其中一個。
flowchart TB
OWNER["負責停止的一方"] -->|"呼叫一次Cancel()"| CTS["CancellationTokenSource"]
CTS -->|"傳遞Token"| W1["Worker處理1"]
CTS -->|"傳遞Token"| W2["Worker處理2"]
CTS -->|"傳遞Token"| W3["函式庫的<br/>取消支援API"]
W1 -->|"確認IsCancellationRequested<br/>善後後自行結束"| E1["正常結束"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>=視為取消完成"]
W3 -->|"即使在等待中也立即中斷"| E3["取消完成"]
圖6:協調式取消的架構。要停止的一方只要呼叫 Cancel(),「何時、如何結束」則由各個處理自己決定。因此才能維持一致的狀態停止
函式庫端的作法也已有定則。可取消的操作應提供接受 CancellationToken 的公開方法,計算迴圈中要定期確認 IsCancellationRequested,或呼叫 ThrowIfCancellationRequested()。後者會送出 OperationCanceledException,Task 會把這個視為「取消完成」而非「失敗」。若同時想因應外部傳入的 token 與內部的原因(如逾時)來停止,可用連結 token 合成。7
6.2. 把 Thread.Abort 當成不存在
「從外部強制殺掉不聽話的執行緒」的 Thread.Abort,在 .NET Core / .NET 5 以後只會擲出 PlatformNotSupportedException,已經無法使用。因為在不知道對方正在執行到哪裡的情況下對執行緒丟例外,會導致資源釋放中斷或狀態損壞。若必須強制終止不因應(或無法寫成因應)協調式取消的第三方程式碼,官方指引是改在另一個行程中執行,再用 Process.Kill 停止。8
6.3. 等待時不要輪詢,而是用等待控制代碼
「用 Sleep(100) 迴圈等到旗標立起」這種寫法,同時浪費 CPU 與反應速度。執行緒之間的訊號可以使用 ManualResetEventSlim 或 SemaphoreSlim 等同步基本型別,能讓執行緒正確休眠到收到訊號為止。13 關於 Windows 上計時器精度與事件等待的取捨,詳見「Windows 上為什麼應先用事件等待而不是計時器等待」。
7. UI 執行緒的特殊情況 ── Windows 桌面應用程式的鐵則
Windows 桌面應用程式在通用原則之外,還有另一個強力限制。那就是UI 只能由建立它的執行緒(UI 執行緒)觸碰這條鐵則。
WinForms 的控制項並非執行緒安全,若從多個執行緒操作,控制項會被逼入不一致的狀態,成為競爭、死結、凍結的原因。Windows 要求應用程式準備一條專用執行緒來接收系統訊息,UI 的建立與操作都必須集中在那條執行緒上。9 WPF 的結構也完全相同,只有 UI 執行緒能變更 UI 元素。10
想從其他執行緒更新 UI 時,不要直接觸碰,而是要轉換成「委託給 UI 執行緒」的形式。
flowchart LR
OS["Windows<br/>滑鼠・鍵盤・重繪"] --> Q["UI執行緒的<br/>訊息佇列"]
BG["背景執行緒<br/>(繁重處理・通訊)"] -->|"用Control.Invoke /<br/>Dispatcher.InvokeAsync委託"| Q
Q --> UI["UI執行緒<br/>唯一能碰觸控制項的執行緒"]
BG -.->|"直接觸碰控制項"| NG["禁止<br/>成為競爭・死結・凍結的原因"]
圖7:UI更新要轉換成「委託」。背景執行緒的工作只做到把任務送進訊息佇列為止,實際觸碰控制項的永遠是UI執行緒本身
| 框架 | 委託的手段 |
|---|---|
| WinForms | Control.Invoke(同步) / Control.BeginInvoke(非同步) / .NET 9以後可用 Control.InvokeAsync9 |
| WPF | Dispatcher.Invoke(同步) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke(非同步)10 |
其中同步形式(Control.Invoke / Dispatcher.Invoke)需要特別注意。若 UI 執行緒正同步等待該 worker 完成,而 worker 又呼叫了 Invoke,就會形成互相等待的死結(正是第2章所述的循環等待)。來自背景的通知、進度回報應以非同步形式(BeginInvoke / InvokeAsync)為預設,同步形式僅限用在能明確保證 UI 執行緒不在等待自己的場合。
實務上還有更好的答案。若把在 UI 執行緒上啟動的處理寫成 async/await,await 會捕捉 UI 執行緒的 SynchronizationContext,自動讓後續處理在 UI 執行緒上恢復執行,因此手寫 Invoke 的場合會大幅減少。不過這並非無條件成立的性質。從背景回呼進入的程式碼,或是經過 ConfigureAwait(false) 之後的接續處理,並不會回到 UI 執行緒,若要在那條路徑上觸碰 UI,依然需要明確的分派。「繁重處理交給 Task.Run 或非同步 I/O,畫面反映結果則放在 await 之後」,這是現代 Windows 應用程式的基本形式。UI 執行緒與 async/await 的關係,整理在「以一頁整理 WPF/WinForms 的 async 與 UI 執行緒」的一張圖裡。
另外,若涉及 Office 整合或舊有元件等 COM 相關的情況,還會加上 COM 特有的執行緒模型(STA/MTA)這另一層。「明明在 UI 執行緒建立了 COM 物件,卻從別的執行緒呼叫而卡死」這類事故就屬於這一層的問題,詳見「COM STA/MTA 基礎」。
8. 若以原生程式碼(C++/C)撰寫
到目前為止的原則 ── 不直接建立執行緒、減少共享可變狀態、鎖的紀律、停止方式的設計 ── 在原生程式碼中同樣完全適用。改變的只是工具。C++ 的對應物是 RAII 與 std::jthread / std::mutex / std::atomic,C 的對應物則是 Win32 API 的 _beginthreadex、SRW 鎖、條件變數、停止事件模式。詳情請參考本系列的「C++篇」「C語言篇」,其中也涵蓋語言特有的陷阱(std::thread 的解構子、TerminateThread 的危險性、DllMain 與 loader lock 等)。
9. 驗證與除錯 ── 以「不會重現」為前提做好準備
多執行緒的臭蟲不能指望靠測試找出來,因為一般的單元測試會把「碰巧沒有發生競爭」的執行也算成成功。準備工作分三層來考量。
第一道防線,就是到目前為止的設計原則本身。共享可變狀態有 5 個的應用程式,和有 50 個的應用程式,該懷疑的地方數量差了 10 倍。審查時要用表格確認「共享的可變資料有哪些」「各自由哪個鎖保護」「鎖的取得順序是否唯一」「停止路徑在哪裡」。畫不出這張表的設計,即使能動,也還沒完成。
第二,不要隱藏異常,而是讓它可觀測。用 Monitor.TryEnter 的逾時偵測鎖等待的異常並記錄下來4,不吞掉丟給執行緒集區的處理中未被觀測到的例外,而是記錄下來,並事先準備好在 Hang 發生時能採集完整傾印(full dump)、確認所有執行緒堆疊 ── 與「偶爾才發生」的臭蟲交手,勝負取決於能從那一次發生中取得多少資訊。傾印與記錄的整備,詳見「Windows 應用程式崩潰時留下記錄與傾印的設計」。
第三,加上負載去搖晃它。用比核心數更多的並行度長時間運轉、把處理順序隨機化、人為插入延遲,讓開發機更容易踩中交錯的組合,這種壓力測試是出貨前揪出競爭的現實手段。偵錯執行時消失的臭蟲,換成發行組建加上高負載,往往就會重現。
10. 總結 ── 增加執行緒之前的檢查清單
多執行緒程式設計的最佳實踐,追根究柢不是「正確寫出同步的技術」,而是「不用寫同步就能過關的設計」。若在著手前能回答以下 8 個問題,就幾乎能避免重大事故。
- 那個處理是 CPU-bound 還是 I/O-bound(若是後者,答案不是執行緒,而是 async/await)
- 是不是正打算寫
new Thread(能否用 Task、Parallel、執行緒集區來表達) - 執行緒之間共享的可變資料有哪些,能否列舉出來
- 那些共享能否靠「分割」「不可變化」「用佇列傳遞」消除
- 剩下的共享資料,是否各自都有一個對應決定好的鎖
- 鎖的取得順序在所有執行緒中是否唯一,鎖定期間有沒有做外部呼叫
- 所有長時間處理是否都傳入了
CancellationToken,停止路徑能否說明清楚 - 觸碰 UI 的程式碼是否都集中在 UI 執行緒上
多執行緒的臭蟲,不會在寫下的那天出現,而是在忘記之後才在客戶端露出獠牙。反過來說,只要在設計階段先過一遍這份檢查清單,就能在寫程式碼之前,摘除「偶爾當機」「每月卡死一次」這種代價最高的故障。
相關文章
- 多執行緒實務最佳實踐 C++ 篇 ── 以 RAII 與 jthread 從結構上消除事故
- 多執行緒實務最佳實踐 C 語言篇 ── 以 Win32 API 的方式安全撰寫
- 多執行緒實務最佳實踐 Java 篇 ── 虛擬執行緒時代的定石
- C# async/await 的最佳實踐 - Task.Run 與 ConfigureAwait 的判斷表
- 以一頁整理 WPF / WinForms 的 async/await 和 UI 執行緒 - await 後的回歸處、Dispatcher、ConfigureAwait、.Result / .Wait() 的卡點
- COM STA/MTA 基礎 - 執行緒模型與避免 Hang 的思考方式
- Windows 上為什麼應先用事件等待而不是計時器等待 - 避免以約 15.6ms 粒度做輪詢
- 使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性
- Windows I/O 的深層(第 3 回) ── I/O 完成埠(IOCP)與 .NET 執行緒集區:async/await 的地下室
相關諮詢領域
合同會社小村軟體提供包含多執行緒化在內的業務應用程式設計審查、針對「偶爾當機、卡死」這類難以重現的問題進行原因調查(傾印分析、找出競爭發生點),以及既有應用程式並行化・非同步化的技術諮詢。從「想請你看看這個設計會不會發生競爭」這種階段開始討論也沒問題。
參考連結
-
Microsoft Learn, Task Parallel Library (TPL)。關於 TPL 是 .NET Framework 4 以後多執行緒・並行程式碼的推薦手段、會依可用的處理器動態調整並行度、負責工作切分・排入執行緒集區・因應取消・狀態管理、單次反覆工作量小的迴圈可能因並行化額外負擔而變慢、即使使用 TPL 也建議先具備鎖・死結・競爭狀態的基本理解等說明。 ↩ ↩2
-
Microsoft Learn, The managed thread pool。關於 ThreadPool 類別提供系統管理的 worker 執行緒集區、讓開發者能專注於應用程式的工作而非執行緒管理、.NET 廣泛在 TPL 的操作・非同步 I/O 完成・計時器回呼・已註冊的等待・Socket 連線等場合使用執行緒集區的說明。 ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism。關於並行迴圈有時會比循序執行更慢、務必先測量、應避免在並行迴圈內寫入共享記憶體並建議使用接受執行緒本地狀態的多載、For/ForEach 的各反覆不保證並行執行,反覆之間互相等待的程式碼可能導致死結的說明。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices。關於競爭狀態(計數器遞增被拆解成讀取・加總・寫回、因覆蓋而遺失的例子)與死結的定義、不使用 Thread.Abort 而應使用協調式取消、不應以型別或 this 作為鎖定對象、.NET 9/C# 13 以後應使用專用的 System.Threading.Lock 實例、C# 的 lock 陳述式在 finally 中保證呼叫 Monitor.Exit、用 Monitor.TryEnter 的逾時偵測死結、單純的狀態變更用 Interlocked 類別較快、靜態資料預設應為執行緒安全・實例資料預設應為非執行緒安全等設計方針的說明。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Microsoft Learn, System.Threading.Channels library。關於 channel 是生產者/消費者模型的 FIFO,內部自行管理同步、用 CreateBounded 可以建立有容量上限的 channel、達到上限時的預設行為是寫入端等待(Wait),也能選擇 DropOldest 等其他 FullMode、寫入比讀出快時會施加背壓的說明。 ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections。關於 System.Collections.Concurrent 底下的集合是透過細粒度鎖或無鎖機制實現執行緒安全、ConcurrentQueue 與 ConcurrentStack 不使用鎖、以 Interlocked 操作實作,能承受多執行緒高頻率新增・刪除的說明。 ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads。關於 CancellationTokenSource 與 CancellationToken 的協調式取消模型步驟、取消不是強制而是協調,停止方式由監聽端自行決定、輪詢・回呼註冊・等待控制代碼三種監看手段、ThrowIfCancellationRequested 送出 OperationCanceledException 後 Task 會視為取消完成、以連結 token 合成多個 token、函式庫應提供接受 CancellationToken 的公開方法的說明。 ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading。關於停止執行緒應使用 CancellationToken、.NET Core 與 .NET 5 以後 Thread.Abort 會擲出 PlatformNotSupportedException、.NET 5 以後在編譯期也會出現淘汰警告(SYSLIB0006)、若需要強制終止不因應協調式取消的第三方程式碼,應在另一個行程中執行並用 Process.Kill 的說明。 ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls。關於 WinForms 控制項的存取並非執行緒安全,從多個執行緒操作會導致不一致狀態・競爭・死結・凍結、所有控制項都必須在同一執行緒建立與存取,Windows 要求有一條專用的 UI 執行緒來配送系統訊息、從其他執行緒應透過 Control.Invoke、.NET 9 以後的 Control.InvokeAsync,或 BackgroundWorker 安全呼叫的說明。 ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF)。關於 WPF 中能變更 UI 的執行緒僅限一條、背景執行緒需將工作項目登錄到 UI 執行緒的 Dispatcher 委託執行、Dispatcher.Invoke 為同步,InvokeAsync 與 BeginInvoke 為非同步、Dispatcher 以優先順序佇列處理工作的說明。 ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library)。關於 Parallel.For / Parallel.ForEach 提供與 for 迴圈幾乎相同寫法的資料並行、不需要建立執行緒或將工作項目排入佇列、基本迴圈也不需要鎖、TPL 會切分資料來源並以多個執行緒處理,負載偏移時會重新分配的說明。 ↩
-
Microsoft Learn, BlockingCollection<T> Class。關於 BlockingCollection 是具備阻塞與容量限制的生產者/消費者實作、容量限制能防止生產端過度超前消費端、並非為非同步存取而設計,非同步的生產者/消費者應考慮使用 Channel<T> 的說明。 ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives。關於 Monitor 透過鎖定對象物件提供互斥,具有執行緒親和性、C# 中應使用 lock 陳述式而非直接使用 Monitor、ReaderWriterLockSlim 使寫入互斥、讀取可同時存取、SemaphoreSlim 是行程內專用的輕量號誌,Semaphore 則可具名用於跨行程同步的說明。 ↩ ↩2 ↩3
-
Microsoft Learn, Async semaphores, locks, and reader/writer coordination。關於 C# 的 lock 陳述式與 Lock 型別具有執行緒親和性,因此無法跨 await 使用(因為 await 前後執行接續的執行緒可能不同)、非同步程式碼中的互斥應使用計數為 1 的 SemaphoreSlim,搭配 WaitAsync 與 try/finally 中的 Release、節流用途可用 bounded 的 Channel 替代的說明。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
委外・委託開發 Windows 應用程式前該整理的事項
在委外・委託開發 Windows 應用程式之前,整理既有軟體改版、設備整合、COM/ActiveX、發布與更新、維護等應留意的重點。
多執行緒實務最佳實踐 C 語言篇 ── 以 Win32 API 的方式安全撰寫
C 語言 × Win32 的多執行緒有其定式:以 _beginthreadex 建立執行緒、SRW 鎖與條件變數、Interlocked、以停止事件 + WaitForMultipleObjects 設計停止流程。本文一併整理 TerminateThread 的危險性與 D...
多執行緒實務最佳實踐 C++ 篇 ── 以 RAII 與 jthread 從結構上消除事故
C++ 的多執行緒是資料競爭會變成未定義行為的世界。本文整理 std::thread 解構函式的陷阱、以 jthread 與 stop_token 設計停止機制、scoped_lock 的死鎖迴避、atomic 的正確定位,以及與 Win32 同步 API 的使用區分。
在 C#・PowerShell 中使用 WMI/CIM ── 硬體資訊取得・處理程序監控・遠端查詢的實務指南
取得 PC 序號、監控磁碟可用空間、偵測處理程序啟動,這些定番需求的標準答案就是 WMI/CIM。本文解說 Get-CimInstance 等 CIM Cmdlet 的用法、從舊版 Get-WmiObject 的遷移方式、C# 的 System.Management 與 C...
如何理解 Windows 的工作階段隔離 ── Session 0・RDP・多使用者同時執行
整理 Windows 應用程式開發者容易混淆的「工作階段」概念。說明服務無法顯示 UI 的 Session 0 隔離原因、RDP 連線時工作階段的行為、具名物件的工作階段隔離,以及共用 PC・RDS 環境常見的設計失誤,從實務角度解說。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
故障調查 & 長期運行故障
整理間歇性故障、通訊診斷、長期運行當機、失敗路徑測試基礎的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
常見問題
整理諮詢這個主題時常見的問題。
- 為什麼應該避免 lock(this) 或 lock(typeof(MyClass))?
- 因為鎖定對象的物件除了自己的程式碼之外,外部也看得到。this 就是自己的實例本身,因此能參照該實例的外部程式碼也能鎖定同一個物件,成為非預期競爭或死結的原因。typeof(MyClass) 更危險,因為 Type 物件在應用程式定義域內只有一個,會與完全無關的程式碼共享鎖。鎖定對象請使用不對外公開的專用物件。在 .NET 9 / C# 13 以後,建議將專用的 System.Threading.Lock 型別實例作為鎖定物件。
- 最多可以建立幾條執行緒?最佳的執行緒數量是多少?
- 「不自行決定執行緒數量」是現代的答案。只要使用 Task 或 Parallel 類別,執行緒集區就會依 CPU 核心數與負載自動調整並行度。自行不斷 new Thread 的設計,在核心數不同的客戶端機器上,容易變成過多或過少。應該留意的不是數量,而是工作的種類:耗用 CPU 的計算即使並行度超過核心數也不會變快;以等待 I/O 為主的處理,本來就不該增加執行緒,正確做法是改用 async/await 的非同步 I/O。
- 加上 volatile 就能保證執行緒安全嗎?
- 不能。volatile 保證的是該欄位的存取不會與前後的記憶體操作重新排序(取得/釋放語意),而不是「讀取、計算、寫回」這種複合操作的原子性。舉例來說,即使是 volatile int 的計數器,多個執行緒同時對它做 ++,加總還是會遺失。計數器的增減或比較後替換這類操作請使用 Interlocked 類別,需要一併保護多個變數時則使用 lock。volatile 值得考慮的情境幾乎僅限於停止旗標這類「只有一個執行緒寫入、其他執行緒只讀取」的單純案例,而且如今這種旗標也已標準化為用 CancellationToken 表示。
- 偶爾才發生的問題該如何判斷是不是多執行緒造成的?
- 值得懷疑的徵兆有三個:「同樣的操作有時會重現、有時不會」「一旦掛上偵錯器或加了記錄,就不再重現」「只在負載高或剛啟動時發生」。時序依賴的臭蟲,特徵就是每次執行結果都不同,這正是競爭狀態的定義本身。排查時,先列出所有共享的可變資料,再逐一整理成表格,記錄「各自由哪個鎖保護」。只要有一處存取沒有被保護,那裡就是嫌疑犯。若是發生 Hang,就取得所有執行緒的堆疊,確認彼此是否在互相等待鎖而形成循環。可以在 Visual Studio 偵錯器中暫停後查看「並行堆疊」,若是在正式環境,則採集傾印檔(dump)進行分析。