LINQ 的優勢並不是提供了什麼新功能,而是讓我們能夠用更新、更簡單、更優雅的方法來實現原有的功能。不過通常來講,這類功能所帶來的就是對性能上的影響——LINQ 也不例外。本篇文章的主要目的就是讓你瞭解 LINQ 查詢對性能的影響。我們將介紹最基本的 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
對象中逐一返迴文本行:
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
對象僅在當次迭代中存在,即不是集合中所有的對象都要同時存在於記憶體中。每一個迭代都包含了從文件中讀取一行、將其分割成字元串數組、根據分割的結果創建對象等操作。一旦操作完當前對象,程式即開始讀取下一行文件,直至處理完文件中的所有行。
可以看到,由於我們藉助了延遲執行所帶來的優勢,程式使用了更少的資源,同時記憶體的消耗也大為降低。
當心立即執行
大多數標準查詢運算符都通過迭代器實現了延遲執行。前面曾介紹過,這樣將有助於降低程式耗費的資源。不過還是有些查詢運算符破壞了這個優雅的延遲執行特性。實際上,這些查詢運算符的行為本身就需要一次性地遍歷序列中的所有元素。
通常地,那些返回數量值,而不是序列的運算符都需要與之配合的查詢立即執行,例如 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. 若該行不是註釋,則繼續處理。
- a. 若該行以
- (3)將該行分割成多個部分。
- (4)通過
Select
運算符創建一個對象。 - (5)根據
foreach
中的語句對book
對象進行操作。 - (6)回到第 1 步。
通過在 Visual Studio 中一步一步地進行調試即可清楚地看到上述每一步操作。這裡我們也建議你能夠如此調試一次,以便更清楚地瞭解 LINQ 查詢的執行過程。
若決定以不同的順((比如通過 orderby
子句或調用 Reverse
運算符)處理文件中的每一行,上述流程則會有所改變。例如我們在查詢中添加了 Reverse
運算符,代碼如下:
...
from line in reader.Lines().Reverse()
...
此時,該查詢的執行順序變成下麵這樣的。
- (1)執行
Reverse
運算符。- a. 立即調用
Lines
運算符,讀取所有行併進行反序操作。
- a. 立即調用
- (2)一次迴圈開始,獲取
Reverse
運算符返回序列中的一行。- a. 若整個文件已經被處理完畢,那麼過程將終止。
- (3)使用
Where
運算符對這一行 進行操作。- a. 若該行以
#
開始,即註釋行,那麼將重新回到第 2 步。 - b. 若該行不是註釋,則繼續處理。
- a. 若該行以
- (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://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,做決定前先看看後續的測試。
首先,這些測試結果都是基於同一個查詢。若將查詢略加修改,那麼結果又將如何呢?例如修改 where
子句中的條件,將比較整型欄位 PageCount
改為比較字元串欄位 Tit1e
:
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
測試代碼如下:
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>
和數組內建的支持,例如 FindAll
、ForEach
、Find
、ConvertAll
和 TrueForAll
等。當然,在任何 LINQ 將會造成巨大性能影響的地方,我們均可使用傳統的 foreach
或 for
迴圈代替。而對於那些不是很頻繁執行的查詢來說,你可以放心地使用 LINQ to Objects。對於那些不是對時間非常敏感的操作而言,執行時間是 60 毫秒還是 10 毫秒並不會給程式的運行帶來什麼顯著差異。別忘了 LINQ 能夠在源代碼級別為你帶來多麼好的可讀性和可維護性!
性能和簡潔:魚和熊掌不可兼得嗎
剛剛我們看到,LINQ 似乎在兼顧代碼的性能和代碼的簡潔清晰方面給我們出了一道難題。我們再來看一個示常式序,用來或者證明,或者推翻這個理論。這次的測試將進行分組操作。下麵代碼中的 LINQ 查詢將圖書按照出版社分組,並將分組後的結果按照出版社名稱進行排序。
var result = from book in books
group book by book.Publisher.Name
into publisherBooks
orderby publisherBooks.Key
select publisherBooks;
若是不使用 LINQ,那麼用傳統的方法也能實現同樣的功能:
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
測試代碼:
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 版本的代碼也存在著性能提升的空間,不過同時也會帶來更高的複雜性。
原文鏈接:https://www.vinanysoft.com/c-sharp/linq-performance-analysis/