刚上手 C# 的人写并发,常见卡点是:照抄一段 Task.Run 能跑,但一到批量请求、批量算数据就开始乱序、丢异常、把线程池打满。这篇不讲概念定义,直接给三段能跑起来的示例,再补一张「什么时候该用哪个」的对照表和排错顺序,让你从能跑过渡到敢放进项目。
刚碰 C# 并发的人,最常见的状态是:抄一段 Task.Run 能跑起来,代码也返回了结果,但一放到批量请求、批量算数据的场景就开始出问题——结果顺序对不上、异常不知道被谁吃了、线程池被别人占满。这篇不讲概念定义,直接给三段能跑起来的示例思路,再补一张「什么时候该用哪个」的对照表和一套排错顺序。
下面三段示例分别对应三种典型场景:多个独立任务等齐、本地数据的批量并行计算、以及任务太多需要限流加取消。你不用全用上,按自己手上那批活儿的性质选一个,先把最小可跑版本跑通再往项目里搬。
最常用的一类:用 Task 并发跑多个独立任务
场景是「一批互不依赖的活儿」,比如同时调几个接口、同时读几个文件。写法上不是循环里一个个 await,而是先把每个活儿包成 Task 收进集合,再用 Task.WhenAll 一次等齐。这样总耗时接近最慢的那个,而不是所有耗时相加。
// 每个任务带一个可识别的键,方便日志对上号
var jobs = new[] { "a", "b", "c" };
Task<string>[] tasks = jobs.Select(key => RunOneAsync(key)).ToArray();
try {
string[] results = await Task.WhenAll(tasks);
} catch (Exception ex) {
// WhenAll 会把异常聚合抛出,要逐个翻 InnerException
foreach (var inner in ex is AggregateException agg ? agg.Flatten().InnerExceptions : new[] { ex })
Console.WriteLine(inner.Message);
}- Task.WhenAll 只保证「全部结束」,不保证结果顺序,返回数组是按你传入的任务顺序排的,别用完成先后去猜;
- 异常是聚合抛出的,外层 catch 到的往往不是真正出问题的那一条,必须遍历 InnerException;
- 每个任务带上编号或业务键,写进日志,否则并发一多你根本不知道哪条挂了;
- 任务成功数、失败数建议单独统计,不要只看「整个 WhenAll 有没有抛」。
CPU 密集型的批量计算:并行循环怎么写
如果手上是一批本地数据做同样处理——算哈希、解析大文本、跑一段纯计算,那用并行循环替代普通 for,能让多核真正吃上。这类活儿不涉及等待,占的是 CPU 时间,和上面那种等接口的场景不是一回事。
// 汇总用线程安全累加,或者各自收集最后合并
long total = 0;
Parallel.ForEach(data, item => {
var value = HeavyCompute(item);
Interlocked.Add(ref total, value);
});| 循环体里做的事 | 适合并行循环吗 | 原因 |
|---|---|---|
| 纯计算、解析本地数据 | 适合 | 吃 CPU,多核并行有真实收益 |
| 等接口、等磁盘、等数据库 | 不适合 | 占的是等待时间,应该走异步 |
| 改同一个共享集合或计数器 | 要先改造 | 不改造会丢数据或抛异常,改用线程安全方式 |
| 循环体里再调外部服务 | 谨慎 | 并行度要主动降下来,别把下游打挂 |
并行度默认跟着核数走,这对纯计算是合理的,但如果循环体里打了同一个外部服务,默认值往往太高。遇到这种混合场景,把并行度显式压到下游能接受的量级,再观察对方有没有报限流。
并发跑多了会被拦:限流与取消的标准写法
任务一多,瞬时并发很容易把下游打满,或者让自己的线程池排队排到超时。标准做法是在并发任务外面套一层信号量:进任务前等一个名额,跑完立刻释放,形成排队效果,同一时刻在跑的数量就被控住了。
using var gate = new SemaphoreSlim(maxConcurrency);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try {
await Task.WhenAll(items.Select(async item => {
await gate.WaitAsync(cts.Token);
try { await HandleAsync(item, cts.Token); }
finally { gate.Release(); }
}));
} catch (OperationCanceledException) { /* 整体停下的正常出口 */ }- 先定并发上限这个数不是拍脑袋,看下游能接受多少、自己线程池有多大,先取一个保守值,跑稳了再往上加。
- 给整批任务一个共同取消入口用同一个 CancellationTokenSource 贯穿所有任务,用户关页面或超时后能整体停下。
- 确认释放写在 finally 里WaitAsync 和 Release 必须成对,异常路径上漏了 Release,名额会被永久占住。
- 验证停得干净触发取消后观察是否还有后台任务在跑,日志里不该再出现新的处理记录。
这两样为什么必须成对出现:只限流不取消,碰到卡住的请求会一直占着名额,后面全在排队;只取消不限流,瞬时压力照样打满下游。只有两三个任务、且都很快返回时,加这层就是徒增复杂度,可以不套。
新手最容易踩的坑与排查顺序
出了问题别急着改代码,按固定顺序排查会快很多。先确认任务的「数量对不对」,再确认「异常有没有被漏掉」,最后看「并发数是不是超过了对方能接受的范围」。大部分线上问题在前两步就能定位。
- 在并发体里直接改共享变量:要么用线程安全累加,要么各自收集最后合并;
- 把异步方法写成同步等待:在并发场景里这么写等于白占线程,还可能死锁;
- 忘记限定并发数:本地跑通不代表线上跑得动,量一上来就被限流;
- 异常只记一句话:并发下必须记下是哪个任务、哪个键失败的,否则没法复现;
- 把并发数、超时、重试次数写死在循环里:改成配置项,调起来才不用动代码。
| 现象 | 先查什么 | 大概率原因 |
|---|---|---|
| 结果数量比预期少 | 任务有没有被吞掉 | 某个任务提前退出或异常没记 |
| 整体报错但不知道哪条 | 异常有没有展开逐个看 | 聚合异常只记了外层那一句 |
| 跑一会就被拒绝 | 并发数是否超下游上限 | 没限流或并行度取太高 |
| 取消后还有日志在刷 | 取消令牌有没有贯穿下去 | 只传了一半任务或内部方法没接令牌 |
验证手段上,本地先小批量跑通再逐步加量,是最省事的办法。接口类并发可以先量一遍延迟基线,判断慢是自己代码的问题还是对端本身就慢。站内的测速工具能把基线数字跑出来,去 /tools/speed-test 核对当时的数字,再决定要不要调并发。
下一步并发参数定下来之后,最容易被忽略的是对端到底稳不稳、延迟什么水平。可以先到测速页跑一遍基线,再回 /price-watch 看同类服务的价格变动,判断现在的并发策略是省了时间还是只是把压力转嫁了。→常见问题
Task.WhenAll 和直接循环 await 有什么区别?
循环里逐个 await 是排队跑,第二个要等第一个结束;WhenAll 是把任务先都发出去再等齐,属于真并发。区别在总耗时和瞬时压力:并发快但会把下游压得更狠,接口类操作要先确认对方的并发上限再决定用哪种。
并发跑一批任务,其中一个失败了怎么办?
默认是等其他任务跑完再以聚合异常的形式抛出,所以你必须显式处理,否则很容易只看到一条笼统的报错。常见做法是每个任务内部自己 try 住,把成功失败记在结果对象上,最后统一汇总,这样部分失败不会拖垮整批。
并发数设多少合适?
没有通用数字,取决于对端限制和本机核数。CPU 密集型的任务跟核数对齐就够;打外部接口的要从很小的数起步,逐步往上加并观察错误率和耗时变化。具体能承受多少,先到 /tools/speed-test 量出单次延迟基线,再据此推算。