这一节我们深究并行编程里被问得最多的两个点:Parallel类的三大方法(For、ForEach、Invoke),以及容易让人绕晕的四种编程模型(SPM、APM、EAP、TAP)。如果你已经在用Task或async/await,但对Parallel的底层分区机制不太清楚,或者在面试中被问到 APM、EAP 时一脸懵,那这篇内容正好补上这块短板。我会按实际项目里能用到的顺序来讲,先把模型脉络理清,再把三个方法逐个拆开,最后附上我踩过的坑。
1. 先理清四种编程模型
先说个扎心的事实:很多人写并行代码写了好几年,也分不清 APM、EAP、TAP 到底有什么关系。这不怪你,因为这三个模式本身就有点像历史版本迭代留下的“三代同堂”,而 SPM 更是常常被忽略的对照组。
1.1 SPM:最朴素的同步编程模型
SPM 全称是 Synchronous Programming Model,中文叫同步编程模型,也是我们最早学到的那种写法:一行代码执行完,再执行下一行。比如读取文件、请求接口、计算累加,都是阻塞式的。
var data = File.ReadAllText("config.json"); Console.WriteLine(data.Length);这段代码在ReadAllText执行期间,当前线程直接挂起等待,直到文件读完才继续。SPM 的优点是好懂、好调试、好排查问题,但缺点也很明显:一个线程一旦阻塞,什么活都干不了。如果在 UI 线程上阻塞,界面就是一卡一卡的;如果在服务器线程上阻塞,线程池里的线程就白白占着资源。
咱们聊并行编程,通常默认已经有了 SPM 作为对照组。所以在理解Parallel时,你可以把 SPM 想象成一个人一口一口吃饭,Parallel就是叫了三五个同桌帮你一起夹菜,而后面讲的 APM、EAP、TAP,则是把盛菜、端菜、吃菜这些动作拆开来处理。
1.2 APM:Begin/End 的古老套路
APM 全称 Asynchronous Programming Model,异步编程模型,是 .NET 早期提供的第一代异步模式。它的核心特征是一对方法:BeginXxx和EndXxx。典型代表是FileStream.BeginRead、HttpWebRequest.BeginGetResponse。
var request = (HttpWebRequest)WebRequest.Create(url); var result = request.BeginGetResponse(null, null); // 继续做别的事情 var response = request.EndGetResponse(result);这里面的设计逻辑是:BeginGetResponse立即返回一个IAsyncResult,表示操作还没完成;你可以在别处继续干活,等真正需要结果时再调用EndGetResponse去拿。听起来挺灵活,但实际用起来非常难受,因为你需要自己管理回调、轮询IsCompleted,还得处理IAsyncResult和回调线程的上下文问题。
APM 最大的问题是:它把“异步”这件事做成了“可以异步,但写起来很辛酸”。代码里到处是Begin、End、回调,主线逻辑被拆得七零八落。我最早接触异步时,因为忘调EndGetResponse,导致大量句柄泄漏,程序跑一个晚上就崩了。所以这个模型现在基本只存在于老代码和面试题里。
1.3 EAP:事件驱动的异步模式
EAP 全称 Event-based Asynchronous Pattern,基于事件的异步模式,是 .NET 2.0 时代为了配合 WinForms 和 WPF 的 UI 线程模型推出的。它的核心是:一个异步方法配合几个事件,比如BackgroundWorker的DoWork、ProgressChanged、RunWorkerCompleted。
var worker = new BackgroundWorker(); worker.DoWork += (s, e) => { // 耗时操作 e.Result = ComputeResult(); }; worker.RunWorkerCompleted += (s, e) => { // 回到 UI 线程,可以直接更新控件 label.Text = e.Result.ToString(); }; worker.RunWorkerAsync();EAP 的开发体验比以前好一点,因为事件天然支持 UI 线程的封送(也就是回调自动回到主线程),做进度条特别方便。但它的问题也很明显:事件只能表达“开始”和“完成”,你没法方便地把多个异步操作组合起来。比如要同时下载两个文件,再合并处理,EAP 写起来就是多套事件互相嵌套,可读性堪忧。
现在大家写 Windows Forms 程序,基本都用async/await替代了 EAP。但需要注意的是,EAP 思想在自定义组件里仍然常见,比如某些 SDK 的“连接成功”“数据到达”事件,本质上还是 EAP 的变体。
1.4 TAP:现代开发的主流
TAP 全称 Task-based Asynchronous Pattern,基于任务的异步模式,也是目前最推荐、最通用的异步模式。它的核心是Task和Task<T>,配合async/await让异步代码读起来几乎和同步代码一样。
async Task<int> ReadAndCountAsync(string path) { var content = await File.ReadAllTextAsync(path); return content.Length; }我见过很多刚接触异步的同学会问:TAP 和 Parallel 有什么关系?其实两者解决的问题不同。TAP 解决的是“异步”——也就是不阻塞当前线程,适合 I/O 密集型操作;Parallel解决的是“并行”——也就是利用多个线程同时计算,适合 CPU 密集型操作。在实际项目里,它们经常配合使用,比如先用Parallel.ForEach并行下载(其实下载是 I/O,不推荐),做成生产者消费者模型时,生产端用Task.Run,消费端用Parallel处理 CPU 密集任务。
把这四种模型放在一张表里,对比会更清楚:
| 模型 | 设计年代 | 核心特征 | 适合场景 | 现在的状态 |
|---|---|---|---|---|
| SPM | 很早 | 同步阻塞 | 逻辑简单、可容忍等待 | 默认基线 |
| APM | .NET 1.x | Begin/End | 老代码、底层互操作 | 基本淘汰 |
| EAP | .NET 2.0 | 事件驱动 | UI 异步、进度反馈 | 被 TAP 替代 |
| TAP | .NET 4.0 起 | Task + async/await | 现代 I/O 异步、全场景 | 推荐 |
理清了这些模型,再看 Parallel 就不会迷糊。Parallel属于并行任务库,底层用的是 TAP 的Task做调度,对外暴露的 API 却保留了一种简单同步调用的手感。比如Parallel.For本身是同步方法,调用它会阻塞当前线程直到所有迭代完成,但它内部会并行执行多个迭代。这一点非常重要,下面拆解三个方法时会反复提到。
2. Parallel.For:最常用的并行循环
Parallel.For是并行编程里最基础、最常用的方法。它解决的核心问题很简单:当你的for循环每一轮迭代都是独立计算、没有先后依赖时,为什么不让多个线程同时跑?
2.1 原理与核心参数
一个普通for循环:
for (int i = 0; i < 1000; i++) { Compute(i); }改成Parallel.For:
Parallel.For(0, 1000, i => { Compute(i); });表面上看只是换了个循环写法,但它内部的机制完全不同。Parallel.For会根据当前 CPU 核数和负载情况,把0~999这个区间划分成多个子区间,然后交给多个线程同时处理。这个分配动作由内部的分区器(Partitioner)负责,默认采用的是范围分区,逻辑上就像一个老师把 1000 道题分配给几个学生同时做,而不是从头到尾一个人做完再叫下一个人。
Parallel.For有几个常用重载,我建议重点掌握带三个参数和最完整的ParallelLoopResult版本:
Parallel.For( int fromInclusive, int toExclusive, Action<int> body );还有带ParallelOptions和TState的版本,后面实操里会提到。基础版本里,你应该注意toExclusive是开区间,也就是“包含前,不包含后”。和for (int i = 0; i < n; i++)一样,Parallel.For(0, n, ...)会执行到n-1。
2.2 实操:并行求和
求一个大型数组的和是经典入门案例,但它有个陷阱:如果直接在闭包里累加局部变量,会出现数据竞争。因为多个线程同时读写同一个变量,结果一定不对。
long total = 0; Parallel.For(0, 10000000, i => { total += i; // 错误示例:存在竞态条件 });为什么不对?total += i在底层是“读-加-写”三步操作,两个线程可能同时读到同一个旧值,然后分别加完写回,其中一个加的结果就覆盖掉了。这就是典型的竞态条件。
正确做法有两种。第一种是使用Interlocked.Add,它能在底层保证原子操作:
long total = 0; Parallel.For(0, 10000000, i => { Interlocked.Add(ref total, i); });第二种是使用Parallel.For提供的局部状态重载,每个线程先维护自己的局部变量,最后再合并。这种方案性能更好,因为Interlocked操作在高并发下会争用同一个内存位置导致性能下降。
Parallel.For( 0, 10000000, () => 0L, (i, state, localTotal) => localTotal + i, finalLocalTotal => Interlocked.Add(ref total, finalLocalTotal) );我来解释一下四个参数的含义。第一个是循环范围;第二个是每个线程的局部初始值工厂,每个线程开始时都会执行一次() => 0L;第三个是每个迭代执行的循环体,接收三个输入:当前索引i、循环状态state、线程局部值localTotal,返回值会更新线程局部值;第四个是每个线程结束后执行的合并,把局部值累加进总结果。
我用一个生活化比喻帮助理解:假设一个公司的财务部门有 10 个线程,每个线程相当于一个小组长。() => 0L相当于给每个小组长发一个空的记账本,循环体内每笔账先记在组长的本子上,等所有迭代结束,再统一把每本记账本的数据汇总,而不是大家挤在一个总账本上抢笔写字。这样一来,总账本只有在合并时才被争用,竞争次数从每次迭代一次,变成了每个线程一次。
实际项目里,Parallel.For经常用于数组批量处理。比如逐像素处理图片,把一张大图的每个像素区域映射到不同的迭代索引:
byte[] pixels = LoadPixels(); Parallel.For(0, pixels.Length, i => { pixels[i] = AdjustBrightness(pixels[i]); });注意数组的每个元素这里是独立读写的,不同索引之间没有交叉,所以安全。如果你处理的是共享集合,就要小心了。
2.3 避坑要点
第一,不要在循环体内修改循环控制变量之外的外部对象,除非做好同步。比如往List<T>里Add,在多线程下可能触发IndexOutOfRangeException或者数据丢失。要用并发集合,比如ConcurrentBag<T>、ConcurrentQueue<T>,或者最后再合并。
第二,Parallel.For不能保证迭代顺序。如果你的输出对顺序有依赖,比如需要按顺序写日志,就不能简单用Parallel.For。我遇到过有新人把“批量发送指令”写成Parallel.For,结果指令到达服务器的顺序乱了,业务上出现竞态,被运维抓包。所以,只有在“迭代之间互相独立”时才适合并行。
第三,注意闭包捕获问题。如果你在循环体内使用外部局部变量,而这个变量在每个迭代中都变化,本质上是共享变量,可能导致不可预期的结果。最好的做法是让循环体只依赖索引参数,不要依赖外部状态。
第四,循环体内避免Thread.Sleep、Task.Wait等阻塞操作。阻塞会导致线程池线程被占用,让并行效果大打折扣。如果循环体本身是阻塞式 I/O,建议改为Task异步模型,而不是Parallel。
3. Parallel.ForEach:集合场景的并行利器
如果说Parallel.For对应普通for,那Parallel.ForEach对应的是foreach。它的使用频率在实战中往往更高,因为处理集合是日常开发最常见的需求。
3.1 与 For 的区别
Parallel.For是索引驱动,适用于按索引访问的数组或列表;Parallel.ForEach是枚举器驱动,适用于任意IEnumerable<T>。它不需要知道集合长度,只要提供一个可遍历的集合就行。
一个典型场景是批量处理一批订单:
List<Order> orders = GetOrders(); Parallel.ForEach(orders, order => { Process(order); });Parallel.ForEach在内部也会分区,但和For的固定范围分区不同。它默认使用按块分配的分区方式,比如把 1000 个订单拆成多个小块,线程处理完一块再去拿下一块,动态实现负载均衡。这就像流水线上的几个工人,活儿不是提前固定分好的,而是谁干完手头的就去箱子里拿下一批。这种动态分配能有效避免某个线程干了轻活提前结束,而另一个线程还在苦干重活的不均衡问题。
如果你处理的集合是IList<T>或数组,Parallel.ForEach也支持你显式指定分区策略:
var source = Enumerable.Range(0, 1000000).ToList(); Parallel.ForEach( Partitioner.Create(source, EnumerablePartitionerOptions.NoBuffering), item => Compute(item) );Partitioner.Create的NoBuffering选项表示不缓冲,追求更均匀的负载,但可能会增加调度开销。默认情况下,Parallel.ForEach会做一定程度的缓冲区优化,减少频繁从共享枚举器取数据的竞争。
3.2 实操:并行处理文件
我拿一个实际项目举例:当时要处理某个目录下几千个日志文件,读取并解析出关键告警。单线程逐文件处理耗时很久,我写了个简单的比较脚本,改成Parallel.ForEach后速度提升非常明显。
string[] files = Directory.GetFiles(@"D:\logs", "*.log"); Parallel.ForEach(files, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, filePath => { var lines = File.ReadAllLines(filePath); var alerts = lines.Where(line => line.Contains("ERROR")).Count(); Interlocked.Add(ref totalErrors, alerts); } );这里我特意设置了MaxDegreeOfParallelism为 CPU 核数,避免一次性启动太多线程导致过度开销。Parallel.ForEach的重载里,ParallelOptions还可以设置取消令牌,这里先拿它控制并行度。
需要注意的是,ReadAllLines本身是同步 I/O。如果文件很大,更好的做法是用异步读取,但因为Parallel的循环体是Action<T>同步委托,没法直接await。如果你真的需要异步 I/O,就得考虑把并行模型换成Task.WhenAll方案:
var tasks = files.Select(path => ProcessFileAsync(path)); await Task.WhenAll(tasks);所以这里也再次说明:Parallel适合 CPU 密集型,Task异步适合 I/O 密集型。日志文件场景,如果文件数量大但单个文件小,Parallel.ForEach也够用;如果单文件就很大,建议用异步逐行读取。
3.3 有序性处理
实战中经常有人问:Parallel.ForEach能不能保证处理顺序?答案是:默认情况下不仅不保证处理顺序,而且每一次运行的顺序都可能不同。
如果你真的需要最终结果有序,有三种思路:
第一种,利用索引重排。如果集合是数组,可以把结果放在数组中,比如results[i] = ...,因为数组不同位置的写入互相独立。但问题在于并行输出时你不知道哪个线程处理了哪个索引,需要先记录对应索引,最后统一排序。
第二种,使用AsParallel().AsOrdered()的 PLINQ 语法。PLINQ 是并行 LINQ,支持保持顺序:
var results = orders .AsParallel() .AsOrdered() .Select(order => Process(order)) .ToList();注意AsOrdered会引入排序开销,但保证输出顺序和输入一致。如果你既想要顺序稳定,又想要并发执行,PLINQ 的AsOrdered是不错的选择。
第三种,使用带索引的Parallel.ForEach重载,它把输出写给指定索引位置:
var results = new OrderResult[orders.Count]; Parallel.ForEach( orders, new ParallelOptions { MaxDegreeOfParallelism = 4 }, (order, state, index) => { results[index] = Process(order); } );这里的index就是输入集合的索引。由于数组不同下标写入不冲突,最后results的顺序和orders一致。这个方案我实际项目里用过很多次,比AsOrdered更直接。
3.4 注意事项
第一,不要在大集合上使用Parallel.ForEach而保留共享链表的写入操作。比如每个迭代都往同一个List<T>里Add,一定会出问题。我在一次性能测试中,用ConcurrentBag<T>收集结果,虽然用了并发安全集合,但输出顺序全乱了,后来改成上面说的索引写入,既安全又保序。
第二,处理到“大量小项”时,并行调度开销可能比串行还大。比如集合只有几百个元素,每个元素处理只要几微秒,用Parallel.ForEach反而慢,因为线程调度和分区开销更大。经验规则是:单个任务至少中等耗时(毫秒级),或者集合元素达到上百万级,才值得并行。
第三,如果集合是延迟加载的IEnumerable<T>(比如yield return生成的序列),Parallel.ForEach会先枚举集合的一部分,再并行处理。但如果原始的延迟枚举器本身不是线程安全的,可能会出问题。稳妥起见,先ToList()再并行。
4. Parallel.Invoke:多个任务的“同时出发”
Parallel.Invoke和前两个方法不一样,它不是循环结构,也不会自动遍历集合。它要做的事情很简单:同时执行若干个独立的操作,并且等所有操作完成后再返回。
4.1 原理与使用
Parallel.Invoke( () => DownloadTemplate(), () => DownloadData(), () => DownloadImage() );上面这段代码会尽量让三个操作并行执行,当然实际并发度取决于 CPU 核数和系统当前负载。Parallel.Invoke内部本质上相当于:
var tasks = new[] { Task.Run(() => DownloadTemplate()), Task.Run(() => DownloadData()), Task.Run(() => DownloadImage()) }; Task.WaitAll(tasks);但它带来了两个额外好处:第一,你是以声明式的方式表达“这些操作互不依赖”,代码意图更清晰;第二,Parallel库内部对线程的使用做了负载均衡,比直接开三个Task.Run再WaitAll更精细。
Parallel.Invoke也支持传入最大并行度参数:
Parallel.Invoke( new ParallelOptions { MaxDegreeOfParallelism = 2 }, () => DoWork("A"), () => DoWork("B"), () => DoWork("C") );这里最多同时运行两个委托,第三个会等前两个有空位再执行。和SemaphoreSlim控制并发限流的思路类似。
4.2 实操:并行加载多个独立数据源
真实项目里有个非常典型的场景:程序启动时需要加载配置、缓存、用户信息等多项数据。如果串行加载,启动时间就等于各项耗时之和;如果用Parallel.Invoke,启动时间约等于最慢的一项。
var config = new AppConfig(); var cache = new CacheService(); Parallel.Invoke( () => config.Load(), () => cache.Load(), () => LoadUserPreferences() );这里有个隐含要求:三个操作必须互相独立。如果某个操作依赖另一个操作的输出,就不能放进Parallel.Invoke。我在刚接触时犯过错误:让操作 A 写配置,操作 B 读配置,结果有时候读到旧值。后来改成 A 完成后再并行 B 和 C,才稳定下来。所以行动前先画个依赖图,有依赖的串行,无依赖的并行,这个朴素的准则能避免很多诡异问题。
另外,Parallel.Invoke适合CPU密集型操作。如果你传的是异步 I/O 委托,比如async () => await request(),实际上Parallel.Invoke只会启动这些异步操作,然后不等它们完成就会返回,因为它不理解async委托的返回Task。这就引出“不要随便在Invoke里用 async lambda”这个坑。如果你需要异步方式并行执行多个 I/O 操作,优先选择:
await Task.WhenAll( DownloadTemplateAsync(), DownloadDataAsync(), DownloadImageAsync() );4.3 注意事项
第一,Parallel.Invoke的参数是委托数组,但它会在所有委托执行完毕后返回。如果其中一个委托抛出异常,Parallel.Invoke会抛出AggregateException,把多个异常包装在一起。处理时要拍平:
try { Parallel.Invoke(Action1, Action2); } catch (AggregateException ex) { foreach (var inner in ex.InnerExceptions) { Console.WriteLine(inner.Message); } }注意如果多个委托同时抛出异常,AggregateException的InnerExceptions里会包含多个异常。这比普通try-catch只能捕到一个异常要麻烦,但也更全面地反映了并行执行的真实情况。
第二,Parallel.Invoke不能保证操作执行的启动顺序。虽然它内部尝试差不多同时调用,但因为线程调度原因,任务 B 有时可能比任务 A 更早执行。如果你的业务对启动顺序有强依赖,必须先串行。
第三,嵌套Parallel调用时,要小心线程池饥饿。比如外层用Parallel.Invoke调了三个操作,每个操作内部又调用Parallel.For并行 8 路,总并行度可能远超 CPU 核数,导致上下文切换开销膨胀。我一般会用ParallelOptions控制每一层并行度,或者干脆把内层改成单线程。
第四,不要在执行完Parallel.Invoke后立即使用未同步的外部共享变量。由于并行操作可能在任意一个线程上执行完成,你必须确保所有共享变量的读写都做了同步,然后再继续后续逻辑。
5. 常见问题与排查技巧实录
这部分我把平时项目里遇到的高频问题整理成一张速查表,每一个都附上了解决思路,可以当作排障手册参考。
| 问题 | 可能原因 | 解决思路 |
|---|---|---|
| 并行循环结果错误 | 竞态条件,多个线程同时读写共享变量 | 使用锁、Interlocked,或线程局部存储 |
| 并行后反而变慢 | 任务太小,调度开销大于收益 | 增大任务粒度,或改用串行 |
| 并行时内存暴涨 | 每个任务都创建大对象,线程争用加剧 | 控制MaxDegreeOfParallelism,减少对象分配 |
| 程序卡死 | 并行循环里执行了Task.Wait或异步阻塞 | 换成Task.WhenAll或调整模型 |
| 系统 CPU 满载,界面无响应 | 并行任务太多且都是 CPU 密集 | 降低并行度,或放到后台线程 |
| 写入集合时抛异常 | List<T>并发写入不安全 | 改用并发集合或索引写入 |
| 异常信息丢失 | 并行操作抛出AggregateException,只捕获外层异常 | 遍历InnerExceptions拍平 |
| 需要保持顺序时结果乱 | Parallel默认不保序 | 使用索引写入或AsOrdered |
| 依赖异步 I/O 的操作没有真正并行 | 把async委托传给了Parallel | 改用Task.WhenAll |
单独讲几个典型排查经历。
第一个是“Parallel.For求和结果不对”。我排查过类似的案例,最后定位到total += i竞态。如果改成Interlocked.Add结果对了,但性能也不算最理想;改成线程局部重载之后,性能又上了个台阶。排查竞态最简单的方式:把并行度设成 1,如果结果稳定复现错误,说明不是并行调度导致的,而是循环体本身有问题;把并行度调大,错误出现变多,基本可以断定是共享状态问题。
第二个是“并行下载没有任何加速”。当时用的是Parallel.ForEach加HttpClient同步请求。问题很明确:同步 I/O 在并行循环里不仅不加速,还会因为占用线程而拖垮整体。后来我把实现改成SemaphoreSlim+Task.WhenAll限流并发,速度立刻提升了。这个教训也加深了我的认知:一定要区分“CPU 密集”和“I/O 密集”。
第三个是“输出日志顺序混乱”。业务需要按时间顺序输出处理记录,但并行处理时,各线程打印的日志交错在一起。我们最终采用了“每个线程先写自己的本地缓冲区,最后按索引合并”的方案,既享受了并行处理的速度,又保住了输出顺序。如果你只是简单加锁,那并行度直接退化为串行,不建议这么做。
第四个是“进程内存持续上涨”。某次并行处理大量位图,每个迭代都生成临时Bitmap,还来不及释放,下一个任务又开始分配。排查后发现是并行度太高,垃圾回收跟不上。最后加了一个ParallelOptions { MaxDegreeOfParallelism = 4 }限制并行度,并在循环里显式释放临时图像对象,内存曲线就平稳了。
还有一个必踩的坑:Parallel.For中调用Thread.Sleep或Task.Delay().Wait(),会让线程池线程“沉睡”。如果多个线程同时睡眠,线程池又不想新增线程,就可能出现任务排队等待线程数恢复的情况,最终并行效率还不如串行。对这种情况,尽量把等待逻辑抽出来,用异步模型处理。
我个人的习惯是:启用并行前先给任务分层。第一层是“能不能并行”的分险评估,看任务之间是否独立、有没有共享状态、异常如何处理。第二层是“值不值得并行”的性能估算,先跑一次串行基线,再用Stopwatch对比并行耗时。如果并行提升不到 1.5 倍,我一般直接退回串行,因为引入并行的复杂度和排查成本不止 1.5 倍。
最后再分享一条经验:Parallel的默认调度不一定是最优的。不要盲目信任Environment.ProcessorCount作为最大并行度,因为云服务器上的 CPU 配额、容器限制、超线程都可能影响实际可并行能力。更靠谱的做法是根据线上压测结果动态调整并行度,或者用.NET的ThreadPool.SetMinThreads配合观测指标来调优。
我自己遇到过最“邪门”的问题,是并行度设成 8 时表现正常,设成 12 时反而性能崩掉。后来发现是容器环境限制了 CPU 配额,程序以为有 16 核,实际只能用 4 核,多出来的并行线程都在等待调度。这也再次说明一个道理:并行编程不是把并行度拉满就能变快,硬件限制、内存带宽、锁竞争都会把收益吃掉。遇到这种问题,先看环境拓扑,再看锁和共享数据,最后才考虑分区策略。