尧图精选

.NET多线程编程:从Task、async/await到锁与并行的选型与避坑指南

🕒 发布时间:2026/10/2 10:23:40 📁 来源:尧图网络
上个月在火山引擎 ADG 社区做了一场关于 .NET 多线程编程的分享结束以后我把大家在群里问得最多的问题整理了一遍发现一个很有意思的现象很多人不是不会写Task.Run而是把多线程、异步、并行当作同一件事来理解。比如有人问“我用 async/await 开了线程为什么接口还是慢”也有人问“为什么加了 lock 并发反而更低”这些问题表面上是 API 用法问题根子其实都在基本概念上。所以这篇文章我打算用“概念地图 选型实战 踩坑排查”的方式来写先帮大家把进程线程、异步并行的关系理顺再给出一套能直接落地的 .NET 多线程工具选型建议和代码示例最后聊几个我在社区里被反复问到的高频问题。适合正在写 .NET 服务端、Windows 服务或者桌面程序但对多线程体系还处于碎片化状态的同学如果你已经能熟练处理锁和 Task可以直接跳到第五节往后看。1. 先弄清线程、进程和异步多线程概念困惑的源头1.1 进程、线程与执行模型到底是谁在跑你的代码在聊 .NET 多线程之前我建议先把操作系统层的最小单位搞清楚。进程是操作系统分配内存、文件句柄、安全上下文等资源的最小单元线程是操作系统进行 CPU 调度的最小单位。同一个进程里的线程共享进程的堆内存、静态变量和句柄所以它们之间天然“距离很近”近到不需要进程间通信就能互相对同一块数据做读写——这是多线程高效的根本原因也是所有脏数据问题的根源。CPU 核数是有限的一个 8 核机器同一时刻最多只有 8 条线程在真正执行。当线程数量超过核数操作系统就必须做时间片轮转每个线程分到几十毫秒的执行时间时间到了就挂起换下一个线程。这中间的动作叫“上下文切换”代价是保存线程寄存器、更新内核调度结构、刷新缓存等。很多人以为开线程越多越快实际情况是线程一多大量时间都耗在切换上CPU 明明 100%业务吞吐却上不去。在 .NET 里了解这个模型有个直接用途Environment.ProcessorCount代表逻辑处理器数写并行代码时常用它来决定并行度。但逻辑处理器数不等于物理核心数超线程环境下可能翻倍另外云主机上看到的核数也可能是虚拟化后的配额不能简单认为并行度越大越好。社区里有个同学在 16 核机器上写了个Parallel.For把并行度硬调成 64结果性能比默认还差原因就是分区过多导致调度开销超过了计算收益。1.2 托管线程与操作系统线程的映射关系.NET 里的System.Threading.Thread是对操作系统线程的托管包装。创建一个 Thread 时CLR 会向操作系统申请一条线程分配默认 1MB 左右的栈空间然后在线程执行完或被显式终止时回收。这项操作的成本很高所以线程池才存在线程池维护一组已经创建的“空闲工人”有任务进来就交给其中一个执行执行完再回收而不是销毁大大减少线程创建和销毁的频率。在 .NET 中线程还有一个很容易被忽略的属性前台线程和后台线程。前台线程是程序退出前必须跑完的线程只要还有一个前台线程活着进程就不会正常退出后台线程则相反当所有前台线程结束CLR 会直接终止进程其中包括后台线程。手动创建new Thread(...)时默认是前台线程线程池里的线程默认是后台线程。这导致一个很隐蔽的问题很多人手动开一个循环线程去处理队列以为进程退出时它会正常停掉结果因为它是前台线程Windows 服务在工作线程上接收到了停止信号进程却迟迟不退出。如果真有常驻任务建议设置IsBackground true并且加一个可靠的退出信号而不是依赖线程存活来卡进程生命周期。另一个基础点是线程异常。线程内部发生的未捕获异常在 .NET 里会导致进程崩溃而不是只让当前线程结束某些版本和某些未处理模式下行为有差异。所以手动开线程时第一件事就是在委托内部包 try/catch 并记录日志。线上服务出现“进程突然没了”却找不到异常很多就是裸线程里抛异常导致的。1.3 多线程、异步、并行的差别先对号入座再写代码这三组词是社区里混淆最严重的。我的简化理解多线程是为了“同时做几件事”核心是分享 CPU 时间。异步是为了“等待外部资源时不占人”核心是请求发出后先返回完成后回调。并行是“把一件大事拆成多个子任务同时计算”核心是榨干多核。举餐厅的例子一个服务员接待好几桌客人客人点完菜后等待厨房做菜服务员不需要站在桌边干等着可以去接待其他客人——这就是异步。如果生意太好老板多雇几个服务员每个服务员只负责两三桌这是并行/多线程。异步不一定需要线程做菜的等待过程本来就不该占用服务员线程而是由厨师底层 IO 设备/内核去完成。.NET 里 async/await 主要解决 IO 等待时的线程浪费问题而 Parallel、多线程主要解决 CPU 密集计算的速度问题。搞反了就会出现“数据库查询很慢我用 Task.Run 包了一层结果接口更慢”的典型错误——Task.Run 只是把阻塞从调用线程挪到了线程池线程本质还是在等还多了一次线程切换。先对号入座后面所有工具选型才会有意义。2. 三种顺手可用的并发工具Thread、ThreadPool 和 Task 的选择边界2.1 Thread别轻易开裸线程除非你真的需要控制权Thread是最底层的托管线程对象用法很直接Thread worker new Thread(() { try { // 长期运行的常驻任务 while (!cancelled) { DoWork(); Thread.Sleep(100); } } catch (Exception ex) { Logger.LogError(ex); } }) { IsBackground true, Name MyWorker }; worker.Start();这段代码适合的场景很清晰任务需要长期独立运行比如后台任务队列、设备通讯管理、定时清理任务。在这些场景下线程池线程不合适因为线程池线程是按需分配的工作线程长期占住一个池线程不放会降低线程池对其他请求的响应能力。另外如果你需要为任务设置独立优先级、线程文化、名称或者想单独控制它的生命周期手动 Thread 也有意义。但除了这些场景我建议尽量别裸开线程。原因有几个第一Thread 创建销毁开销大如果任务是高频短小的你会在创建线程上浪费大量时间第二线程内异常处理必须自己兜住一旦遗漏进程直接崩第三Thread.Abort 在现代 .NET 里已经基本不能可靠终止线程停线程需要自己设计CancellationToken或标志位复杂度不小。很多刚入门的同学习惯“有问题就 new Thread”这是并发设计失衡的信号。更好的做法是先问任务是否短小且频繁如果是交给线程池是否需要返回值、组合多个任务交给 Task。2.2 ThreadPool线程复用的默认方案但别在里面睡大觉ThreadPool.QueueUserWorkItem是最简单的线程池用法ThreadPool.QueueUserWorkItem(_ { ProcessBackgroundJob(data); });线程池内部有一套很精妙的调度机制。初始时它会根据 CPU 核数和平时的负载自动调整工作线程数量任务多时逐渐增加空闲时再回收。这套机制英文叫 hill-climbing目标是让线程池吞吐量最大化。你不需要手动管理线程生命周期只要把“独立、短小、没有关联”的任务扔进去就行。但线程池也有明显边界。最要命的是线程池线程是共享资源如果你在池线程上做阻塞操作比如Thread.Sleep、Task.Wait()、同步 IO那这个线程就被“占住”了。线程池为了避免饥饿会不断补充新线程但补充有延迟而且每多一条线程都有栈空间和调度成本。你在极端情况下会看到线程池线程数冲到几千CPU 却不高所有请求都卡在某个同步等待上。这就是线程池饥饿。所以在线程池上执行的任务要么是 CPU 密集的小计算要么是异步非阻塞的逻辑绝不能是长时间无意义的阻塞等待。另外线程池线程是后台线程而且默认线程名是空的调试时很难分辨是哪条线程在跑哪个任务。建议在BeginRequest或关键逻辑里把活动 ID、业务 ID 串到日志里方便后续和线程栈一起看。2.3 Task线程池之上的抽象层但它不是一个“线程”Task是给线程池加了一层更友好的壳。它默认也是在线程池上执行但解决了返回值、异常传播、组合等待、取消协作等手工 Thread 很难做好的问题Taskint task Task.Run(() ComputeResult()); int result await task;Task.Run本质是“把委托放进线程池队列”返回一个任务句柄。它和裸线程最大的区别是你可以 await 它也可以Task.WhenAll等待多个任务还可以在任务里抛出异常之后通过 await 把它带出来而不用自己在委托里 try/catch 然后吞掉。这个抽象让代码的可组合性大幅提升。但注意Task 本身不代表不占线程。CPU 密集的 Task 执行时必然占一个线程池线程IO 密集的 Task 如果没有使用 async API依然会占线程。很多同学把 await 和并行混在一起以为写await Task.Run(() HeavyCompute())就能让接口并行处理多个访客实际上这只是在把重计算扔到线程池最终线程池也会被拖垮。正确的做法是需要并行计算的密集任务用Parallel.ForEach或自己控制并行度需要并发 IO 的任务应该从源头使用异步 IO 接口而不是用 Task.Run 来包装同步阻塞代码。2.4 选型表什么场景该用哪个我把这层工具的选择逻辑整理成一张表社区分享时放出来反响很好工具适合场景核心注意点Thread独立常驻的后台任务、需要自定义优先级/线程名的场景创建销毁成本高异常必须自理前台线程会影响进程退出ThreadPool短小、频繁、相互独立的计算或后台任务任务内不能长时间阻塞线程数是动态调整的不要手动干涉Task需要返回值、异常传播、取消、组合多个异步流程默认在线程池执行CPU 密集场景不会自动提速Parallel / PLINQCPU 密集、元素相互独立、可分批计算的集合任务注意共享状态和并行度不适合 IO 密集async/awaitIO 密集等待网络、数据库、文件以及服务端高并发不提高单次请求速度但能显著提高吞吐不要用 Waity/Result 去阻塞反过来记能异步就异步能并行就并行能复用线程池就复用线程池裸线程是最后的选项。这个顺序不仅省事也最容易排查问题。3. async/await 工作原理解析为什么它不创造线程却能提高吞吐3.1 状态机与异步方法执行流程async/await 是社区讨论最多也误解最多的部分。它的底层原理其实不复杂编译器把异步方法改写成一个状态机。当执行到await时编译器会检查被等待的操作是否已经完成。如果没完成方法立刻返回一个未完成的 Task当前线程可以做其他事等被等待的操作完成时再通过回调恢复到状态机的下一个状态继续执行。看一个最常见的例子public async Taskstring GetOrderInfoAsync(HttpClient client, long id) { var json await client.GetStringAsync($https://api.example.com/order/{id}); return ParseOrder(json); }当await client.GetStringAsync(...)发起网络请求时调用GetOrderInfoAsync的线程并不会一直等响应。请求发出后它立即把“拿到响应后要解析 json”这个剩余步骤登记到状态机然后返回给调用者。网络数据到达时线程池里的某个空闲线程会接着执行ParseOrder(json)并返回结果。这样做的效果是一个线程可以同时“服务”几千个正在等待网络响应的请求因为大部分时间线程不是在干等而是被释放回线程池去处理其他请求。所以 async/await 的真正价值不是加速单个请求而是减少线程占用。在 .NET 服务端每个请求通常都会绑定线程如果每个请求因为等待数据库/网络阻塞 100ms那么 10 个并发请求就需要 10 个线程同时阻塞用了异步之后同样 10 个请求在线程里的时间是微秒级的实际占用的线程可能只有 1-2 个。这也是 .NET 高并发后端和桌面 IO 程序都推荐 async 的原因。3.2 同步上下文与 ConfigureAwait为什么有时候一加就死锁await 执行完成后要决定“代码恢复到哪个线程执行”。默认规则是在 UI 线程上 await恢复时回到 UI 线程在 ASP.NET 旧版同步上下文里 await恢复时回到这个上下文。这个机制叫捕获同步上下文目的是方便 UI 程序让你的代码不用手动Invoke就能操作控件。但捕获上下文也有代价。一个常见死锁代码是这样的// WinForms/WPF 或者旧版 ASP.NET 环境下执行 public void Button_Click() { var result GetAsync().Result; // 阻塞 UI 线程 } public async Taskint GetAsync() { await Task.Delay(100); // 完成后续需要回到 UI 线程 return 42; }UI 线程调用GetAsync().Result把自己卡住等待结果GetAsync内部 await Task.Delay 完成后需要回到 UI 线程继续执行但 UI 线程已经被.Result占住了于是互等死锁。解决办法有两个一是全程不要同步阻塞从 UI 入口到最底层都用 async二是在不需要上下文恢复的位置加.ConfigureAwait(false)让后续代码不回 UI 线程。需要澄清的是ASP.NET Core 中默认没有捕获同步上下文所以ConfigureAwait(false)写不写不会像旧版那样引起死锁它更多是库作者为了避免给自己找麻烦才写的。在桌面程序里如果你要操作 UI 控件await 后面不能随便加ConfigureAwait(false)否则跨线程操作控件会抛异常。这部分的结论理解上下文机制比背规则更重要遇到死锁先检查是不是“同步上下文被同步阻塞占住了”。3.3 常见的异步误用把异步当并行把拖把当扫帚我在社区里收到最多的一个问题“我用了 async/await为什么接口响应还是很慢” 这种情况大概率是把 async 用错了地方。async/await 对 IO 等待型的慢有效比如等数据库、HTTP、文件读取但对 CPU 计算型的慢几乎无效比如 MapReduce、图像缩放、大量字符串拼接。后者需要的是并行不是异步。如果你把await Task.Run(() HeavyCpuWork(item))当成异步优化去包一个 CPU 密集操作只是把计算从调用线程挪到线程池线程单次请求的耗时不会减少甚至因为线程切换还多了一点延迟。另一个误用是异步方法里混入同步阻塞调用public async TaskData GetDataAsync() { var response await httpClient.GetAsync(url); // 好的异步 var bytes response.Content.ReadAsByteArrayAsync().Result; // 坏的阻塞 return Deserialize(bytes); }这种代码在中大型项目里很常见往往是一个遗留同步方法被包了一个假异步壳。它带来的问题是线程池线程被Result挡住等内部异步完成而内部异步完成又可能依赖线程池线程去执行回调和后续代码最终引发线程池饥饿。诊断这种问题的经典现象是请求量稍高CPU 才 20%接口却大量超时线程池线程数不断上涨但每个线程都卡在某个 Task 的Wait()上。修复方式是把同步调用改成真正的异步调用全程用 await。如果第三方库只提供同步方法可以引用Task.Run做隔离但那只适合桌面程序保 UI 响应不能救服务端吞吐。4. 共享状态与线程安全锁有很多种选错比不选更糟4.1 lock 的真面Monitor 的语法糖临界区越小越好多线程最大的麻烦是共享可变状态。两个线程同时执行count看起来是一行代码实际上是“读 count 原值 → 加 1 → 把新值写回”三步。两个线程可能同时读到同一个原值然后都写回相同的值最终结果比预期少一次。解决这类问题最常见的手段就是 lockprivate readonly object _sync new object(); private int _count; public void Increment() { lock (_sync) { _count; } }lock实际上是Monitor.Enter和Monitor.Exit的语法糖编译期会保证即使在异常情况下也能在 finally 里退出锁。锁对象必须是引用类型不能锁 string因为字符串可能被其他代码持有不能锁值类型因为装箱之后每次都会创建新对象也不要锁 this 或公共字段否则外部代码不知道你的锁约定很难排查死锁。锁有一个很容易被忽略的性能代价进入临界区的线程必须等待持有锁的线程退出如果临界区里做了耗时操作比如网络请求、复杂 IO其他线程全部排队。所以临界区要尽可能小只保护真正需要无冲突的那几行代码。有人喜欢在方法外面加一个粗粒度 lock把整个方法都包起来并发一高吞吐立刻下降排查时只看到锁竞争却很找到哪一段代码拖慢的原因就在于临界区太大。把“加锁”当成“整个方法只能串行”的设计是很多性能问题的元凶。4.2 Interlocked 与无锁编程能处理的场景其实很窄对于简单的整数加减和标志位更新更优的选择是Interlockedprivate int _count; public void Increment() { Interlocked.Increment(ref _count); }Interlocked 是通过 CPU 提供的原子指令实现的既不阻塞线程也不会有锁竞争开销。它适合计数器、序号生成、简单状态切换等场景。类似的还有Interlocked.Exchange、Interlocked.CompareExchange后者是实现无锁链表、无锁栈的核心原语。但无锁编程的适用面其实很窄。Interlocked 只能保证一次“读改写”是原子的不能保证一个复合操作是原子的。比如你想判断某个字段是否为某个值然后更新到新值中间不能有别人插一脚这时必须用CompareExchange做 CAS 循环稍有不慎就 ABA 问题。对绝大多数业务系统我不建议自己实现复杂的无锁结构能用并发集合就用并发集合该上锁就上锁。无锁代码性能好但正确性极难验证尤其在 x86、ARM 不同内存模型下表现不一致。先把锁用对再考虑无锁。4.3 并发集合框架给的安全网比手写锁更可靠在 .NET 里很多共享集合场景无需自己加锁。ConcurrentDictionary、ConcurrentQueue、ConcurrentBag、BlockingCollection都内置了线程安全逻辑内部使用细粒度锁或无锁算法且经过大量生产环境验证。使用并发集合有几点值得注意ConcurrentDictionary的GetOrAdd是原子操作适合做缓存但它不能保证工厂函数只执行一次对昂贵对象的构造需要自己处理。ConcurrentQueue是无界队列入队和出队都是线程安全的但迭代时只能看到某个时刻的一致快照不能保证队列在全迭代期间不变。ConcurrentBag是无序集合适合生产者消费者但遍历顺序完全不确定依赖顺序的业务不能用它。BlockingCollection是对并发队列的进一步封装支持阻塞消费、有界容量和协作取消。我在项目中倾向于先用并发集合替代手写锁尤其是“一个线程写、多个线程读”的场景。手写一个Dictionary lock并不难但稍不留神就会在 foreach 时修改集合导致异常或者在锁范围上犯错。用ConcurrentDictionary可以少很多心智负担。4.4 生产者消费者BlockingCollection 的最小实践生产者消费者是多线程中最经典的模式。下面这段代码是我在 ADG 社区演示用的最小例子它能跑通且比手写锁的方式规整得多using System.Collections.Concurrent; var queue new BlockingCollectionint(boundedCapacity: 100); var cancellation new CancellationTokenSource(); // 生产者 var producer Task.Run(async () { for (int i 0; i 1000; i) { if (!queue.TryAdd(i, 100, cancellation.Token)) break; await Task.Delay(10); } queue.CompleteAdding(); }); // 消费者 var consumer Task.Run(() { foreach (var item in queue.GetConsumingEnumerable(cancellation.Token)) { Process(item); } }); await Task.WhenAll(producer, consumer);这里的boundedCapacity: 100很关键它让生产者不能无限堆积如果消费者处理不过来生产者会被阻塞或TryAdd返回 false起到天然的背压作用。GetConsumingEnumerable会在队列为空时阻塞等待在调用CompleteAdding后自动结束不需要手动去轮询队列状态。这段代码再往后扩展可以替换成 .NET 里更现代的ChannelT它支持多个生产者多个消费者而且异步等待做得更好。但 BlockingCollection 对理解“生产者—消费者—背压”这些核心概念依然是非常好的入门工具也更容易被基础稍薄的同学接受。5. 多线程程序踩坑实录从死锁到 CPU 飙高的排查链路5.1 死锁两个锁的经典场景以及怎么快速发现死锁是并发编程里最“玄学”的问题因为它不总是稳定复现。操作系统层面的死锁需要同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。最常见的代码级死锁是加锁顺序不一致public class Account { private readonly object _lock new object(); public decimal Balance { get; set; } public void Transfer(Account other, decimal amount) { lock (_lock) { lock (other._lock) // 线程A 持有 A 锁等待 B线程B 持有 B 锁等待 A { Balance - amount; other.Balance amount; } } } }如果两个账户在同一时刻互相转账A 线程先拿 A 锁再拿 B 锁B 线程先拿 B 锁再拿 A 锁就会死锁。入门级解决方案是固定加锁顺序比如先按账户 ID 排序所有转账统一从小到大加锁。更好一点的做法是用Monitor.TryEnter加超时获取不到就回滚避免无限期等待。关于死锁排查我的经验是不要靠肉眼一行行找。在 Visual Studio 里调试时用“调试 → 全部中断”然后打开“并行堆栈”窗口就能看到每条线程的调用栈死锁线程通常会停在Monitor.Enter或Wait上并且相互等待的栈会形成环。生产环境没有调试器则用dotnet-dump collect抓进程转储再用dotnet-dump analyze查看所有托管线程的栈配合!threads和!clrstack来定位。对比多个 dump 文件里的线程栈一般能很快看出锁环。5.2 竞态条件count 为什么丢更新竞态条件比死锁更隐蔽因为程序不会卡死只是结果错误。最经典的入门例子是并发自增int counter 0; var tasks Enumerable.Range(0, 1000).Select(_ Task.Run(() { for (int i 0; i 1000; i) { counter; // 错误读-改-写不是原子操作 } })); Task.WaitAll(tasks.ToArray()); Console.WriteLine(counter); // 远小于 1000000我见过有些同学测试出来的值有时是 999998有时是 999992于是以为“概率很小没事”这是很危险的。竞态的出现跟线程调度时机、CPU 缓存、操作顺序都有关可能只在特定高并发下出现。修复很简单要么用Interlocked.Increment(ref counter);要么用 lock 保护自增语句。更隐蔽的竞态发生在“先判断再操作”的模式里比如检查缓存是否存在、不存在则加载两个线程同时检查都返回不存在然后同时加载把脏数据写回。这种场景不能靠单个判断语句解决需要ConcurrentDictionary.GetOrAdd或者 lock 包住整个检查加载过程。竞态问题的本质是“操作序列被其他线程打断”所以判断时先问自己我这个操作是原子的吗如果不是就必须加锁或使用并发集合。5.3 线程池饥饿为什么任务非常多反而非常慢线程池饥饿是服务端最容易被误判为“内存不足”或“死锁稳定复现”的问题。现象是压力测试时 QPS 上不去CPU 只有 20%-30%但请求平均耗时猛增dotnet-counters 里看到threadpool-thread-count在持续增加threadpool-queue-length也在上涨。根因通常是线程池线程被大范围阻塞。最常见的阻塞源是同步等待异步任务例如在控制器里调用.Result、在库方法里Task.Wait()、或者使用了一些只提供同步语义的第三方 SDK。线程池为了避免任务没人处理会通过 hill-climbing 机制逐步增加线程但它增加线程的速度跟不上任务积压的速度而且每个线程都会分配栈内存最终系统在大量线程之间频繁切换CPU 全部耗在调度上业务更慢。排查链路通常是这样的先看 CPU 和线程池计数如果 CPU 不高但线程池线程数很高基本可以认定是阻塞而不是计算瓶颈然后抓 dump 看线程栈重点搜索Monitor.Enter、WaitHandle.WaitOne、Task.Wait这类阻塞点找出哪个同步调用把线程占死了。修复方式除了消除同步阻塞外如果确实有需要长期占用的后台任务应改用TaskCreationOptions.LongRunning或独立 Thread把它们从线程池里请出去别和请求处理共享那组线程。5.4 用 dotnet-counters 和 dump 排查线上问题社区里经常有人问“代码在本地复现不了只能在线上看怎么办”。我的建议是先把基础监控做起来再谈定位。.NET 自带的dotnet-counters是一个很好用的轻量性能监视器可以在容器里直接执行dotnet-counters monitor --process-id 1234 --counters System.Runtime它会输出cpu-usage、working-set、threadpool-thread-count、threadpool-queue-length、lock-contention-count、exceptions这些关键指标。当你看线程池饥饿时重点盯threadpool-queue-length是否持续大于某个阈值以及lock-contention-count是否突增。锁竞争严重时通常伴随着线程池 CPU 下降和请求延迟上升这时再配合dotnet-dump collect抓进程转储。dotnet-dump collect --process-id 1234 dotnet-dump analyze core_1234analyze 进入后常用命令包括clrthreads查看线程状态、clrstack查看托管栈、dumpheap查看托管堆、sosstatus确认是否加载主模块。如果发现所有线程栈都停在同一把锁上就用!locks看锁持有情况。这套流程在 Linux 容器、Windows 服务上都通用比在本地装一堆调试器要轻量很多。6. 并行计算的度Parallel 在什么情况下真的能提速6.1 Parallel.For 与并行分区机制Parallel.For和Parallel.ForEach是最直观的 CPU 密集型并行工具。它们不是简单地把循环体扔到线程池执行而是会把集合拆成多个分区每个分区分配一个任务分区内部串行迭代分区间并行。比如对一个 10000 元的数组做耗时的纯计算默认分区数会和 CPU 处理器数相关也就是说你不需要手动拆数据框架会帮你拆。double[] results new double[n]; Parallel.For(0, n, i { results[i] ComputeExpensive(i); });这个例子安全因为每个线程写的是数组的不同索引不存在共享写。但要注意如果你在循环里使用了一个普通int sum累加所有线程都在往同一个变量上加就会出竞态。Parallel.For提供了ParallelLoopState和局部状态重载来实现分段聚合但先别想高级优化先把“循环内不能有共享可变变量”这条红线记住。并行适合的任务特征是计算时间长、单个元素彼此完全独立、不依赖执行顺序。典型的是图像处理、加密哈希、数值模拟、批量数据转换。如果元素之间有依赖或者需要输出全局有序结果强行并行不仅在结果上可能出错还会引入大量排序开销最后比串行还慢。6.2 MaxDegreeOfParallelism 与并行度的选择默认并行度由运行时决定一般接近处理器逻辑核心数但实际项目里很少直接使用默认值。原因有几个机器上可能还跑着其他服务比如在火山引擎 ECS 上同一台宿主还运行着数据库代理、日志采集等超线程下逻辑核数可能高于实际物理核过多并行任务会造成 CPU 队列堆积另外虚拟化环境允许核数可能只是配额不是稳定的物理资源。所以我习惯显式设置Parallel.ForEach( items, new ParallelOptions { MaxDegreeOfParallelism Environment.ProcessorCount - 1 // 留一个核给系统 }, item Process(item));这里ProcessorCount - 1是我个人经验因为服务器上除了你的应用底层监控、日志、运行时后台线程也需要 CPU。如果容器内存受限还要考虑每个任务的内存占用并行度不能只看 CPU两个万级数组排序任务同时跑可能内存先爆。并行度也不是越高越好。当并行度超过可用核数时线程会争抢 CPU频繁切换上下文每个任务的实际执行时间反而变长。尤其当每个任务内有锁或共享资源时并行度越高锁竞争越剧烈吞吐可能直线下降。实测中遇到过把 MaxDegreeOfParallelism 从 4 调到 16结果相同数据集的排序总耗时增加了 30%就是因为 CPU 只有 4 核其余 12 个并行任务全部在排队和切换。6.3 PLINQ AsParallel 的陷阱隐形顺序依赖PLINQ 让并行变得非常“美味”一行.AsParallel()完成并行化但它隐藏了不少风险。最典型的是顺序问题var ordered source.AsParallel().AsOrdered().Select(Transform).ToArray();AsParallel()会把查询并行执行默认不保证结果顺序和输入顺序一致。如果后续代码依赖顺序就必须加AsOrdered()但这会让 PLINQ 在内部增加存缓冲和排序逻辑部分抵消并行收益。更麻烦的是在并行查询内部使用普通集合收集结果var results new Liststring(); source.AsParallel().Select(x Transform(x)) .ForAll(x results.Add(x)); // List 线程不安全ForAll会并行调用委托ListT.Add不是线程安全的大量元素加入时会丢数据或抛异常。正确做法是每分区返回结果再由 PLINQ 合并或者干脆输出到ConcurrentQueue。我的建议PLINQ 适合纯计算、无副作用、输出结果可以做无序处理的场景比如批量校验、批量模板渲染。如果你的业务对输出顺序有严格要求或者一不小心就会写共享集合别用它老老实实写 Parallel.ForEach 或普通 foreach。并行代码首要目标不是快是保持正确。在火山引擎 ADG 社区里每次分享完我最后都会说同一句话多线程是个放大器不是加速器。你原本正确高效的代码通过合理并行可以更快如果代码本身存在竞态或阻塞并行只会把问题放大得更明显。所以新手阶段别急着上 Parallel 和花式锁先把 async/await 的 IO 思路用对把锁的粒度控制好大部分服务端性能问题已经能解决。至于更深的并发模型推荐去精读《CLR via C#》和《Concurrency in C# Cookbook》相关章节配合 dotnet-dump 在生产环境实测几次比刷一百篇并发文章都管用。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →