为什么把 LINQ 与异步放在一起练
在真实项目里,集合处理和 I/O 往往同时出现:先从数据库取出一批订单,再对每条记录调用远程接口补全信息,最后聚合成报表。LINQ 负责「怎么把数据变形成想要的形状」,异步负责「怎么在等 I/O 时不傻等」。两者结合,才是现代 .NET 服务端代码的日常形态。
分开学它们都不难,难的是在同一套业务流程里把两者用对——什么时候该并行,什么时候该顺序。
- 查询把多层循环与筛选压缩成可读的链式表达式。
- 并行判断一段计算是否真的值得交给 PLINQ。
- 并发用 Task 组合多个互不依赖的 I/O,而不是开一堆线程。
- 取消让漫长的操作能被用户或超时及时终止。
LINQ:链式查询与延迟执行的边界
LINQ 的核心价值是把「筛选、投影、分组、排序」写成声明式表达式。它默认延迟执行:链式调用本身不产生结果,只有在 foreach、ToList、Count 等触发点才真正计算。这让你可以自由地拼装查询而不必担心每一步都生成中间集合。
var summary = orders
.Where(o => o.Status == OrderStatus.Paid)
.GroupBy(o => o.City)
.Select(g => new
{
City = g.Key,
Count = g.Count(),
Revenue = g.Sum(o => o.Amount)
})
.OrderByDescending(x => x.Revenue)
.ToList();
需要记住三条边界:第一,延迟查询可能被重复求值,如果同一个查询要被用多次,先用 ToList() 固化;第二,Count()、Any() 这类方法的代价取决于序列类型,已经是 ICollection 时优先用属性;第三,查询里的 lambda 不要做有副作用的操作,否则每次枚举都会重复触发,bug 会非常隐蔽。
并行化:PLINQ 与聚合
当数据量足够大、且每条记录的计算彼此独立时,可以把普通 LINQ 换成 PLINQ:加上 AsParallel(),框架会自动把工作分摊到多个核心。它最适合 CPU 密集、无共享状态的映射与过滤,例如对几十万张图片做特征提取。
var features = images
.AsParallel()
.WithDegreeOfParallelism(Environment.ProcessorCount)
.Select(img => ExtractFeature(img))
.Where(v => v.Confidence > 0.8)
.ToList();
但不要把 PLINQ 当银弹。数据量小的时候,线程调度的开销会超过并行收益;lambda 里若访问共享集合或数据库连接,还会引入锁竞争甚至线程安全问题。判断标准很简单:先量一下单线程版本耗时多少,只有在毫秒级以上、且计算无副作用时再考虑并行。
async/await:并发而不是并跑
await 的本质是「等待期间释放线程去干别的」,而不是「同时开多条线程」。对于 I/O 密集场景,这意味着用很少的线程就能支撑大量并发请求。下面这个反例最常见——把异步方法用 .Result 同步阻塞,既丢掉了异步的优势,又可能在有同步上下文的界面程序里造成死锁。
- 别阻塞不要用
.Result或.Wait()等异步方法。 - 签名库与接口一律返回
Task,async void只留给事件。 - 提前启动互不依赖的任务先
var t = XAsync(),再统一 await。 - 库代码加
ConfigureAwait(false),减少不必要的上下文切回。
Task 组合:WhenAll、WhenAny 与取消
真正提升吞吐的是任务组合。假设要为每笔订单查询物流、支付、用户三个下游服务,它们彼此独立,正确做法是先并行启动三个任务,再用 Task.WhenAll 等待全部完成,总耗时取决于最慢的那个,而不是三者之和。
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
Task<Track[]> t1 = FetchLogisticsAsync(order.Id, cts.Token);
Task<PayInfo> t2 = FetchPayAsync(order.Id, cts.Token);
Task<User> t3 = FetchUserAsync(order.UserId, cts.Token);
await Task.WhenAll(t1, t2, t3);
Task.WhenAny 则适合「谁先返回就用谁」的场景,例如向多个镜像源请求同一份数据。CancellationToken 要一路向下传递:超时到了,网络层会自动中断挂起的请求,而不是让任务一直空等到完成。WhenAll 只会抛出第一个异常,需要拿到全部失败原因时,应单独遍历每个任务的 Exception。
性能误区与调试手段
第一个误区是过早优化。在没有性能数据前就把循环改成 LINQ 或改成并行,往往只是让代码更难懂。先以可读性优先,再用 profiling 定位真正的热点。第二个误区是忽略分配:LINQ 在链式调用中会为闭包、枚举器产生临时对象,高吞吐路径上频繁调用时,GC 压力会成为瓶颈,必要时回退成手写循环或用数组直读。
调试异步代码时,善用「并行堆栈」窗口,它能把同一个逻辑操作在多个线程上的片段拼起来看;遇到「偶尔挂死」,多半是某处同步等待或未释放的 CancellationTokenSource,记得用 using 确保它被释放。
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
var result = await CallRemoteAsync(cts.Token);
Console.WriteLine(result);
}
catch (OperationCanceledException)
{
Console.WriteLine("超时已取消,未阻塞主线程");
}
小结与延伸阅读
把本篇浓缩成三句话:LINQ 负责把数据变形写清楚,异步负责在等 I/O 时不占线程,Task 组合负责把互不依赖的操作并行起来。三者的共同前提,是你先理解每一步「什么时候真正执行」。
- 语言基础看《C# 语言核心:.NET 开发基石》,补齐类型、可空性与资源释放。
- 框架能力看《.NET 8 新特性与应用开发》,理解 Minimal API 如何承载异步接口。
- 后台常驻场景,接《Windows 服务与系统编程实战》。