LINQ 性能分析

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 对象中逐一返回文本行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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 来遍历该查询的结果:

1
2
3
4
foreach (var book in books)
{
    Console.WriteLine(book.Isbn);
}

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

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

当心立即执行

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

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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 运算符,代码如下:

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

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

现在,你已经了解了某些查询运算符的低效行为。接下来将介绍一个常见的场景,从中你会看到我们为什么要小心地使用 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 循环:

1
2
3
4
5
6
7
8
9
var books = SampleData.Books;
Book maxBook = null;
foreach (var book in books)
{
    if (maxBook == null || book.PageCount > maxBook.PageCount)
    {
        maxBook = book;
    }
}

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

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

1
2
3
4
5
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)。

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

1
2
3
4
5
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)。

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

1
2
3
4
5
6
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 运算符的实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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;
}

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

1
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 模式编译。

测试代码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
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 所提供的最简单的查询之一就是过滤,如下面的代码所示:

1
2
3
var query = from book in SampleData.Books
            where book.PageCount > 500
            select book;

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

1
2
3
4
5
6
7
8
var books = new List<Book>();
foreach (var book in SampleData.Books)
{
    if (book.PageCount > 500)
    {
        books.Add(book);
    }
}

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

1
2
3
4
5
6
7
8
9
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 方法:

1
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,做决定前先看看后续的测试。

首先,这些测试结果都是基于同一个查询。若将查询略加修改,那么结果又将如何呢?例如修改 where 子句中的条件,将比较整型字段 PageCount 改为比较字符串字段 Tit1e

1
2
3
var result = (from book in books
                where book.Title.StartsWith("l")
                select book).ToList();

按照同样方式修改其他的测试代码,并再次运行 20 次。其结果将如下表所示:

方法                  平均时间(毫秒)            最小时间(毫秒)            最大时间(毫秒)
foreach               144.3                       136                         177
for                   134.55                      125                         156
List<T>.FindAll       136.45                      131                         161
LINQ                  148.4                       136                         193

测试代码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using LinqInAction.LinqBooks.Common;

static class Demo
{
    public static void Main()
    {
        var books = BooksForPerformance();

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

        var time = 20;
        var result = Test(Foreach, books, time);
        Console.WriteLine($"{"foreach",-22}{result.avg,-28}{result.min,-28}{result.max,-28}");

        result = Test(For, books, time);
        Console.WriteLine($"{"for",-22}{result.avg,-28}{result.min,-28}{result.max,-28}");

        result = Test(FindAll, books, time);
        Console.WriteLine($"{"List<T>.FindAll",-22}{result.avg,-28}{result.min,-28}{result.max,-28}");

        result = Test(Linq, books, time);
        Console.WriteLine($"{"LINQ",-22}{result.avg,-28}{result.min,-28}{result.max,-28}");



        Console.ReadKey();
    }

    private static List<Book> 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
            });
        }

        return result;
    }

    /// <summary>
    /// 第一种方法
    /// </summary>
    /// <returns></returns>
    static void Foreach(List<Book> books)
    {
        var result = new List<Book>();
        foreach (var book in books)
        {
            if (book.Title.StartsWith("l"))
            {
                result.Add(book);
            }
        }
    }

    /// <summary>
    /// 第二种方法
    /// </summary>
    static void For(List<Book> books)
    {
        var result = new List<Book>();
        for (int i = 0; i < books.Count; i++)
        {
            var book = books[i];
            if (book.Title.StartsWith("l"))
            {
                result.Add(book);
            }
        }
    }

    /// <summary>
    /// 第三种方法
    /// </summary>
    static void FindAll(List<Book> books)
    {
        var result = books.FindAll(book => book.Title.StartsWith("l"));
    }

    /// <summary>
    /// 第四种方法
    /// </summary>
    static void Linq(List<Book> books)
    {
        var result = (from book in books
                      where book.Title.StartsWith("l")
                      select book).ToList();
    }


    /// <summary>
    /// 测试
    /// </summary>
    /// <param name="action"></param>
    /// <param name="books"></param>
    /// <param name="time"></param>
    /// <returns></returns>
    static (double avg, long max, long min) Test(Action<List<Book>> action, List<Book> books, int time)
    {
        List<long> times = new List<long>();
        Stopwatch stopwatch = new Stopwatch();

        for (int i = 0; i < time; i++)
        {
            stopwatch.Start();
            action(books);
            stopwatch.Stop();
            times.Add(stopwatch.ElapsedMilliseconds);
            stopwatch.Reset();
        }

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

LINQ 的做法比前面例子中比较整型值的版本要多花费大概 5 倍的时间。这是因为字符串操作要比数值操作更加耗时。不过最有趣的则是,这一次 LINQ 的做法只比最快的做法慢一点点。两次比较的结果清楚地说明,LINQ 所带来的一些性能上额外的开销并不一定成为程序效率上的瓶颈。

不过为什么两个测试会有如此的差异呢?当我们把 where 子句中的比较条件从整型改变为字符串之后,实际上就相应地增加了每一段代码的执行时间。这段额外的时间将应用于所有的测试代码上,不过 LINQ 所带来的性能开销则始终维持在一个相对恒定的水平上。因此可以这样认为,查询中执行的操作越少,相对而言 LINQ 所带来的性能开销则越大。

这并没有什么值得惊讶的——凡事都有利弊,LINQ 也不会只带来好处。LINQ 需要一些额外的工作,例如创建对象和对垃圾收集器的更高依赖等。这些额外的工作让 LINQ 的执行效率极大地依赖于所要执行的查询。有些时候效率可能只会降低 5%,而有些时候则可能降低 500%。

结论就是,不要害怕使用 LINQ,不过使用时要多加小心。对于一些简单而又频繁执行的操作,或许传统的方法更适合一些。对于简单的过滤或搜索操作,我们可以仍使用 List<T>和数组内建的支持,例如 FindAllForEachFindConvertAllTrueForAll 等。当然,在任何 LINQ 将会造成巨大性能影响的地方,我们均可使用传统的 foreachfor 循环代替。而对于那些不是很频繁执行的查询来说,你可以放心地使用 LINQ to Objects。对于那些不是对时间非常敏感的操作而言,执行时间是 60 毫秒还是 10 毫秒并不会给程序的运行带来什么显著差异。别忘了 LINQ 能够在源代码级别为你带来多么好的可读性和可维护性!

性能和简洁:鱼和熊掌不可兼得吗

刚刚我们看到,LINQ 似乎在兼顾代码的性能和代码的简洁清晰方面给我们出了一道难题。我们再来看一个示例程序,用来或者证明,或者推翻这个理论。这次的测试将进行分组操作。下面代码中的 LINQ 查询将图书按照出版社分组,并将分组后的结果按照出版社名称进行排序。

1
2
3
4
5
var result = from book in books
                group book by book.Publisher.Name
    into publisherBooks
                orderby publisherBooks.Key
                select publisherBooks;

若是不使用 LINQ,那么用传统的方法也能实现同样的功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var result = new SortedDictionary<string, List<Book>>();
foreach (var book in books)
{
    if (!result.TryGetValue(book.Publisher.Name, out var publisherBooks))
    {
        publisherBooks = new List<Book>();
        result[book.Publisher.Name] = publisherBooks;
    }
    publisherBooks.Add(book);
}

运行 20 次的结果:

方法                  平均时间(毫秒)            最小时间(毫秒)            最大时间(毫秒)
LINQ                  61.85                       46                          124
Foreach               421.45                      391                         505

测试代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using LinqInAction.LinqBooks.Common;

static class Demo
{
    public static void Main()
    {
        var books = BooksForPerformance();

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

        var time = 20;
        var result = Test(Linq, books, time);
        Console.WriteLine($"{"LINQ",-22}{result.avg,-28}{result.min,-28}{result.max,-28}");

        result = Test(Foreach, books, time);
        Console.WriteLine($"{"Foreach",-22}{result.avg,-28}{result.min,-28}{result.max,-28}");

        Console.ReadKey();
    }

    private static List<Book> 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
            });
        }

        return result;
    }

    /// <summary>
    /// 第一种方法
    /// </summary>
    /// <returns></returns>
    static void Linq(List<Book> books)
    {
        var result = (from book in books
                      group book by book.Publisher.Name
            into publisherBooks
                      orderby publisherBooks.Key
                      select publisherBooks).ToList();
    }

    /// <summary>
    /// 第二种方法
    /// </summary>
    static void Foreach(List<Book> books)
    {
        var result = new SortedDictionary<string, List<Book>>();
        foreach (var book in books)
        {
            if (!result.TryGetValue(book.Publisher.Name, out var publisherBooks))
            {
                publisherBooks = new List<Book>();
                result[book.Publisher.Name] = publisherBooks;
            }
            publisherBooks.Add(book);
        }
    }

    /// <summary>
    /// 测试
    /// </summary>
    /// <param name="action"></param>
    /// <param name="books"></param>
    /// <param name="time"></param>
    /// <returns></returns>
    static (double avg, long max, long min) Test(Action<List<Book>> action, List<Book> books, int time)
    {
        List<long> times = new List<long>();
        Stopwatch stopwatch = new Stopwatch();

        for (int i = 0; i < time; i++)
        {
            stopwatch.Start();
            action(books);
            stopwatch.Stop();
            times.Add(stopwatch.ElapsedMilliseconds);
            stopwatch.Reset();
        }

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

毫无疑问,传统方法的代码更长也更复杂。虽然并不是太难以理解,不过若是对功能有更进一步的需求,那么可以想象这段代码将会越来越长,越来越复杂。而 LINQ 的版本则能够始终保持简单!

上述两段代码中最主要的差别在于其使用了完全不同的两种理念。LINQ 版本使用的是声明式的方法,而传统的版本则通过一系列的命令实现。在 LINQ 出现之前,C# 中的代码都是命令式的,因为语言本身就是如此。命令式的代码详细地给出了执行某些操作所需要的完整步骤。而 LINQ 的声明式方法则仅仅描述了我们所期望得到的结果,对于具体的实现过程并不在意。与详细描述实现步骤不同的是,LINQ 代码则更像是对结果的直接定义。这才是二者最核心的不同之处!

前面曾经说过,你应该已经信服于 LINQ 所带来的种种便利。那么这个新的示例程序又将要证明些什么呢?答案就是,若测试一下这两种方法的执行效率,你会看到 LINQ 版本要快于传统的代码!

当然,你可能会对产生这样的结果存有疑惑,不过我们将把调查研究的工作留给你自己。这里我们要说的是:若你希望在传统代码中得到与 LINQ 同样的执行效率,可能需要继续编写更加复杂的代码。

从内存占用以及插入时间等角度考虑,SortedDictionary 是一个比较低效的数据结构。此外,我们还在每一次循环中使用了 TryGetValue。而 LINQ 运算符则能够更有效地处理 这类场景。当然,这个非 LINQ 版本的代码也存在着性能提升的空间,不过同时也会带来更高的复杂性。

参考:

Fabrice Marguerie,Steve Eichert,Jim Wooley.LINQ in Action.Manning Publications,2008-2-14. http://linqinaction.net/