Parallel.ForEach适用于计算密集、迭代无依赖且数据量大的场景;单次迭代≥10ms、元素≥数千、无共享可变状态时才推荐使用,IO操作应改用异步方案。

Parallel.ForEach 不是“加速万能开关”,它只在计算密集、迭代间无强依赖、且数据量足够大时才值得用。小集合、IO 主导或共享状态频繁修改的场景,强行并行反而更慢,还容易出竞态。
什么时候该用 Parallel.ForEach 而不是普通 foreach
核心判断依据就两条:
- 单次迭代耗时 ≥ 10ms(比如做一次简单数学运算不算,但解析一个 JSON 字符串、校验一段 RSA 签名、处理一张 2MB 图片缩略图就算)
- 集合元素数量 ≥ 数千(
Enumerable.Range(0, 100)这种基本没意义) - 迭代之间不共用可变对象——比如别在循环体里反复写同一个
List<T>,也不去改同一个Dictionary<K,V>的值
常见误用:把读文件、发 HTTP 请求、调数据库这种 IO 操作塞进 Parallel.ForEach,结果线程池被占满,吞吐反而暴跌。这类任务该用 Task.WhenAll + 异步方法。
Parallel.ForEach 的三个重载怎么选
最常用的是双参数版本:Parallel.ForEach(IEnumerable<T>, Action<T>),适合纯计算、无状态、不关心顺序的场景。
真正需要控制并发度、捕获异常或做线程本地缓存时,得用带 ParallelOptions 或 localInit/localFinally 的重载:
- 限制最大并行数:传入
new ParallelOptions { MaxDegreeOfParallelism = 4 },避免 CPU 过载或服务端限流 - 想在线程开始前初始化资源(比如每个线程配一个独立的
HttpClient实例):用四参数重载,localInit返回新实例,localFinally负责释放 - 异常会直接中断整个循环——除非你手动包在
try/catch里,或用ParallelLoopResult.IsCompleted判断是否全成功
共享集合写入的坑:为什么结果少了、重复了、甚至抛 InvalidOperationException
错误写法示例:
var results = new List<int>();
Parallel.ForEach(data, item => {
results.Add(Calculate(item)); // ⚠️ 多线程同时 Add,可能丢数据或崩溃
});
正确做法有三种:
- 用线程安全集合:
ConcurrentBag<T>、ConcurrentQueue<T>(注意ConcurrentBag不保证顺序) - 用
localInit+localFinally做线程本地收集,最后合并:localInit返回空List<T>,body往里面 Add,localFinally把这个 List 加到全局ConcurrentBag - 根本别边算边存——先用
AsParallel().Select(...).ToList()得到结果数组,再统一处理
别迷信“加 lock 就安全”:锁粒度太大会抵消并行收益,而且容易死锁。
调试和性能验证不能靠感觉
并行代码出问题往往静默失败:结果错、少数据、CPU 占满但进度卡住。必须实测:
- 用
Stopwatch对比foreach和Parallel.ForEach的耗时,别只看“快了没”,要看“快多少、是否稳定” - 开 Visual Studio 的“并行堆栈”窗口(Debug → Windows → Parallel Stacks),看线程是否真在跑、有没有卡在锁或等待上
- 监控
ThreadPool.GetAvailableThreads,如果可用线程持续接近 0,说明你压爆了线程池
最常被忽略的一点:Parallel.ForEach 默认使用 ThreadPool 线程,而 ThreadPool 是为短时任务优化的。如果你的单次迭代动辄几百毫秒,它会不断扩容线程,引发 GC 压力和上下文切换开销——这时候该考虑 Task.Run 手动调度,或者换回同步循环。


















