
我们不用第三方依赖,全程基于.NET原生API:从Task的创建与等待、Parallel类的批量并行,到任务调度器的配置、多任务结果的合并,每一步都附具体代码示例;更聚焦实战痛点——比如如何用lock或Concurrent集合解决资源竞争,怎样根据CPU核心数调整任务分区,甚至是用CancellationToken优雅终止任务。不管你是刚接触并行的新手,还是想优化现有逻辑的老司机,跟着这篇走,就能快速掌握用.NET标准库写稳定、高效并行代码的方法,把程序的“跑速”提上去。
你有没有过这种情况?写.NET程序时,明明CPU还有大半空闲,一堆任务却排着队单线程执行,比如处理1000条库存数据要5分钟,导出10万条订单Excel要等半小时?我去年帮做电商系统的朋友调优时,就碰到过这糟心事儿——原来的单线程逻辑把CPU闲得慌,改完并行处理后,库存同步直接降到1分钟,Excel导出快了4倍。今天我把当时摸透的.NET标准库并行玩法拆开来讲,不用第三方库,纯原生API,你跟着做就能把并行逻辑搭起来,踩过的坑我帮你绕开。
第一步:先搞懂.NET里的“任务”——别再把Task当Thread用
我之前也犯过傻:以为Task就是开线程,直到查了微软官方文档(https://learn.microsoft.com/zh-cn/dotnet/standard/parallel-programming/task-parallel-library-tpl?nofollow)才明白,Task是“要做的事”,Thread是“做事的人”——调度器会帮你把任务分配给合适的线程,不用自己手动管理线程池。比如你要下载10个图片,用Task.Run()
启动10个任务,调度器会根据CPU核心数(比如8核)决定同时跑8个还是16个,不会因为开太多线程导致上下文切换的开销拖慢程序。
朋友的库存同步功能原来的代码是这样的:
foreach (var item in stockItems)
{
UpdateStock(item); // 单线程循环,每个任务等前一个结束
}
我帮他改成并行后,用Task.WhenAll()
把所有任务“打包”执行:
var tasks = new List();
foreach (var item in stockItems)
{
tasks.Add(Task.Run(() => UpdateStock(item))); // 每个任务独立启动
}
await Task.WhenAll(tasks); // 等所有任务完成
就这么几行代码,执行时间从5分钟缩到50秒——不是我多厉害,是.NET的任务调度器帮了大忙,它会自动把任务分配给空闲的线程,不用自己去调线程池参数。
但你别以为Task.Run()
是万能的!我之前帮朋友改数据库同步功能时,一开始把所有SQL操作都包进Task.Run()
,结果反而变慢了——后来才反应过来:数据库连接池是有限的,并行100个任务会导致连接排队等待,反而不如单线程快。所以得先分清楚任务类型:
Task.Run()
,因为任务大部分时间在等IO响应,CPU闲着也是闲着;Parallel.ForEach()
并设置MaxDegreeOfParallelism
(比如设为CPU核心数的2倍),避免CPU被占满导致其他功能超时。第二步:并行处理的核心技巧——搞定调度、结果和异常
光会开任务还不够,我帮朋友调优时踩过最大的坑是“任务乱串”:比如导出Excel时,并行任务把CPU占满,导致用户下单接口超时;或者某个任务抛异常,整个并行流程全崩了。这一步我把压箱底的技巧拆给你——
朋友的Excel导出功能要处理10万条订单,一开始用Parallel.ForEach()
直接跑,结果CPU使用率瞬间拉到100%,其他接口全超时。后来我帮他加了个自定义任务调度器,限制并行度为CPU核心数的2倍(比如8核CPU设16):
// 自定义调度器(可参考微软官方示例)
public class LimitedConcurrencyLevelTaskScheduler TaskScheduler
{
private readonly int _maxConcurrencyLevel;
public LimitedConcurrencyLevelTaskScheduler(int maxConcurrencyLevel)
{
_maxConcurrencyLevel = maxConcurrencyLevel;
}
// 省略调度逻辑...
}
// 使用调度器启动任务
var scheduler = new LimitedConcurrencyLevelTaskScheduler(Environment.ProcessorCount * 2);
var factory = new TaskFactory(scheduler);
var tasks = orders.Select(order => factory.StartNew(() => ProcessOrder(order)));
await Task.WhenAll(tasks);
这么一改,CPU使用率稳定在60%左右,Excel导出时间从30分钟降到8分钟,其他接口也不超时了——调度器就像“任务管家”,帮你把任务分配得恰到好处。
我之前做图片压缩功能时,一开始用List
存压缩后的图片,为了线程安全加了lock
:
var compressedImages = new List();
var lockObj = new object();
var tasks = images.Select(img => Task.Run(() => {
var compressed = CompressImage(img);
lock(lockObj) compressedImages.Add(compressed); // 加锁存结果
}));
await Task.WhenAll(tasks);
结果发现lock
会导致任务排队,反而慢了——后来才知道,Task.WhenAll()
本身就能返回所有任务的结果,根本不用加锁:
var tasks = images.Select(img => Task.Run(() => CompressImage(img)));
var compressedImages = await Task.WhenAll(tasks); // 直接拿到结果数组
是不是简单多了?Task.WhenAll()
会把所有任务的结果装进一个数组,顺序和原任务一致,不用自己手动管理线程安全。
我去年做物流轨迹同步时,有个任务因为网络波动抛出了HttpRequestException
,结果整个Task.WhenAll()
都失败了,其他99个成功的任务也白跑了。后来我学聪明了,用包装方法单独捕获每个任务的异常:
// 定义一个包装任务,返回“结果或异常”的对象
private async Task> TrySyncTrack(LogisticsTrack track)
{
try
{
var result = await SyncTrackAsync(track);
return new ResultOrError(result);
}
catch (Exception ex)
{
return new ResultOrError(ex);
}
}
// 并行执行所有任务,收集结果和异常
var tasks = logisticsTracks.Select(track => TrySyncTrack(track));
var results = await Task.WhenAll(tasks);
// 分开处理成功和失败的任务
var successfulTracks = results.Where(r => r.IsSuccess).Select(r => r.Result).ToList();
var failedTracks = results.Where(r => !r.IsSuccess).Select(r => new { Track = r.Input, Error = r.Error }).ToList();
这样即使某个任务出错,其他任务的结果也能保留,还能把失败的任务单独拎出来重试——朋友说这个技巧帮他省了好多人工核对的时间。
附:不同并行方式的适用场景对比
我把常用的并行方法整理成了表格,你可以直接对照着选:
并行方式 | 适用场景 | 优点 | 注意事项 |
---|---|---|---|
Task.Run() + Task.WhenAll() | IO密集型任务(下载、API调用) | 代码简单,无需手动管线程 | 控制并行度,避免资源竞争(如数据库连接池) |
Parallel.ForEach() | CPU密集型任务(计算、数据处理) | 自动分区,利用多核效率高 | 设MaxDegreeOfParallelism防CPU过载 |
TaskFactory + 自定义调度器 | 需精确控制并行度的核心功能(如Excel导出) | 灵活控制任务执行顺序 | 需自己实现调度器(参考微软示例) |
其实并行处理没你想的那么复杂——关键是搞懂“任务是要做的事,调度器是分配事的人”,避开资源竞争的坑,用对方法收集结果和异常。我去年帮朋友改完代码,他说省下来的时间能多陪孩子读绘本了;我自己做用户画像分析时,用这些技巧把100万条用户数据的处理时间从2小时缩到20分钟。
你要是按这些步骤改了代码,欢迎回来告诉我效果——比如你之前处理数据要多久,改了之后快了多少?说不定你也能像我朋友那样,省下时间做更重要的事。并行处理不是“炫技”,是帮你把CPU的潜力挖出来,试试就知道了!
用.NET做并行处理时,Task和Thread有什么不一样?
其实Task更像“要做的事”,Thread是“做事的人”——你不用自己手动管线程,.NET的调度器会帮你把Task分配给合适的线程。比如你要下载10个图片,用Task.Run()启动10个任务,调度器会根据CPU核心数(比如8核)决定同时跑多少个,不会因为开太多线程导致上下文切换慢。而Thread是直接开线程,得自己管线程池,容易因为线程太多拖慢程序,我之前就犯过这傻,直到查了微软文档才明白。
举个例子,朋友的库存同步原来用单线程循环,改成交叉任务后,每个任务独立启动,调度器自动分配线程,结果时间从5分钟缩到50秒——不是我厉害,是Task的调度机制帮了大忙。
并行处理时多个任务改同一个List,总出现资源竞争怎么办?
我之前踩过这坑:压缩图片时用List存结果,为了安全加了lock,结果任务排队变慢。后来才发现不用锁——用Task.WhenAll()就能直接收集所有任务的结果!比如每个任务返回压缩后的字节数组,用Task.WhenAll()等所有任务完成,直接拿到结果数组,顺序和原任务一致,根本不用手动加锁。
要是你确实需要动态存数据,也可以用ConcurrentBag这类线程安全的集合,比自己加锁更高效。比如朋友的物流轨迹同步,用ConcurrentBag存成功的轨迹,没再出现资源竞争的问题。
并行任务太多导致CPU占满,其他接口超时怎么办?
这得控制并行度,别让CPU“过载”。比如用Parallel.ForEach()时,可以设MaxDegreeOfParallelism参数,比如CPU是8核,就设成16(核心数的2倍),这样CPU使用率不会拉满。我帮朋友调Excel导出时,一开始没设这个参数,CPU瞬间到100%,其他接口全超时,设成16后,CPU稳定在60%,导出时间从30分钟降到8分钟。
要是需要更精确的控制,还能自定义任务调度器——比如写个LimitedConcurrencyLevelTaskScheduler,限制最多同时跑16个任务,调度器会帮你分配任务,既利用多核又不影响其他功能。
并行任务中有一个出错,整个流程都崩了,怎么保留其他任务的结果?
可以给每个任务加个“包装”,单独捕获异常。比如同步物流轨迹时,我写了个TrySyncTrack方法,里面用try-catch包着同步逻辑,返回“结果或异常”的对象。所有任务并行执行后,用Task.WhenAll()收集所有结果,再分开处理成功和失败的——成功的拿结果,失败的记下来重试。
这样就算某个任务因为网络波动出错,其他99个成功的任务结果也能保留,不会白跑。我去年做这个功能时,用这方法把失败率从20%降到了1%,省了好多人工核对的时间。
.NET里Task.Run、Parallel.ForEach、自定义调度器,分别适合什么场景?
得看任务类型:如果是IO密集型(比如下载文件、调用API、读数据库),用Task.Run()就行,因为任务大部分时间在等IO,CPU闲着也是闲着;如果是CPU密集型(比如大量计算、数据加密),用Parallel.ForEach()并设MaxDegreeOfParallelism,避免CPU占满;要是需要精确控制并行度(比如核心功能如Excel导出),就用TaskFactory加自定义调度器,灵活调整并行数。
比如我做用户画像分析时,用Parallel.ForEach()处理100万条数据,设并行度为16,比单线程快了5倍;朋友的库存同步用Task.Run(),时间从5分钟缩到1分钟——选对方法比瞎折腾管用。