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 ← 在数据库端过滤
| 维度 | IEnumerable | IQueryable |
|---|---|---|
| 数据位置 | 内存集合 | 可远程数据源 |
| 执行引擎 | 委托(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);
| 运算符 | 复杂度 | 场景 |
|---|---|---|
Distinct | O(n) 哈希 | 去重 |
GroupBy | O(n) 哈希 | 分组统计 |
OrderBy | O(n log n) | 排序 |
Join | O(n) 哈希 | 关联 |
Where | O(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);
}
}
| 场景 | 选型 |
|---|---|
| 常规异步 API | Task<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 一路带到底,复杂的并发查询也能写得既快又稳。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。