百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 热门文章 > 正文

LINQ 内部结构:速度优化

bigegpt 2024-08-18 14:14 3 浏览


LINQ 优雅的外观背后隐藏着一个精心设计的核心,其设计不仅仅是为了简单。 本文深入探讨 LINQ 的技术复杂性,重点关注提高其执行效率的“速度优化”。 这些优化也有很多缺点。

LINQ

LINQ 是“语言集成查询”的缩写。 它的名字来自于直接用 C# 编写查询的能力:

var query =
    from person in people
    where person.Age > 20
    select person.Name;

优点是所有查询组件都可以在编译时由编译器验证,而不是让应用程序在运行时失败。 还支持自动完成。

SharpLab 是一个很好的工具,可以查看编译器对代码的实际操作。 您可以看到查询实际上已转换为与以下内容等效的内容:

var query = 
  Enumerable.Select(Enumerable.Where(people, person => person.Age > 20), person => person.Name);

Enumerable 是在 System.Linq 命名空间中声明的静态类。 这意味着它的所有方法都是静态的,包括此查询中使用的 Select() 和Where() 方法。

注意:Enumerable 是一个包含数百个方法的庞大类。 这实际上是使用partial 关键字的一个很好的例子。 这允许类被分成多个文件。 请随时在 https://github.com/dotnet/dotnet/tree/main/src/runtime/src/libraries/System.Linq/src/System/Linq 检查源代码

Enumerable 中的所有方法实际上都被声明为 IEnumerable<T> 的扩展方法。 这意味着查询可以重写如下:

var query = people
    .Where(person => person.Age > 20)
    .Select(person => person.Name);

这样更容易理解。 people 是一个实现 IEnumerable<Person> 的集合,然后Where() 应用谓词函数并返回实现 IEnumerable<T> 的对象的实例,然后 Select() 应用选择器函数并返回实现 IEnumerable<T> 的另一个对象的实例 IEnumerable<字符串>。 这意味着查询将保留对 Select() 返回的 IEnumerable<string> 的引用。

这里重要的是 Enumerable 类中声明的方法支持 LINQ。 LINQ 的性能是由这些方法的性能定义的。 在本文中,我将尝试解释 LINQ 中用于提高其性能的许多技巧。

IQueryable<T>

LINQ 实际上提供了另一种查询机制,该机制由 IQueryable<T> 接口支持,该接口也在 System.Linq 命名空间中定义。 在这种情况下,查询被委托给提供者,该提供者在运行时将查询转换为表达式树,然后生成可由其他引擎解释的查询。

您可能会发现多个“LINQ to SQL”提供程序将查询转换为可由数据库执行的 SQL 查询。 您还可能会找到其他提供程序,例如 LinqToExcel、LinqToTwitter、LinqToCsv、LINQ-to-BigQuery、LINQ-to-GameObject-for-Unity、ElasticLINQ、GpuLinq 等等。 它们可以在非常不同的引擎中执行相同的查询。

使用提供程序时,查询在其他地方执行,性能取决于许多因素。 因此,这种类型的查询执行超出了本文的讨论范围。

隐式枚举器崩溃

LINQ 操作是可组合的,这意味着一个操作的输出可以是另一个操作的输入。 我们已经在上面看到 Select() 可以在Where()之后使用。

在没有任何优化的情况下,Where() 和 Select() 的实现类似于以下内容:

public static IEnumerable<TSource> Where<TSource>(
    this IEnumerable<TSource> source, 
    Func<TSource, bool> predicate)
{
    foreach(var item in source)
    {
        if(predicate(item))
            yield return item;
    }
}


public static IEnumerable<TResult> Select<TSource, TResult>(
    this IEnumerable<TSource> source, 
    Func<TSource, TResult> selector)
{
    foreach(var item in source)
    {
        yield return selector(item);
    }
}     

每个操作都会返回一个可枚举值。 这意味着要遍历其组合结果,必须创建每个枚举器的实例,必须从枚举器“拉”每个项目,该枚举器从另一个枚举器“拉”该项目,后者从源“拉” 枚举器。 操作组合是使用 LINQ 的一大优势,但也是最大的性能问题之一。 幸运的是,在某些情况下可以避免这种情况。

在Where()之后使用Select()是一种非常常见的使用模式。 为了提高性能,Where() 返回的枚举具有 Select() 的重载,该重载返回一个采用谓词和选择器作为参数的枚举。 这相当于执行以下命令:

public static IEnumerable<TResult> WhereSelect<TSource, TResult>(
    this IEnumerable<TSource> source, 
    Func<TSource, bool> predicate, 
    Func<TSource, TResult> selector)
{
    foreach(var item in source)
    {
        if(predicate(item))
            yield return selector(item);
    }
}

它在一个 foreach 循环中同时应用谓词和选择器。 这意味着减少了一层枚举器来“拉动”项目,从而获得更好的性能。

注意:查看我的另一篇文章“高效数据处理:利用 C# 的 foreach 循环”以了解 foreach 的工作原理。

如果您碰巧连续有两个Where() 或两个Select() 操作,也会发生同样的情况。 这相当于执行以下命令之一:

public static IEnumerable<TSource> Where<TSource>(
    this IEnumerable<TSource> source, 
    Func<TSource, bool> predicate1, 
    Func<TSource, bool> predicate2)
    => source.Where(item => predicate1(item) && predicate2(item));
    
public static IEnumerable<TResult> Select<TSource, TMiddle, TResult>(
    this IEnumerable<TSource> source, 
    Func<TSource, TMiddle> selector1, 
    Func<TMiddle, TResult> selector2)
    => source.Select(item => selector2(selector1(item)));

所有这些枚举器优化都是隐式发生的。 您无需更改代码中的任何内容即可使它们发生。

显式枚举器崩溃

在某些情况下,您必须显式使用某个重载,以便折叠枚举器。 First()、FirstOrDefault()、Single()、SingleOrDefault()、Last()、LastOrDefault()、Any() 和 Count() 等操作就是这种情况。 所有这些操作都有一个以谓词作为参数的重载。 它与使用相同谓词在无参数扩展之前使用Where() 具有完全相同的结果。 这意味着, source.Where(predicate).Count() 的结果与 source.Count(predicate) 相同,但第二个可能具有更好的性能。

以下是 Count() 操作的可能实现(无需其他优化):

public static int Count<TSource>(
    this IEnumerable<TSource> source
{
    var count = 0;
    using(var enumerator = source.GetEnumerator())
    {
        checked
        {
            while(enumerator.MoveNext())
                count++;
        }
    }
    return count;
}

public static int Count<TSource>(
    this IEnumerable<TSource> source, 
    Func<TSource, bool> predicate)
{
    var count = 0;
    foreach(var item in source)
    {
        checked
        {
            if(predicate(item))
                count++;
        }
    }
    return count;
})

第二个重载使用一个 foreach 来进行过滤和求和。 这可以避免使用枚举器来执行谓词。

请注意,隐式折叠对于这些重载不起作用。

源类型

所有 LINQ 方法都是 IEnumerable<T> 的扩展方法。 正如我在之前的几篇文章中已经解释过的,就遍历集合时的性能而言,IEnumerable<T> 是最糟糕的情况。 有多种集合类型可以提供更好的性能。

LINQ 库假定源集合可能已转换为不同的类型。 例如,List<T> 可以转换为 IEnumerable<T>。 例如,通过这样做,就不可能显式调用 Count 属性来查找它包含的项目数。

例如,要查找集合是否为空,我们应该使用 Any() 操作。 没有优化的情况下,其实现如下:

public static bool Any<TSource>(this IEnumerable<TSource> source)
{
    using(var enumerator = source.GetEnumerator())
    {
        return enumerator.MoveNext();
    }
}

它必须创建枚举器的实例,然后调用 MoveNext() 方法。 如果该方法返回 true,则意味着它至少包含一项。

许多集合(例如 List<T>)在内部存储项目数。 对于数组和跨度,通常可以使用 Length 属性来检索,或者在其他情况下使用 Count 属性来检索。 这些集合通常实现提供 Count 属性的接口 IReadOnlyCollection<T> 和 ICollection<T>。 调用这些属性应该比使用枚举器更快。

注意:没有任何 LINQ 操作检查 IReadOnlyCollection<T> 和 IReadOnlyList<T> 类型。 正如我在之前的文章中所解释的,实现这些接口的集合还必须分别实现 ICollection<T> 和 IList<T>,以便 LINQ 可以优化它们的遍历。

如果检查 Any() 的实现,您会发现它调用内部方法 TryGetNonEnumeratedCount() 来查找集合是否提供 Count 属性。 然后,此方法检查源集合是否为 null,以及它是否实现 ICollection()、IIListProvider<T> 或 ICollection。 如果该方法返回 true,则 Any() 使用 Count 属性,否则使用枚举器。 这一切都是在运行时完成的,消耗CPU时间。

注意:Iterator<T>、IIListProvider<T> 和 IPartition<T> 是内部接口,只有内部枚举可以实现,因此只有这些可以利用这些优化。

此初始化时间在 Any() 等操作中或当集合中的项目很少时最为明显。 对于包含大量项目的集合,这种初始化时间是非常值得的,因为在每个项目上节省少量时间将为完全遍历集合节省大量时间。

这些优化带来了很多问题。 首先,并非所有方法都具有这些优化,并且具有这些优化的方法不一致且没有记录。 您必须检查每个操作及其组合的源代码。

值类型枚举器

对于源是 List<T> 的情况,Where() 使用自定义WhereListIterator<T>。 这个“迭代器”保证 List<T> 提供的值类型枚举器不被装箱。

注意:查看我的另一篇文章“C# 中值类型与引用类型枚举器的性能”,了解为什么值类型枚举器很重要。

另一方面,.NET 提供的所有集合确实都有值类型枚举器,但 LINQ 不会优化它们,因为此解决方案不是通用的,它仅适用于 List<T>。

此外,LINQ 不提供任何值类型枚举器,因为所有返回可枚举值的操作都返回 IEnumerable<T>,而不是显式枚举器类型。

通用数学

在 .NET 中实现数学运算一直是一个挑战,因为数学运算符无法在接口中定义。 这导致 LINQ 必须为 .NET 提供的每种数字类型提供 Sum() 和 Average() 的特定实现。

不幸的是,.NET 8 微弱地尝试使用这个新功能。 它仅在内部使用它,并且仍然提供数字类型的所有重载。 这意味着第三方数字类型仍然无法有效地使用 Sum() 和 Average() 方法。

注意:查看我的另一篇文章“.NET 中的通用数学”,了解如何针对您自己的数字类型进行优化。

SIMD

.NET 8 中的 LINQ 最终使用了 SIMD。 不幸的是,它的使用场景非常有限:

Sum() 但仅当项目类型为 int 或 long 时。

Average() 但仅当项目类型为 int 时。

不幸的是,只有这些场景才能保证处理 NaN 和 Infinite 时的向后兼容性。

注意:查看我的另一篇文章“.NET 中的单指令、多数据 (SIMD)”,了解什么是 SIMD 以及如何在自己的代码中使用它。

结论

IEnumerable<T> 提供的抽象使 LINQ 成为一个出色的库,以便可以轻松实现和维护数据处理。 同时,它也是 .NET 应用程序性能不足的最大原因。

.NET 性能的提高主要归功于 JIT 编译器的改进。 该框架还通过新的 API 进行了改进。 然而,这些不能用于保持 LINQ 100% 向后兼容。 很多内部优化与最新进展不一致。

相关推荐

悠悠万事,吃饭为大(悠悠万事吃饭为大,什么意思)

新媒体编辑:杜岷赵蕾初审:程秀娟审核:汤小俊审签:周星...

高铁扒门事件升级版!婚宴上‘冲喜’老人团:我们抢的是社会资源

凌晨两点改方案时,突然收到婚庆团队发来的视频——胶东某酒店宴会厅,三个穿大红棉袄的中年妇女跟敢死队似的往前冲,眼瞅着就要扑到新娘的高额钻石项链上。要不是门口小伙及时阻拦,这婚礼造型团队熬了三个月的方案...

微服务架构实战:商家管理后台与sso设计,SSO客户端设计

SSO客户端设计下面通过模块merchant-security对SSO客户端安全认证部分的实现进行封装,以便各个接入SSO的客户端应用进行引用。安全认证的项目管理配置SSO客户端安全认证的项目管理使...

还在为 Spring Boot 配置类加载机制困惑?一文为你彻底解惑

在当今微服务架构盛行、项目复杂度不断攀升的开发环境下,SpringBoot作为Java后端开发的主流框架,无疑是我们手中的得力武器。然而,当我们在享受其自动配置带来的便捷时,是否曾被配置类加载...

Seata源码—6.Seata AT模式的数据源代理二

大纲1.Seata的Resource资源接口源码2.Seata数据源连接池代理的实现源码3.Client向Server发起注册RM的源码4.Client向Server注册RM时的交互源码5.数据源连接...

30分钟了解K8S(30分钟了解微积分)

微服务演进方向o面向分布式设计(Distribution):容器、微服务、API驱动的开发;o面向配置设计(Configuration):一个镜像,多个环境配置;o面向韧性设计(Resista...

SpringBoot条件化配置(@Conditional)全面解析与实战指南

一、条件化配置基础概念1.1什么是条件化配置条件化配置是Spring框架提供的一种基于特定条件来决定是否注册Bean或加载配置的机制。在SpringBoot中,这一机制通过@Conditional...

一招解决所有依赖冲突(克服依赖)

背景介绍最近遇到了这样一个问题,我们有一个jar包common-tool,作为基础工具包,被各个项目在引用。突然某一天发现日志很多报错。一看是NoSuchMethodError,意思是Dis...

你读过Mybatis的源码?说说它用到了几种设计模式

学习设计模式时,很多人都有类似的困扰——明明概念背得滚瓜烂熟,一到写代码就完全想不起来怎么用。就像学了一堆游泳技巧,却从没下过水实践,很难真正掌握。其实理解一个知识点,就像看立体模型,单角度观察总...

golang对接阿里云私有Bucket上传图片、授权访问图片

1、为什么要设置私有bucket公共读写:互联网上任何用户都可以对该Bucket内的文件进行访问,并且向该Bucket写入数据。这有可能造成您数据的外泄以及费用激增,若被人恶意写入违法信息还可...

spring中的资源的加载(spring加载原理)

最近在网上看到有人问@ContextConfiguration("classpath:/bean.xml")中除了classpath这种还有其他的写法么,看他的意思是想从本地文件...

Android资源使用(android资源文件)

Android资源管理机制在Android的开发中,需要使用到各式各样的资源,这些资源往往是一些静态资源,比如位图,颜色,布局定义,用户界面使用到的字符串,动画等。这些资源统统放在项目的res/独立子...

如何深度理解mybatis?(如何深度理解康乐服务质量管理的5个维度)

深度自定义mybatis回顾mybatis的操作的核心步骤编写核心类SqlSessionFacotryBuild进行解析配置文件深度分析解析SqlSessionFacotryBuild干的核心工作编写...

@Autowired与@Resource原理知识点详解

springIOCAOP的不多做赘述了,说下IOC:SpringIOC解决的是对象管理和对象依赖的问题,IOC容器可以理解为一个对象工厂,我们都把该对象交给工厂,工厂管理这些对象的创建以及依赖关系...

java的redis连接工具篇(java redis client)

在Java里,有不少用于连接Redis的工具,下面为你介绍一些主流的工具及其特点:JedisJedis是Redis官方推荐的Java连接工具,它提供了全面的Redis命令支持,且...