换成.NET 9,你的LINQ代码还能快上7倍

各位 .NETer 们,大家好!自 C# 3.0 以来,语言集成查询(LINQ),特别是它的 System.Linq.Enumerable 模块(我们称为 LINQ to Objects),早已成为我们 C# 开发工具箱中的一把瑞士军刀。它那无与伦比的表达力和可读性,让我们能用声明式的优雅姿态,轻松驾驭内存中的各种集合操作。

然而,这份优雅在过去常常伴随着性能的"税"。在那些对性能要求极为苛刻的"热路径"中,我们这些老江湖们往往会小心翼翼,甚至不得不进行一种痛苦的仪式------"去 LINQ 化"(de-LINQing)。我们忍痛将那些漂亮的查询表达式,手动重写成原始粗暴的 forforeach 循环,只为从 CPU 周期中榨出最后一滴油。

但是,朋友们,时代变了!.NET 9 的发布,将从根本上颠覆这一性能格局。这不仅仅是微调,而是一次深刻的、具有战略意义的性能革命。通过对 LINQ to Objects 的一系列架构级优化,.NET 9 带来了肉眼可见的性能飞跃。对于许多常见操作,我们现在只需要重新编译一下应用,就能"免费"享受到这份性能红利。那种在可读性与性能之间反复纠结的日子,终于要一去不复返了!

今天,就让我们一起深入探索 .NET 9 中 LINQ to Objects 的性能优化,看看 .NET 团队的那些"魔法师"们,又为我们带来了哪些令人骄傲的"骚操作"。

1. .NET 9 LINQ 新速度的两大架构支柱

.NET 9 中 LINQ 的性能飞跃并非源于某个单一的黑科技,而是建立在几个关键的架构性改进之上。这些底层策略协同工作,系统性地消除了传统 LINQ 实现中的固有开销。

1.1. 通过专用迭代器融合操作 (Iterator Fusion)

传统上,LINQ 查询链(如 source.Where(...).Select(...))在执行时,每一次方法调用都会将前一个 IEnumerable<T> 封装到一个新的迭代器对象中。这个过程会创建层层嵌套的迭代器,带来额外的堆分配和虚方法调用开销,就像给数据套上了一层又一层的俄罗斯套娃。

.NET 9 的解决方案堪称绝妙:引入 "迭代器融合"(Iterator Fusion)。运行时现在能够智能识别出常见的、相邻的 LINQ 方法调用链。一旦匹配到预定义的模式,它就会绕过标准的层层封装,直接实例化一个单一的、高度专业化的"融合迭代器",这个迭代器一次性就能执行多个操作的逻辑。

案例研究: ListWhereSelectIterator<TSource, TResult>

Where(...).Select(...) 是最经典的 LINQ 操作链,也是迭代器融合的绝佳范例。在 .NET 9 之前,对一个 List<T> 执行此操作会创建至少两个迭代器对象。

而现在,.NET 9 引入了一个名为 ListWhereSelectIterator<TSource, TResult> 的内部迭代器,专门用于处理这种模式。当 Enumerable.Select 方法发现它的数据源是一个 ListWhereIterator<TSource>(即 WhereList<T> 上创建的专用迭代器)时,它不再傻傻地进行二次封装,而是直接创建一个融合了过滤和投影逻辑的 ListWhereSelectIterator 实例。

这个融合迭代器的 MoveNext() 方法,揭示了优化的核心:它在同一个循环迭代中调用了来自 Where 的谓词和来自 Select 的投影委托。这种设计干净利落地消除了一整个迭代器层级、相关的堆分配以及一次虚方法分派,直接转化为实打实的 CPU 和内存性能提升。

1.2. 利用 Span<T> 绕过枚举器开销

传统的 IEnumerable<T> 迭代方式存在固有的性能"税"。为了绕过这些开销,.NET 9 的 LINQ 实现引入了一个关键的内部快速通道(fast path):TryGetSpan() 方法。

现在,许多终端 LINQ 操作(如 Count, Any, First, ToArray 等)在执行前,会先尝试从源集合中获取一个 ReadOnlySpan<T>。如果源对象是数组(T[])或 List<T>TryGetSpan() 就能直接访问其底层连续内存,创建一个零开销的 Span<T>

一旦成功获取到 Span<T>,LINQ 操作符就可以在一个高度可优化的 for 循环中直接遍历内存,完全避免了 IEnumerable<T> 接口带来的所有开销。这是 .NET 9 中许多操作符性能大幅提升的主要原因 。虽然此模式在旧版本中已用于少数聚合方法,但 .NET 9 将其应用范围前所未有地扩展到了所有带谓词的终端操作符,实现了革命性的性能飞跃。

2. 性能为王:基准测试见真章

得益于迭代器融合和 Span<T> 快速通道,许多我们日常使用的 LINQ 操作符都获得了新生。口说无凭,我们用 BenchmarkDotNet 的数据说话。

2.1. 终端操作的零开销革命: Any, All, Count, First, 和 Single

这些终端操作符是 TryGetSpan() 优化的最大受益者。当它们作用于数组或 List<T> 时,现在可以完全在栈上完成工作,无需任何堆分配来创建枚举器。

基准测试结果令人振奋!与 .NET 8 相比,这些操作在 .NET 9 上的执行速度提升了约 7 倍 ,并且操作本身的内存分配降至零

下面是一个 BenchmarkDotNet 基准测试类,你可以亲自验证这种改进:

csharp 复制代码
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<LinqTerminalMethodsBenchmark>();

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80, baseline: true)]
[SimpleJob(RuntimeMoniker.Net90)]
public class LinqTerminalMethodsBenchmark
{
    private static readonly List<int> _dataSet = Enumerable.Range(0, 1000).ToList();

    [Benchmark]
    public bool Any() => _dataSet.Any(x => x == 1000);

    [Benchmark]
    public bool All() => _dataSet.All(x => x >= 0);

    [Benchmark]
    public int Count() => _dataSet.Count(x => x == 0);

    [Benchmark]
    public int First() => _dataSet.First(x => x == 999);

    [Benchmark]
    public int Single() => _dataSet.Single(x => x == 0);
}

表 1: 终端 LINQ 操作符在 List<int> 上的官方基准测试结果

这是我的电脑相关信息:

复制代码
BenchmarkDotNet v0.15.2, Windows 10 (10.0.19045.6093/22H2/2022Update)
Intel Core i9-9880H CPU 2.30GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.100-preview.5.25277.114
  [Host]   : .NET 8.0.17 (8.0.1725.26602), X64 RyuJIT AVX2
  .NET 8.0 : .NET 8.0.17 (8.0.1725.26602), X64 RyuJIT AVX2
  .NET 9.0 : .NET 9.0.6 (9.0.625.26613), X64 RyuJIT AVX2

这是我的基准测试结果:

Method Runtime Mean Ratio Gen0 Allocated Alloc Ratio
Any .NET 8.0 1,947.2 ns 1.00 0.0038 40 B 1.00
.NET 9.0 274.2 ns 0.14 - - 0.00
All .NET 8.0 2,199.1 ns 1.00 0.0038 40 B 1.00
.NET 9.0 267.7 ns 0.12 - - 0.00
Count .NET 8.0 2,199.7 ns 1.00 0.0038 40 B 1.00
.NET 9.0 275.7 ns 0.13 - - 0.00
First .NET 8.0 2,241.8 ns 1.00 0.0038 40 B 1.00
.NET 9.0 526.3 ns 0.23 - - 0.00
Single .NET 8.0 1,844.2 ns 1.00 0.0038 40 B 1.00
.NET 9.0 348.7 ns 0.19 - - 0.00

这些结果清楚地表明,.NET 9 在终端 LINQ 操作上的性能提升是革命性的,每个测试项都有 75%~85% 的提升。

2.2. 链式操作的融合之力: Where(...).Select(...)

正如第一节所讨论的,Where(...).Select(...) 链的性能提升是迭代器融合的直接成果。基准测试表明,当源是 List<T> 时,这个操作链的速度提升了约 57% ,内存分配更是减少了超过 60%

csharp 复制代码
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<LinqChainedMethodsBenchmark>();

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80, baseline: true)]
[SimpleJob(RuntimeMoniker.Net90)]
public class LinqChainedMethodsBenchmark
{
    private static readonly List<int> _dataSet = Enumerable.Range(0, 100_000).ToList();

    [Benchmark]
    public List<int> WhereSelect() => _dataSet.Where(x => x % 2 == 0).Select(x => x * 2).ToList();
}

Where(...).Select(...) 链的基准测试结果

Method Job Mean Ratio Gen0 Gen1 Gen2 Allocated Alloc Ratio
WhereSelect .NET 8.0 392.6 us 1.00 124.5117 124.5117 124.5117 512.56 KB 1.00
WhereSelect .NET 9.0 168.4 us 0.43 62.2559 62.2559 62.2559 195.56 KB 0.38

这种提升意味着我们可以在更少的内存开销下,处理更大的数据集,同时享受 LINQ 带来的代码可读性。

3. 为性能而设计:.NET 9 的新 LINQ 方法

除了优化现有方法,.NET 9 还为我们带来了几个全新的 LINQ API,它们的设计初衷就是为了解决常见的性能和可读性反模式。

3.1. CountBy 和 AggregateBy: 告别低效的 GroupBy

在.NET 9 之前,按键分组并进行计数或求和,我们通常使用 GroupBy 后跟 SelectCount()Sum()。这种模式最大的问题是 GroupBy 会将所有中间分组和元素都缓存在内存中,导致显著的内存开销。

现在,我们可以和这种低效说再见了!.NET 9 引入的 CountByAggregateBy 方法,为此类场景提供了单次遍历、低内存分配的完美解决方案。

表 3: GroupBy vs. CountBy/AggregateBy 对比

任务 .NET 8 及更早版本 (GroupBy) .NET 9 新方式 (CountBy/AggregateBy) 关键优势
按部门统计员工人数 employees.GroupBy(e => e.Department).ToDictionary(g => g.Key, g => g.Count()) employees.CountBy(e => e.Department) 更少内存分配,意图更清晰
按部门计算总薪资 employees.GroupBy(e => e.Department).ToDictionary(g => g.Key, g => g.Sum(e => e.Salary)) employees.AggregateBy(e => e.Department, 0m, (sum, e) => sum + e.Salary) 单次遍历,避免中间集合

看看使用新方法的代码是多么简洁:

csharp 复制代码
public record Employee(string Name, string Department, decimal Salary);

var employees = new List<Employee>
{
    new("Alice", "IT", 80000),
    new("Bob", "HR", 60000),
    new("Charlie", "IT", 95000)
};

// .NET 9: 使用 CountBy 统计各部门人数
var departmentCounts = employees.CountBy(e => e.Department);
// departmentCounts is IDictionary<string, int>

// .NET 9: 使用 AggregateBy 计算各部门总薪资
var departmentSalaries = employees.AggregateBy(
    e => e.Department,
    seed: 0m,
    (total, employee) => total + employee.Salary);
// departmentSalaries is IDictionary<string, decimal>

3.2. Index() 方法: 标准化索引迭代

在迭代时获取元素索引,这个需求太常见了。以前我们要么手动维护一个计数器,要么使用 Select((item, index) =>...) 的重载,都略显笨拙。

.NET 9 引入了 IEnumerable.Index() 方法,提供了一个全新的、标准化的、并且高度可读的解决方案。它返回一个 IEnumerable<(int index, T item)>,让我们可以用元组解构在 foreach 中优雅地同时访问索引和元素。

csharp 复制代码
var items = new[] { "Apple", "Banana", "Cherry" };

// .NET 9: 使用 Index() 方法进行优雅的索引迭代
foreach (var (index, item) in items.Index())
{
    Console.WriteLine($"Item at index {index} is {item}");
}

这绝对是一项"开发者体验"的巨大优化,减少了我们的认知负荷,消除了样板代码,让代码更优雅、更易于维护。

4. 站在巨人的肩膀上

值得一提的是,.NET 9 的性能飞跃并非一蹴而就,而是建立在 .NET 平台多年来持续优化的深厚基础之上。例如,此前 .NET 中就对 OrderBy(...).First() 这样的模式进行了智能优化,将其转换为更高效的 Min() 操作。更早的版本中,JIT 编译器就已经能够对简单的 Sum() 等操作进行自动矢量化(SIMD),榨干 CPU 的性能。

这些来自过去版本的增强,与 .NET 9 的架构革新相结合,共同构成了今天 LINQ 强大的性能表现。它体现了 .NET 团队一种持之以恒的工匠精神。

结论与战略建议

.NET 9 为 LINQ to Objects 带来了一次多维度、深层次的性能革新。它由架构创新、全新 API 和历史累积的运行时增强共同驱动。

基于以上分析,我为各位.NETer 提供以下战略性建议:

  1. 充满信心地升级首要建议就是尽快升级到.NET 9。性能优势是显著且广泛的,并且在许多情况下,仅需重新编译即可获得。
  2. 拥抱新 API :主动寻找代码库中复杂的 GroupBy 聚合链,并用更高效、更具可读性的 CountByAggregateBy 进行重构。在需要索引迭代时,果断采用 Index() 方法。
  3. 重新审视性能假设for 循环与 LINQ 之间的历史性能差距已在 .NET 9 中被大幅甚至完全抹平。在升级后,应该重新对关键的热路径进行性能分析。那些过去为了性能而被"去 LINQ 化"的代码,现在可能不再需要这种手动优化了。
  4. 为快速通道而设计 :在设计自己的数据结构或 API 时,如果可行,优先返回数组或 List<T>,以确保你的 API 的使用者能够受益于 LINQ 的 TryGetSpan() 快速通道优化。

总而言之,.NET 9 标志着 LINQ 进入了一个新时代。它已从一个单纯追求便利性的工具,转变为一个真正的高性能数据操作利器。作为.NET 开发者,我们有理由为此感到自豪!


感谢阅读到这里,如果感觉本文对您有帮助,请不吝评论点赞 ,这也是我持续创作的动力!

也欢迎加入我的 .NET骚操作 QQ群:495782587一起交流.NET 和 AI 的各种有趣玩法!