app开发者平台在数字化时代的重要性与发展趋势解析
1017
2022-09-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
{
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
Func
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
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
Func
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
var books = SampleData.Books.ToList().FindAll(book => book.PageCount > 500);
虽然还会有其他的实现方式,不过这里的主要目的并不是将它们一一列出。为了比较每种做法的性能,我们特地随机创建了一个包含一百万个对象的集合。下表给出了在 Release 模式下运行 20 次的统计结果:
方法 平均时间(毫秒) 最小时间(毫秒) 最大时间(毫秒)
foreach 18.45 13 55
for 15.2 9 63
List
LINQ 27.05 20 77
感到出人意料?还是有些失望?LINQ to Objects 似乎要比其他方法慢了很多!不过不要立即放弃 LINQ,做决定前先看看后续的测试。
首先,这些测试结果都是基于同一个查询。若将查询略加修改,那么结果又将如何呢?例如修改
版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。
发表评论
暂时没有评论,来抢沙发吧~