
这一节我们深究并行编程里被问得最多的两个点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 APMBegin/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和TaskT配合async/await让异步代码读起来几乎和同步代码一样。async Taskint 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.xBegin/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.ForParallel.For(0, 1000, i { Compute(i); });表面上看只是换了个循环写法但它内部的机制完全不同。Parallel.For会根据当前 CPU 核数和负载情况把0~999这个区间划分成多个子区间然后交给多个线程同时处理。这个分配动作由内部的分区器Partitioner负责默认采用的是范围分区逻辑上就像一个老师把 1000 道题分配给几个学生同时做而不是从头到尾一个人做完再叫下一个人。Parallel.For有几个常用重载我建议重点掌握带三个参数和最完整的ParallelLoopResult版本Parallel.For( int fromInclusive, int toExclusive, Actionint 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 避坑要点第一不要在循环体内修改循环控制变量之外的外部对象除非做好同步。比如往ListT里Add在多线程下可能触发IndexOutOfRangeException或者数据丢失。要用并发集合比如ConcurrentBagT、ConcurrentQueueT或者最后再合并。第二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是枚举器驱动适用于任意IEnumerableT。它不需要知道集合长度只要提供一个可遍历的集合就行。一个典型场景是批量处理一批订单ListOrder orders GetOrders(); Parallel.ForEach(orders, order { Process(order); });Parallel.ForEach在内部也会分区但和For的固定范围分区不同。它默认使用按块分配的分区方式比如把 1000 个订单拆成多个小块线程处理完一块再去拿下一块动态实现负载均衡。这就像流水线上的几个工人活儿不是提前固定分好的而是谁干完手头的就去箱子里拿下一批。这种动态分配能有效避免某个线程干了轻活提前结束而另一个线程还在苦干重活的不均衡问题。如果你处理的集合是IListT或数组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的循环体是ActionT同步委托没法直接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而保留共享链表的写入操作。比如每个迭代都往同一个ListT里Add一定会出问题。我在一次性能测试中用ConcurrentBagT收集结果虽然用了并发安全集合但输出顺序全乱了后来改成上面说的索引写入既安全又保序。第二处理到“大量小项”时并行调度开销可能比串行还大。比如集合只有几百个元素每个元素处理只要几微秒用Parallel.ForEach反而慢因为线程调度和分区开销更大。经验规则是单个任务至少中等耗时毫秒级或者集合元素达到上百万级才值得并行。第三如果集合是延迟加载的IEnumerableT比如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 密集降低并行度或放到后台线程写入集合时抛异常ListT并发写入不安全改用并发集合或索引写入异常信息丢失并行操作抛出AggregateException只捕获外层异常遍历InnerExceptions拍平需要保持顺序时结果乱Parallel默认不保序使用索引写入或AsOrdered依赖异步 I/O 的操作没有真正并行把async委托传给了Parallel改用Task.WhenAll单独讲几个典型排查经历。第一个是“Parallel.For求和结果不对”。我排查过类似的案例最后定位到total i竞态。如果改成Interlocked.Add结果对了但性能也不算最理想改成线程局部重载之后性能又上了个台阶。排查竞态最简单的方式把并行度设成 1如果结果稳定复现错误说明不是并行调度导致的而是循环体本身有问题把并行度调大错误出现变多基本可以断定是共享状态问题。第二个是“并行下载没有任何加速”。当时用的是Parallel.ForEach加HttpClient同步请求。问题很明确同步 I/O 在并行循环里不仅不加速还会因为占用线程而拖垮整体。后来我把实现改成SemaphoreSlimTask.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 核多出来的并行线程都在等待调度。这也再次说明一个道理并行编程不是把并行度拉满就能变快硬件限制、内存带宽、锁竞争都会把收益吃掉。遇到这种问题先看环境拓扑再看锁和共享数据最后才考虑分区策略。