为什么把 LINQ 与异步放在一起练

在真实项目里,集合处理和 I/O 往往同时出现:先从数据库取出一批订单,再对每条记录调用远程接口补全信息,最后聚合成报表。LINQ 负责「怎么把数据变形成想要的形状」,异步负责「怎么在等 I/O 时不傻等」。两者结合,才是现代 .NET 服务端代码的日常形态。

分开学它们都不难,难的是在同一套业务流程里把两者用对——什么时候该并行,什么时候该顺序。
本篇要解决的问题
  • 查询把多层循环与筛选压缩成可读的链式表达式。
  • 并行判断一段计算是否真的值得交给 PLINQ。
  • 并发用 Task 组合多个互不依赖的 I/O,而不是开一堆线程。
  • 取消让漫长的操作能被用户或超时及时终止。
C# LINQ 与异步实战配图:多条发光数据流汇聚成中央管道结构
集合的变形与 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。