VB.net 2010 视频教程 VB.net 2010 视频教程 python基础视频教程
SQL Server 2008 视频教程 c#入门经典教程 Visual Basic从门到精通视频教程
当前位置:
首页 > 编程开发 > C#编程 >
  • C#教程之LINQ 性能分析

本站最新发布   C#从入门到精通
试听地址  
https://www.xin3721.com/eschool/CSharpxin3721/

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

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

我们还是以 LINQ to Text Files 示例程序(链接:https://www.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> 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 对象仅在当次迭代中存在,即不是集合中所有的对象都要同时存在于内存中。每一个迭代都包含了从文件中读取一行、将其分割成字符串数组、根据分割的结果创建对象等操作。一旦操作完当前对象,程序即开始读取下一行文件,直至处理完文件中的所有行。

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

当心立即执行

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

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

除此之外,某些返回序列,而不是数量值的运算符也需要在返回之前完整遍历源序列。例如 OrderByOrderByDescending 和 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 运算符将从前优美的管道流程完全破坏掉,因为它在最开始就将文本文件中的所有行一次性地加载到了内存中。因此,除非确实有这样的需要,否则不要轻易使用此类运算符,否则在处理大型数据源时将显著降低程序的执行效率,并占用极大的内存。

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

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

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

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

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

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

SampleData 参考链接:https://www.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<TElement, TData>(
    this IEnumerable<TElement> source,
    Func<TElement, TData> selector)
    where TData : IComparable<TData>
{
    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<Book>();
        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();
    }

    /// <summary>
    /// 第一种方法
    /// </summary>
    /// <returns></returns>
    static void Foreach()
    {
        var books = SampleData.Books;
        Book maxBook = null;
        foreach (var book in books)
        {
            if (maxBook == null || book.PageCount > maxBook.PageCount)
            {
                maxBook = book;
            }
        }
    }

    /// <summary>
    /// 第二种方法
    /// </summary>
    static void OrderByAndFirst()
    {
        var books = SampleData.Books;
        var sortedList = from book in books
                         orderby book.PageCount descending
                         select book;
        var maxBook = sortedList.First();
    }

    /// <summary>
    /// 第三种方法
    /// </summary>
    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();
    }

    /// <summary>
    /// 第四种方法
    /// </summary>
    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();
    }

    /// <summary>
    /// 第五种方法
    /// </summary>
    static void Custom()
    {
        var books = SampleData.Books;
        var maxBook = books.MaxElement(book => book.PageCount);
    }

    /// <summary>
    /// 测试
    /// </summary>
    /// <param name="action"></param>
    /// <param name="time"></param>
    /// <returns></returns>
    static (double avg, long max, long min) Test(Action action, int time)
    {
        List<long> times = new List<long>();
        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<TElement, TData>(
        this IEnumerable<TElement> source,
        Func<TElement, TData> selector)
        where TData : IComparable<TData>
    {
        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<Book>();
foreach (var book in SampleData.Books)
{
    if (book.PageCount > 500)
    {
        books.Add(book);
    }
}

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

var books = new List<Book>();
for (int i = 0; i < SampleData.Books.Length; i++)
{
    var book = SampleData.Books[i];
    if (book.PageCount > 500)
    {
        books.Add(book);
    }
}

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

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

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

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

foreach               18.45                       13                          55
for                   15.2                        9                           63
List<T>.FindAll       14.15                       11                          63
LINQ                  27.05                       20                          77

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

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

相关教程