LINQ 性能分析(林秋楠)

网友投稿 328 2022-08-16

LINQ 性能分析(林秋楠)

LINQ 的优势并不是提供了什么新功能,而是让我们能够用更新、更简单、更优雅的方法来实现原有的功能。不过通常来讲,这类功能所带来的就是对性能上的影响——LINQ 也不例外。本篇文章的主要目的就是让你了解 LINQ 查询对性能的影响。我们将介绍最基本的 LINQ 性能分析方法,并提供一些数据。还会给出一些常见的误区——了解这些误区之后,我们即可小心地绕开。

一般情况下,在 .NET 框架中完成同一样工作总是会有多种不同的方法。有些时候这些不同仅仅体现在个人喜好或是代码形式的一致性上。不过在另一些情况下,个正确的选择将对整个程序起到决定性的作用。在 LINQ 中也存在这样的情况——有一些做法很适合在 LINQ 查询中使用,而有一些则应该尽量避免。

我们还是以 LINQ to Text Files 示例程序(链接:https://vinanysoft.com/c-sharp/linq-to-text-files/)作为开始。其中我们可以看到选择正确的读取文本文件方法在 LINQ 查询中的重要性。

选择恰当的流操作方式

LINQ to Text Files 示例程序存在着一个潜在的问题,即其中使用了 ReadAllLines 方法。该方法将一次性地返回 CSV 文件中的所有内容。对于小文件来说,这并没有什么问题。不过若是文件非常大的话,那么该程序则会占用相当惊人的内存!

问题还不止这些,这样的查询可能会影响到我们所期待的 LINQ 中延迟查询执行特性。在通常情况下,查询的执行将在需要时才开始,也就是说,一个查询仅在我们开始遍历其结果(例如使用 foreach 循环)的时候才会开始执行。不过在这里,ReadAllLines 方法将立即执行并将文件整个加载至内存中。但事实上很有可能程序并不完全需要其中的所有数据。

LINQ to Objects 在设计时就非常推荐以延迟的方式执行查询。这种类似于流的处理方式同样也节省了资源(内存、CPU 等)。因此我们也应该尽量使用类似的方法编写程序。

.NET 框架提供了很多种读取文本文件的方法,File.ReadAllLines 就是其中一种比较简单的。而更好的解决方案则是用 StreamReader 对象以流的方式加载文件,这样将大大节省资源,并让程序的执行更加流畅。将 StreamReader 集成到查询语句中有很多种方法,其中较为优雅的一种是创建一个自定义的查询运算符。

Lines 查询运算符,用来从 StreamReader 对象中逐一返回文本行:

public static class StreamReaderEnumerable

{

public static IEnumerable Lines(this StreamReader source)

{

string line;

if (source == null)

throw new ArgumentNullException("source");

while ((line = source.ReadLine()) != null)

yield return line;

}

}

Lines 查询运算符是以 StreamReader 类的扩展方法形式提供的。该运算符将依次返回由 StreamReader 所提供的源文件中的每行数据,不过在查询开始真正执行之前,它并不会加载任何的数据至内存。

使用 Lines 查询运算符以流的方式解析 CSV 文件:

using (StreamReader reader = new StreamReader("books.csv"))

{

var books = from line in reader.Lines()

where !line.StartsWith("#")

let parts = line.Split(',')

select new

{

Title = parts[1],

Publisher = parts[3],

Isbn = parts[0]

};

}

上述做法的优势在于,我们可以在操作大型文件的同时保持一个较小的内存占用。这类问题在提高查询执行效率方面至关重要。若没有仔细设计的话,查询语句经常会耗费大量的内存。

我们来回顾一下当前版本 LINQ to Text Files 中的改变。关键在于实现了延迟求值——对象只有在需要,即开始遍历结果时才会创建,而不是在查询的一开始就一步到位。

若我们使用 foreach 来遍历该查询的结果:

foreach (var book in books)

{

Console.WriteLine(book.Isbn);

}

foreach 循环中的 book 对象仅在当次迭代中存在,即不是集合中所有的对象都要同时存在于内存中。每一个迭代都包含了从文件中读取一行、将其分割成字符串数组、根据分割的结果创建对象等操作。一旦操作完当前对象,程序即开始读取下一行文件,直至处理完文件中的所有行。

可以看到,由于我们借助了延迟执行所带来的优势,程序使用了更少的资源,同时内存的消耗也大为降低。

当心立即执行

大多数标准查询运算符都通过迭代器实现了延迟执行。前面曾介绍过,这样将有助于降低程序耗费的资源。不过还是有些查询运算符破坏了这个优雅的延迟执行特性。实际上,这些查询运算符的行为本身就需要一次性地遍历序列中的所有元素。

通常地,那些返回数量值,而不是序列的运算符都需要与之配合的查询立即执行,例如 Aggregate、Average、Count、LongCount、Max、Min 和 Sum 等聚集运算符。这并没有什么奇怪的——聚集运算的本意就是从一组集合数据中计算出一个数量值。为了计算出这个结果,运算符需要遍历集合中的每一个元素。

除此之外,某些返回序列,而不是数量值的运算符也需要在返回之前完整遍历源序列。例如 OrderBy、OrderByDescending 和 Reverse。这类运算符将改变源序列中元素的位置。为了能够,正确地计算出序列中某个元素的位置,这些运算符需要首先对源序列进行遍历。

让我们继续使用 LINQ to Text Files 示例来详细地描述一下问题。在上一节中,我们以流的方式逐行加载源文件,而不是一次性地完全加载。如下面的代码所示:

using (StreamReader reader = new StreamReader("books.csv"))

{

var books = from line in reader.Lines()

where !line.StartsWith("#")

let parts = line.Split(',')

select new

{

Title = parts[1],

Publisher = parts[3],

Isbn = parts[0]

};

}

foreach (var book in books)

{

Console.WriteLine(book.Isbn);

}

上述代码的执行顺序是这样的。

(1)一次循环开始,使用 Lines 运算符从文件中读取一行。

a. 若整个文件已经被处理完毕,那么过程将终止。

(2)使用 Where 运算符对这一行进行操作。

a. 若该行以 # 开始,即注释行,那么将重新回到第 1 步。

b. 若该行不是注释,则继续处理。

(3)将该行分割成多个部分。

(4)通过 Select 运算符创建一个对象。

(5)根据 foreach 中的语句对 book 对象进行操作。

(6)回到第 1 步。

通过在 Visual Studio 中一步一步地进行调试即可清楚地看到上述每一步操作。这里我们也建议你能够如此调试一次,以便更清楚地了解 LINQ 查询的执行过程。

若决定以不同的顺((比如通过 orderby 子句或调用 Reverse 运算符)处理文件中的每一行,上述流程则会有所改变。例如我们在查询中添加了 Reverse 运算符,代码如下:

...

from line in reader.Lines().Reverse()

...

此时,该查询的执行顺序变成下面这样的。

(1)执行 Reverse 运算符。

a. 立即调用 Lines 运算符,读取所有行并进行反序操作。

(2)一次循环开始,获取 Reverse 运算符返回序列中的一行。

a. 若整个文件已经被处理完毕,那么过程将终止。

(3)使用 Where 运算符对这一行 进行操作。

a. 若该行以 # 开始,即注释行,那么将重新回到第 2 步。

b. 若该行不是注释,则继续处理。

(4)将该行分割为多个部分。

(5)通过 Select 运算符创建一个对象。

(6)根据 foreach 中的语句对 book 对象进行操作。

(7)回到第 2 步。

可以看到,Reverse 运算符将从前优美的管道流程完全破坏掉,因为它在最开始就将文本文件中的所有行一次性地加载到了内存中。因此,除非确实有这样的需要,否则不要轻易使用此类运算符,否则在处理大型数据源时将显著降低程序的执行效率,并占用极大的内存。

有些转换运算符也会破坏查询的延迟执行特性,例如 ToArray、ToDictionary、ToList 和 ToLookup 等。虽然这些运算符返回的也是序列,不过却是以包含源序列中所有元素的集合形式一次性给出的。为了创建将要返回的集合,这些运算符必须完整遍历源序列中的每一个元素。

现在,你已经了解了某些查询运算符的低效行为。接下来将介绍一个常见的场景,从中你会看到我们为什么要小心地使用 LINQ 以及其标准查询运算符。

LINQ to Objects 会降低代码的性能吗

很多时候 LINQ to Objects 并不能直接提供我们所要的结果。假如我们希望在一个给定的集合中,找到一个元素,该元素的某个指定属性的值在所有集合元素中最大。这就像是在一盒饼干中找到巧克力最多的那一块。这盒饼干就是那个集合,巧克力的多少就是要比较的那个属性。

一开始,你可能会想到直接使用标准查询运算符中的 Max。不过 Max 运算符仅能够返回最大的值,而不是包含这个值的对象。Max 能够帮助你找到巧克力的最多数量,不过却不能告诉你具体是那一块饼干。

在处理这个常见场景时,我们有很多种选择,包括用不同的方法使用 LINQ,或是直接使用传统的代码等。让我们先来看几种可选的、能够弥补 Max 不足的方法。

SampleData 参考链接:https://vinanysoft.com/c-sharp/linq-in-action-test-data/

各种不同的方法

第一种方法是使用 foreach 循环:

var books = SampleData.Books;

Book maxBook = null;

foreach (var book in books)

{

if (maxBook == null || book.PageCount > maxBook.PageCount)

{

maxBook = book;

}

}

这种解决方案非常易于理解,其中保留了“目前为止页数最多的图书”的引用。这种方法只需要遍历一遍集合,其时间复杂度为 O(n),除非我们能够了解更多有关该集合的信息,否则这就是理论上最快的方法。

第二种方法是先按照页数为集合中的图书对象排序,然后获取其中的第一个元素:

var books = SampleData.Books;

var sortedList = from book in books

orderby book.PageCount descending

select book;

var maxBook = sortedList.First();

在上述做法中,我们首先使用 LINQ 查询将图书集合按照页数逆序排列,随后取得排在最前面的一个元素。其缺点在于我们必须首先对整个集合进行排序,然后才能取得结果。其时间复杂度为 O(n log n)。

第三种方法是使用子查询:

var books = SampleData.Books;

var maxList = from book in books

where book.PageCount == books.Max(b => b.PageCount)

select book;

var maxBook = maxList.First();

在这个方法中,我们将找到集合中页码等于最大页数的每一本书,然后取得其中的第一本。不过这种做法将在比较每个元素时都要计算一遍最大页数,让时间复杂度上升为 O(n2)。

第四种方法是使用两个查询:

var books = SampleData.Books;

var maxPageCount = books.Max(book => book.PageCount);

var maxList = from book in books

where book.PageCount == maxPageCount

select book;

var maxBook = maxList.First();

这种做法与第三种类似,不过不会每次重复地计算最大页数——一开始就先把它计算好。这样就将时间复杂度降低至 O(n),但我们仍需要遍历该集合两次。

最后一种方法的意义在于,它能够更好地与 LINQ 集成在一起,即通过自定义的查询运算符实现。下面的代码给出了该 MaxElement 运算符的实现。

public static TElement MaxElement(

this IEnumerable source,

Func selector)

where TData : IComparable

{

if (source == null)

throw new ArgumentNullException("source");

if (selector == null)

throw new ArgumentNullException("selector");

Boolean firstElement = true;

TElement result = default(TElement);

TData maxValue = default(TData);

foreach (TElement element in source)

{

var candidate = selector(element);

if (firstElement || (candidate.CompareTo(maxValue) > 0))

{

firstElement = false;

maxValue = candidate;

result = element;

}

}

return result;

}

该查询运算符的使用方法非常简单:

var maxBook = books.MaxElement(book => book.PageCount);

下表给出了上述 5 种方法的运行时间,其中每种方法都执行了 20 次:

方法 平均时间(毫秒) 最小时间(毫秒) 最大时间(毫秒)

foreach 4.15 4 5

OrderBy + First 360.6 316 439

子查询 4432.5 4364 4558

两次查询 7.7 7 10

自定义查询运算符 7.7 7 12

测试环境为 Windows 10 专业版,AMD Ryzen 5 2400G with Radeon Vega Graphics 3.60 GHz CPU,32G 内存,程序均以 Release 模式编译。

测试代码如下:

using System;

using System.Collections.Generic;

using System.Diagnostics;

using System.Linq;

using LinqInAction.LinqBooks.Common;

static class Demo

{

public static void Main()

{

BooksForPerformance();

Console.WriteLine("{0,-20}{1,-20}{2,-20}{3,-20}", "方法", "平均时间(毫秒)", "最小时间(毫秒)", "最大时间(毫秒)");

var time = 20;

var result = Test(Foreach, time);

Console.WriteLine($"{"foreach",-22}{result.avg,-28}{result.min,-28}{result.max,-28}");

result = Test(OrderByAndFirst, time);

Console.WriteLine($"{"OrderBy + First",-22}{result.avg,-28}{result.min,-28}{result.max,-28}");

result = Test(Subquery, time);

Console.WriteLine($"{"子查询",-19}{result.avg,-28}{result.min,-28}{result.max,-28}");

result = Test(TwoQueries, time);

Console.WriteLine($"{"两次查询",-18}{result.avg,-28}{result.min,-28}{result.max,-28}");

result = Test(Custom, time);

Console.WriteLine($"{"自定义查询运算符",-14}{result.avg,-28}{result.min,-28}{result.max,-28}");

Console.ReadKey();

}

private static void BooksForPerformance()

{

var rndBooks = new Random(123);

var rndPublishers = new Random(123);

var publisherCount = SampleData.Publishers.Count();

var result = new List();

for (int i = 0; i < 1000000; i++)

{

var publisher = SampleData.Publishers.Skip(rndPublishers.Next(publisherCount)).First();

var pageCount = rndBooks.Next(1000);

result.Add(new Book

{

Title = pageCount.ToString(),

PageCount = pageCount,

Publisher = publisher

});

}

SampleData.Books = result.ToArray();

}

///

/// 第一种方法

///

///

static void Foreach()

{

var books = SampleData.Books;

Book maxBook = null;

foreach (var book in books)

{

if (maxBook == null || book.PageCount > maxBook.PageCount)

{

maxBook = book;

}

}

}

///

/// 第二种方法

///

static void OrderByAndFirst()

{

var books = SampleData.Books;

var sortedList = from book in books

orderby book.PageCount descending

select book;

var maxBook = sortedList.First();

}

///

/// 第三种方法

///

static void Subquery()

{

var books = SampleData.Books;

var maxList = from book in books

where book.PageCount == books.Max(b => b.PageCount)

select book;

var maxBook = maxList.First();

}

///

/// 第四种方法

///

static void TwoQueries()

{

var books = SampleData.Books;

var maxPageCount = books.Max(book => book.PageCount);

var maxList = from book in books

where book.PageCount == maxPageCount

select book;

var maxBook = maxList.First();

}

///

/// 第五种方法

///

static void Custom()

{

var books = SampleData.Books;

var maxBook = books.MaxElement(book => book.PageCount);

}

///

/// 测试

///

///

///

///

static (double avg, long max, long min) Test(Action action, int time)

{

List times = new List();

Stopwatch stopwatch = new Stopwatch();

for (int i = 0; i < time; i++)

{

stopwatch.Start();

action();

stopwatch.Stop();

times.Add(stopwatch.ElapsedMilliseconds);

stopwatch.Reset();

}

return (times.Average(), times.Max(), times.Min());

}

public static TElement MaxElement(

this IEnumerable source,

Func selector)

where TData : IComparable

{

if (source == null)

throw new ArgumentNullException("source");

if (selector == null)

throw new ArgumentNullException("selector");

Boolean firstElement = true;

TElement result = default(TElement);

TData maxValue = default(TData);

foreach (TElement element in source)

{

var candidate = selector(element);

if (firstElement || (candidate.CompareTo(maxValue) > 0))

{

firstElement = false;

maxValue = candidate;

result = element;

}

}

return result;

}

}

从上述统计数据中可以看到,不同做法之间的性能差异非常大。因此在使用 LINQ 查询之前,必须仔细斟酌!普遍来看,对集合只遍历一次的效率要比其他做法高很多。虽然与传统的、非 LINQ 的方式相比,自定义查询运算符的效率并不算最好,不过它仍遥遥领先于其他做法。因此你可以根据个人喜好选择是使用这个自定义查询运算符,还是回到传统的 foreach 解决方案中。而本人的观点是,虽然自定义查询运算符存在着一些性能开销,不过它显然是在 LINQ 上下文中的一种比较优雅的解决方案。

学到了什么

首先需要注意的就是 LINQ to Objects 查询的复杂度。因为我们的操作大多是耗时的循环遍历,因此更要尽可能地优化,以便节省 CPU 资源。尽量不要多次遍历同一个集合,因为这显然不是个高效的操作。换句话说,谁都不希望一次又一次地重复计算饼干上巧克力的多少。你的目标只是尽快地找到这块饼干,从而尽快开始下一步。

我们也要考虑查询将要执行的上下文。例如,同样一段查询,在 LINQ to Objects 和 LINQ to SQL 上下文中执行的效率可能有着很大的差别。因为 LINQ to SQL 将受到 SQL 语言本身的限制,并需要按照它自己的方式解释查询语句。

结论就是必须聪明地使用 LINQ to Objects。也要知道 LINQ to Objects 并不是所有问题的最终解决方案。在某些情况下,可能传统的方法要更好一些,例如直接使用 foreach 循环等。而在另一些情况下,虽然也能够使用 LINQ,不过可能需要通过创建自定义的查询运算符来提高执行效率。在 Python 中有这样一个哲学:Python 代码是为了简单、可读且可维护,而性能优化的部分则统统应该放在 C++ 中实现。与之对应的 LINQ 哲学则是:用 LINQ 的方法编写所有代码,而将优化的部分统统封装到自定义的查询运算符中。

使用 LINQ to Objects 的代价

LINQ to Objects 带来了让人惊艳的代码简洁性与可读性。而作为比较,传统的操作集合代码则显得冗长繁杂。这里将要给出一些不使用 LINQ 的理由。当然并不是真正的不使用,而是要让你知道 LINQ 在性能方面的开销。

LINQ 所提供的最简单的查询之一就是过滤,如下面的代码所示:

var query = from book in SampleData.Books

where book.PageCount > 500

select book;

上述操作也可以使用传统的方法实现。下面的代码就给出了 foreach 的实现方式:

var books = new List();

foreach (var book in SampleData.Books)

{

if (book.PageCount > 500)

{

books.Add(book);

}

}

下面的代码则使用了 for 循环

var books = new List();

for (int i = 0; i < SampleData.Books.Length; i++)

{

var book = SampleData.Books[i];

if (book.PageCount > 500)

{

books.Add(book);

}

}

下面的代码使用了 List.FindAll 方法:

var books = SampleData.Books.ToList().FindAll(book => book.PageCount > 500);

虽然还会有其他的实现方式,不过这里的主要目的并不是将它们一一列出。为了比较每种做法的性能,我们特地随机创建了一个包含一百万个对象的集合。下表给出了在 Release 模式下运行 20 次的统计结果:

方法 平均时间(毫秒) 最小时间(毫秒) 最大时间(毫秒)

foreach 18.45 13 55

for 15.2 9 63

List.FindAll 14.15 11 63

LINQ 27.05 20 77

感到出人意料?还是有些失望?LINQ to Objects 似乎要比其他方法慢了很多!不过不要立即放弃 LINQ,做决定前先看看后续的测试。

首先,这些测试结果都是基于同一个查询。若将查询略加修改,那么结果又将如何呢?例如修改

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:Java Spring Cloud 实战之路 - 1 创建项目(java安装教程详细)
下一篇:Python--flask使用 SQLAlchemy查询数据库最近时间段或之前的数据
相关文章

 发表评论

暂时没有评论,来抢沙发吧~