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