LINQ 查询与异步编程

讲解 LINQ 延迟执行、IEnumerable 与 IQueryable 的本质差异,深入 Task、async await、ValueTask 与取消机制,构建现代 C# 异步编程的完整图景。

1. LINQ 查询的基本形态

一句话总结: LINQ 用声明式语法表达数据查询,把过滤、投影、分组、聚合抽象为可组合的运算符,写的是「要什么」而不是「怎么拿」。

LINQ(Language Integrated Query)是 C# 内置的查询能力。它有两种写法:查询语法(query syntax) 和 方法语法(fluent syntax),两者是同一套运算符的两种表达。

var numbers = new[] { 5, 2, 8, 1, 9, 3 };

// 查询语法(类 SQL)
var queryResult = from n in numbers
                  where n > 3
                  orderby n descending
                  select n * 10;

// 方法语法(等价)
var methodResult = numbers
    .Where(n => n > 3)
    .OrderByDescending(n => n)
    .Select(n => n * 10);

foreach (var n in methodResult)
{
    Console.WriteLine(n);
}
运算符族示例作用
过滤Where按谓词筛选元素
投影Select变换每个元素
排序OrderBy / ThenBy稳定排序
分组GroupBy按键分组
聚合Count / Sum / Average数值汇总
连接Join / GroupJoin关联两个序列

一句话: 查询语法可读、方法语法灵活。团队里统一一种为主即可——方法语法在表达复杂组合时更强大,也更容易和 lambda 无缝衔接。

2. 延迟执行与立即执行

一句话总结: Where、Select 这类运算符是延迟执行的,只有真正遍历时才触发求值;Count、ToList 是立即执行,会当场跑完整条管线。

延迟执行(deferred execution)是 LINQ 最容易踩坑、也最值得理解的性质。构建查询只是搭好「执行计划」,不碰数据;数据在第一次 foreach 或调用触发执行的运算符时才真正流动。

var source = new List<int> { 1, 2, 3 };
var query = source.Where(n => n > 1);     // 此刻不执行

source.Add(4);                            // 修改源数据

foreach (var n in query)                  // 遍历时才执行,能看到 4
{
    Console.Write(n);                     // 输出 234
}

// 立即执行:此刻求值并固化结果
var materialized = source.Where(n => n > 1).ToList();
运算符执行时机用途
Where / Select / OrderBy延迟构建管线
First() / Count() / Any()立即立刻求结果
ToList() / ToArray()立即固化快照
GroupBy / Join延迟分组结果遍历时才产出

避坑: 延迟执行 + 外部可变状态,容易产生「改完源数据结果变了」的诡异 bug。要得到稳定快照就用 ToList() 固话。另外,同一个延迟查询遍历两次,源数据会被重新求值两次——涉及昂贵 I/O 时务必只遍历一次或先固化。

3. IEnumerable 与 IQueryable 的本质差异

一句话总结: IEnumerable 在内存里用迭代器逐个算,IQueryable 把查询翻译成表达式树交给 Provider 执行,是 LINQ to Objects 与 EF Core 的分水岭。

IEnumerable<T> 代表「内存中可迭代的序列」,运算符在 CLR 里跑委托。IQueryable<T> 代表「可被查询 Provider 翻译的查询」,运算符构建的是表达式树(Expression Tree),最终由 EF Core 翻译成 SQL 在数据库端执行。

// IEnumerable:内存求值,委托直接执行
IEnumerable<Order> local = orders.Where(o => o.Total > 100);

// IQueryable:表达式树,翻译成 SQL
IQueryable<Order> remote = db.Orders.Where(o => o.Total > 100);
// SELECT * FROM Orders WHERE Total > 100  ← 在数据库端过滤
维度IEnumerableIQueryable
数据位置内存集合可远程数据源
执行引擎委托(CLR)Provider(如 SQL)
构建物迭代器链表达式树
典型来源List / 数组DbSet / 外部数据源

关键坑: 把 IQueryable 误当 IEnumerable 用(例如先 .ToList() 再 .Where()),过滤就从数据库端挪到客户端,可能全表拉到内存再过滤。顺序很重要:先拼接 IQueryable 过滤条件,最后再固化。

4. 常用查询运算符实战

一句话总结: 聚合、分组、连接、去重等运算符组合起来,能覆盖绝大多数报表与查询需求,但要注意运算符的复杂度语义。

// 分组聚合
var byCategory = products
    .GroupBy(p => p.Category)
    .Select(g => new { g.Key, Count = g.Count(), Total = g.Sum(p => p.Price) });

// 连接两个序列
var orderDetails = orders
    .Join(orderLines, o => o.Id, l => l.OrderId,
          (o, l) => new { o.Customer, l.Sku, l.Qty });

// 去重 + 取前 N
var recent = logs
    .Select(l => l.Host)
    .Distinct()
    .OrderByDescending(h => h)
    .Take(5);

// Any / All / Contains 快速判定
bool hasBig = orders.Any(o => o.Total > 1000);
运算符复杂度场景
DistinctO(n) 哈希去重
GroupByO(n) 哈希分组统计
OrderByO(n log n)排序
JoinO(n) 哈希关联
WhereO(n)过滤

避坑: LINQ to Objects 的 Where(predicate) 每次迭代都要跑一遍谓词,谓词里有昂贵计算(正则、数据库调用)时尤其要警惕。把稳定值先投影出来,或转成字典做 O(1) 查找。

5. Task 与 async await

一句话总结: async await 让异步代码写起来像同步,本质是状态机 + 回调调度;Task 代表一次可能未完成的操作,await 不阻塞线程。

C# 异步模型建立在 Task / Task<T> 之上。async 关键字标记方法,await 挂起方法直到 Task 完成,期间线程被释放回线程池。async await 编译成状态机,不是开新线程。

// async 方法:await 不阻塞线程
public async Task<string> FetchAsync(HttpClient client, string url)
{
    string body = await client.GetStringAsync(url);
    return body;
}

// 并行发起多个独立请求
public async Task<int[]> FetchManyAsync(HttpClient client, string[] urls)
{
    var tasks = urls.Select(u => client.GetStringAsync(u)).ToArray();
    string[] bodies = await Task.WhenAll(tasks);
    return bodies.Select(b => b.Length).ToArray();
}
关键字/类型作用
async标记方法可用 await,编译为状态机
await挂起但不阻塞,完成后继续
Task无返回值异步操作
Task<T>有返回值异步操作
Task.WhenAll等待多个并行任务
Task.WhenAny等待任一任务完成

避坑: async void 只在事件处理器里允许,普通方法用它会让异常无法被捕获、直接崩溃进程。同步等待异步方法用 .Result / .Wait() 会引发死锁(尤其在有 SynchronizationContext 的 UI 层),务必用 await 一路贯穿。

5.2 异步异常与状态机成本

async 方法编译成状态机,异常在 await 点抛出时会转存并重新抛出,这与同步代码的异常传播有微妙差异。理解状态机成本,才知道哪些地方「不必 async」。

// async 方法内的异常:await 时被捕获、完成时重新抛出
public async Task<string> MaybeFailAsync(HttpClient client)
{
    try
    {
        return await client.GetStringAsync("https://api.example.com");
    }
    catch (HttpRequestException ex)
    {
        return "fallback";               // 捕获的是 await 后的异常
    }
}

// 不必要的 async:没有 await 就别标 async
public Task<int> NoAwaitAsync(int x)
{
    // 直接返回 Task,避免状态机分配
    return Task.FromResult(x * 2);
}

// 等价但多一次状态机分配
// public async Task<int> NoAwaitAsync(int x) => x * 2;
写法状态机分配异常时机建议
await 一路贯穿必要开销挂起点重新抛出首选
无 await 却标 async白白分配同步抛出直接返回 Task
.Result 阻塞无状态机但死锁风险包成 AggregateException禁止
async void不可追踪线程池上崩溃仅事件处理器

避坑: async 状态机本身有分配成本(约几十字节),高频短路径方法若没有真 await,直接返回 Task.FromResult 或 ValueTask 更划算。异常方面,多个 Task.WhenAll 中任何一个失败,异常在 await 处重新抛出,若要逐个排查错误,需遍历每个 task 的 Exception 属性。

6. ValueTask 与取消机制

一句话总结: ValueTask 避免热点路径的堆分配,CancellationToken 把协作式取消贯穿整个异步链路,是高性能与可靠性的双保险。

ValueTask<T> 是 Task<T> 的轻量替代:当结果同步可用时,它不产生任何堆分配。适合「多数时候立即返回、偶尔真的异步」的热路径方法。CancellationToken 让调用方可以在中途请求停止,被调用方应尽早检查并抛出 OperationCanceledException。

// ValueTask:同步完成时不分配堆对象
public ValueTask<int> TryGetAsync(int key, CancellationToken ct = default)
{
    if (cache.TryGetValue(key, out int value))
    {
        return new ValueTask<int>(value);   // 零分配
    }
    return new ValueTask<int>(LoadAsync(key, ct));
}

// 取消:传播到 HttpClient 与自定义循环
public async Task DownloadAsync(HttpClient client, string url,
                                CancellationToken ct)
{
    using var resp = await client.GetAsync(url, ct);
    await foreach (var chunk in resp.Content.ReadAsStreamWithCancellation(ct))
    {
        ct.ThrowIfCancellationRequested();   // 主动响应取消
        ProcessChunk(chunk);
    }
}
场景选型
常规异步 APITask<T>
高频、常同步完成ValueTask<T>
可中止的耗时操作带 CancellationToken 参数
批量等待WhenAll + 每个任务透传 token

避坑: ValueTask 只能 await 一次,不能缓存、不能 WhenAll——因为它可能没有堆对象支撑多次等待。取消的正确姿势是全程传递同一个 token:忘记把 token 传进内层方法,外层取消就只是做样子。

7. 异步流与 PLINQ 的取舍

一句话总结: IAsyncEnumerable 让生产者按需异步产出元素,PLINQ 用并行加速 CPU 密集的 LINQ 管线,二者适用场景截然不同。

异步流用 await foreach 消费 IAsyncEnumerable<T>,适合分页拉取、流式读取这类「元素本身要异步获取」的场景。PLINQ 把 LINQ 运算符并行化,适合纯 CPU 密集、无共享状态的集合运算。

// 异步流:按页拉取,边拉边消费
public async IAsyncEnumerable<Order> StreamOrdersAsync(int pageSize,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    int page = 0;
    while (true)
    {
        var batch = await GetPageAsync(page++, pageSize, ct);
        if (batch.Count == 0) yield break;
        foreach (var o in batch) yield return o;
    }
}

// 消费端
await foreach (var order in StreamOrdersAsync(100))
{
    Console.WriteLine(order.Id);
}

// PLINQ:CPU 密集集合运算并行化
var heavy = Enumerable.Range(0, 1_000_000)
    .AsParallel()
    .Select(x => ExpensiveCompute(x))
    .ToArray();
场景工具注意
流式数据、分页IAsyncEnumerable每页一次 await
CPU 密集大集合PLINQ避免共享可变状态
I/O 密集并发Task.WhenAll由线程池调度
内存查询组合LINQ to Objects顺序执行

一句话: I/O 密集用 Task 并发、数据流式用 IAsyncEnumerable、CPU 密集用 PLINQ。三者共享的原则是——别让串行等待成为瓶颈,也别让并发破坏正确性。

8. 总结

环节要点
LINQ 形态查询语法与方法语法等价,选一种主用
执行时机Where 等延迟、ToList 等立即,快照用固化
IEnumerable vs IQueryable内存求值 vs 表达式树翻译,先过滤后固化
运算符分组/连接/聚合覆盖报表,注意复杂度
async await状态机调度,不阻塞线程
ValueTask / 取消热路径零分配,token 全程传递
异步流 / PLINQ流式用 IAsyncEnumerable,CPU 密集用 PLINQ

LINQ 与异步是 C# 开发者每天都要打交道的能力。LINQ 的钥匙是执行时机,异步的钥匙是调用链贯穿——把延迟执行想清楚,把 await 和 token 一路带到底,复杂的并发查询也能写得既快又稳。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. 消息与后台任务
  2. 缓存与并发控制
  3. 测试体系:xUnit 与 Moq