1. 在 Parallel.For 與 Parallel.ForEach 中例外的運作
在普通的 for 迴圈裡很簡單:如果在迴圈體內丟出例外 — 迴圈立刻結束,例外會往外拋。在平行迴圈裡情況不同。我們來拆解。
所有例外都會被收集到一個「包」裡
當平行迭代(Parallel.For/ForEach)的某一次執行中發生例外時,它不會立即向外拋出,而是被打包。這個過程會繼續:其他迭代要麼完成,要麼也丟出例外。結果是:當平行迴圈結束執行(或被強制中斷)時,所有「丟出的」例外會被收集並以一個 AggregateException 物件一起拋出。
AggregateException 是一個「容器」,裡面存放了在平行迭代期間發生的所有例外集合。這很方便:我們可以一次拿到所有錯誤(或者至少在主要執行緒完成前收集到的那些錯誤)。
實務上的樣子
範例:平行處理,偶爾會丟出例外
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
int[] numbers = { 1, 2, 0, 4, 0, 6, 7, 8 };
try
{
Parallel.ForEach(numbers, number =>
{
// 我們故意除以這個數,有時候它等於零!
// 這會引發 DivideByZeroException
int result = 100 / number;
Console.WriteLine($"100 / {number} = {result}");
});
}
catch (AggregateException ex)
{
Console.WriteLine("在平行迴圈中發現錯誤!");
// 遍歷所有發生的例外
foreach (var inner in ex.InnerExceptions)
{
Console.WriteLine($"類型: {inner.GetType().Name} — 訊息: {inner.Message}");
}
}
}
}
會發生的情況:
- 主集合裡有零,而除以 0 在數學(和 C#)裡是禁區:會產生 DivideByZeroException。
- 平行迴圈開始處理。一旦某處發生除以零 — 迴圈不會立刻停止,而是讓已經開始執行的迭代繼續執行。
- 當所有執行緒完成工作(有的發生錯誤、有的沒有)後,外層會拋出一個包含所有發生例外的 AggregateException。
把例外處理機制視覺化
flowchart LR
A[執行緒 1]
B[執行緒 2]
C[執行緒 3]
D[執行緒 4]
E[Parallel.ForEach]
F[例外 1]
G[例外 2]
H[AggregateException]
subgraph 迭代
A --> F
B --> G
C --> E
D --> E
F --> H
G --> H
E --> H
end
圖中可見:不同執行緒可能遇到不同錯誤,最終它們都會被「打包」成單一的 AggregateException。
2. 錯誤處理的實務要點
如何處理 AggregateException?
當捕捉到 AggregateException 時,通常有兩種情境:
- 把所有錯誤顯示給使用者(或寫到日誌),以便累積經驗。
- 判斷哪些錯誤是關鍵的,哪些是雜訊:決定整個操作要不要算失敗,或許忽略某些錯誤。
常見模式:透過 Handle 來處理
try
{
Parallel.For(0, 10, i =>
{
if (i == 3 || i == 7)
throw new InvalidOperationException($"錯誤在迭代 {i}");
Console.WriteLine($"已處理: {i}");
});
}
catch (AggregateException ex)
{
ex.Handle(e =>
{
if (e is InvalidOperationException)
{
Console.WriteLine("捕捉到錯誤: " + e.Message);
// true = 錯誤被視為已處理
return true;
}
// false = 不處理,會再拋出
return false;
});
}
這種做法允許你只處理那些你認為是「正常」的錯誤,其他的則繼續往上傳遞,以免忽略真正的重大故障。
實作上的有趣(且危險)細節
迴圈何時停止?
當某次迭代發生例外時,Parallel.For/ForEach 不會再啟動新的迭代,但已經開始執行的迭代會繼續執行。等所有活動中的迭代結束後,會拋出 AggregateException。如果執行緒很多,仍會有「尾巴工作」完成——因此可能會有多個錯誤被收集。
如果不捕捉例外,應用會當掉。
如果不把 Parallel.For/ForEach 包在 try-catch 裡,應用會在所有迭代完成後於第一次遇到的錯誤位置崩潰——這對使用者來說很不友善。
把例外「留在」迴圈內處理。
有時你想要讓個別迭代的錯誤不破壞整體流程,那就直接在平行迴圈體內捕捉例外:
Parallel.ForEach(numbers, number =>
{
try
{
int result = 100 / number;
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine($"在數字 {number} 發生錯誤: {ex.Message}");
}
});
這種方式適合你不需要把所有例外一次性收集的情境——你在出錯時立即處理(例如寫日誌)。但要小心:如果這麼做了,就不會產生 AggregateException,也就無法從外部一次性知道整體是否完全正常。
當呼叫 Break() 或 Stop() 時。
如果迭代呼叫了 ParallelLoopState.Break() 或 ParallelLoopState.Stop(),迴圈會嘗試停止新的迭代:Break() 在當前索引之後結束迭代,而 Stop() 會停止所有迭代。然而如果同時發生例外,例外會被保留並在所有活動迭代結束後以 AggregateException 拋出。
3. 有用的細節
普通迴圈 vs 平行迴圈的例外
在普通迴圈中,任何錯誤都會導致工作立即結束:例外向外拋出,一切停止。
在 C# 的平行迴圈中採取比較折衷的策略:已啟動的任務會繼續執行,只有在整個過程完成後才把所有錯誤一次性「吐出」。這讓你能夠收集到所有錯誤,而不會遺失其中的任何一個,然後在迴圈結束後再做決策。
4. 在 Parallel.For 和 Parallel.ForEach 處理例外時常見的錯誤
錯誤 №1:忽略 AggregateException。
如果不捕捉 AggregateException,應用會在所有迭代結束後當掉,可能導致資料遺失,並在伺服器或 GUI 應用中造成故障。
錯誤 №2:在沒有 try-catch 的情況下呼叫 .Wait()。
對 Parallel.For/ForEach 呼叫 .Wait() 而不處理 AggregateException 會導致未處理的例外,讓診斷變得困難。
錯誤 №3:忽視重複錯誤。
多個相同的錯誤(例如多次除以零)可能是由重複的資料引起。若不分析 InnerExceptions,可能會忽略根本原因。
錯誤 №4:吞掉所有例外。
在迴圈內使用 catch (Exception) { /* 空 */ } 會隱藏錯誤,導致重要資訊遺失與「幽靈」型 bug。
不同迴圈中錯誤行為比較
| 選項 | 普通 for/foreach | Parallel.For / ForEach |
|---|---|---|
| 例外被處理的時間 | 立即 | 在所有迭代完成後 |
| 錯誤格式 | 單一 exception | 包含集合的 AggregateException |
| 其它迭代 | 不再執行 | 已啟動的繼續執行 |
| 在迴圈體內捕捉錯誤 | 可以 | 可以 |
| 在外部捕捉錯誤 | 可以 | 可以,透過 AggregateException |
要點與面試短問:
- 如果不處理 AggregateException 會怎樣?
應用會在所有迭代結束後崩潰——不論錯誤發生在何時或何處。 - 能否知道錯誤發生在哪一個迭代?
只有你在例外裡加入索引或資料資訊時才知道。 - AggregateException 可能是空的嗎?
不會,只有當至少有一個內部例外時它才會被建立。如果沒有錯誤,就不會拋出它。 - 如果在迴圈內捕捉例外,外面還會有錯誤嗎?
不會,因為你已經在內部處理了,外部就不會再收到 AggregateException。
現在你不僅能啟動多執行緒的迴圈,還能優雅地處理它們的平行「故障」!如同一貫提醒:對多執行緒要小心,它喜歡驚喜,尤其是那些沒有人處理的。
GO TO FULL VERSION