併發程式設計-8.並行資料結構和並行Linq

F(x)_King發表於2024-03-30

PLINQ 簡介

PLINQ 是 LINQ 的一組 .NET 擴充套件,允許透過利用執行緒池並行執行部分 LINQ 查詢。 PLINQ 實現提供所有可用 LINQ 查詢操作的並行版本。

與 LINQ 查詢一樣,PLINQ 查詢提供延遲執行。 這意味著在需要列舉物件之前不會對其進行查詢。 如果您不熟悉 LINQ 的延遲執行,我們將透過一個簡單的示例來說明該概念。 考慮這兩個 LINQ 查詢:

internal void QueryCities(List<string> cities)
{
    // Query is executed with ToList call
    List<string> citiesWithS = cities.Where(s =>
                                            s.StartsWith('S')).ToList();
    // Query is not executed here
    IEnumerable<string> citiesWithT = cities.Where(s =>
                                                   s.StartsWith('T'));
    // Query is executed here when enumerating
    foreach (string city in citiesWithT)
    {
        // Do something with citiesWithT
    }
}

在示例中,由於呼叫了 ToList(),因此會立即執行填充 carsWithSLINQ 查詢。 填充 cityWithT 的第二個查詢不會立即執行。執行會推遲到需要 IEnumerable 值時為止。 直到我們在 foreach 迴圈中迭代它們之前,才需要 carsWithT 值。 同樣的原則也適用於 PLINQ 查詢。

PLINQ 在其他方面也與 LINQ 類似。 您可以在任何實現 IEnumerableIEnumerable<T> 的集合上建立 PLINQ 查詢。 您可以使用所有熟悉的 LINQ 操作,例如WhereFirstOrDefaultSelect 等。 主要區別在於,PLINQ 嘗試透過跨多個執行緒的部分或全部查詢來利用並行程式設計的功能。 在內部,PLINQ 將記憶體中的資料分割槽為多個段,並對每個段並行執行查詢。

PLINQ 和效能

在決定哪些 LINQ 查詢適合利用 PLINQ 的強大功能時,您必須考慮許多因素。 要考慮的主要因素是要執行的工作的量級或複雜性是否足以抵消執行緒的開銷。 您應該對大型資料集進行操作,並對集合中的每個專案執行昂貴的操作。 檢查字串第一個字母的 LINQ 示例不太適合 PLINQ,尤其是在源集合僅包含少量專案的情況下。

PLINQ 可能獲得的效能的另一個因素是執行查詢的系統上可用的核心數量。 PLINQ 可以利用的核心數量越多,潛在收益就越大。 PLINQ 可以將大型資料集分解為更多的工作單元,以便與許多可用的核心並行執行。

與傳統 LINQ 查詢相比,對資料進行排序和分組可能會產生更大的開銷。 PLINQ 資料是分段的,但必須在整個集合中執行分組和排序。 PLINQ 最適合資料順序不重要的查詢。

我們將在保留資料順序和使用 PLINQ 合併資料部分中討論影響查詢效能的一些其他因素。 現在,讓我們開始建立第一個 PLINQ 查詢。

建立 PLINQ 查詢

PLINQ 的大部分功能都是透過 System.Linq.ParallelEnumerable 類的成員公開的。 此類包含可用於記憶體中物件查詢的所有 LINQ 運算子的實現。 此類中有一些特定於 PLINQ 查詢的附加運算子。 要理解的兩個最重要的運算子是 AsParallelAsSequentialAsParallel 運算子指示應嘗試並行執行所有後續 LINQ 操作。 相反,AsSequential 運算子向 PLINQ 指示應按順序執行其後面的 LINQ 操作。

讓我們看一個使用這兩個 PLINQ 運算子的示例。 我們的查詢將在 List<Person> 上執行,定義如下:

internal class Person
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Age { get; set; }
}

讓我們考慮一下我們正在處理數千甚至數百萬人的資料集。 我們希望利用 PLINQ 從資料中僅查詢 18 歲或以上的成年人,然後按姓氏對他們進行分組。 我們只想並行執行查詢的Where 子句。 GroupBy 操作將按順序執行。 這個方法將會做到這一點:

internal void QueryAndGroupPeople(List<Person> people)
{
    var results = people.AsParallel().Where(p => p.Age > 17)
        .AsSequential().GroupBy(p => p.LastName);
    foreach (var group in results)
    {
        Console.WriteLine($"Last name {group.Key} has {group.Count()} people.");
    }
  // Sample output:
  // Last name Jones has 4220 people.
  // Last name Xu has 3434 people.
  // Last name Patel has 4798 people.
  // Last name Smith has 3051 people.
  // Last name Sanchez has 3811 people.
  // ...
}

GroupBy LINQ 方法將返回 IEnumerable<IGrouping<string, Person>>,其中每個 IGrouping<string, Person> 例項包含具有相同 LastName 的所有人員。 此 GroupBy 操作並行執行還是順序執行更快取決於資料的構成。 您應該始終測試您的應用程式,以確定引入並行性是否可以提高處理生產資料時的效能。 我們將在第 10 章介紹對程式碼進行效能測試的方法。

查詢語法與方法語法

LINQ 查詢可以使用方法語法或查詢語法進行編碼。 方法語法是將多個方法串聯在一起以構建查詢的地方。 這就是我們在本節中一直在做的事情。 查詢語法略有不同,它類似於 T-SQL 查詢語法。 讓我們檢查一下以兩種方式編寫的同一個 PLINQ 查詢。

下面是一個簡單的 PLINQ 查詢,用於從使用方法語法編寫的人員列表中僅返回成人:

var peopleQuery1 = people.AsParallel().Where(p => p.Age > 17);

以下是使用查詢語法編寫的完全相同的 PLINQ 查詢:

var peopleQuery2 = from person in people.AsParallel()
						where person.Age > 17
						select person;

您應該使用您喜歡的語法。 在本章的其餘部分中,我們將使用方法語法作為示例。

將 LINQ 查詢轉換為 PLINQ

在本節中,我們將介紹一些其他 PLINQ 運算子,並向您展示如何利用它們將現有 LINQ 查詢轉換為 PLINQ 查詢。 您現有的查詢可能需要保留資料的順序。 也許您現有的程式碼根本不使用 LINQ。 可能有機會將 foreach 迴圈中的某些邏輯轉換為 PLINQ 操作。

將 LINQ 查詢轉換為 PLINQ 查詢的方法是將 AsParallel() 語句插入到查詢中,就像我們在上一節中所做的那樣。 AsParallel() 後面的所有內容都將並行執行,直到遇到 AsSequential() 語句。

如果您的查詢要求保留物件的原始順序,您可以包含 AsOrdered() 語句:

var results = people.AsParallel().AsOrdered()
    .Where(p => p.LastName.StartsWith("H"));

但是,這不會像不保留資料序列的查詢那樣高效。 要顯式告訴 PLINQ 不保留資料順序,請使用 AsUnordered() 語句:

var results = people.AsParallel().AsUnordered()
    .Where(p => p.LastName.StartsWith("H"));

如果資料的順序不重要,則查詢的無序版本將執行得更好;您永遠不應該將 AsOrdered() 與 PLINQ 一起使用。

讓我們考慮另一個例子。 我們將從一個方法開始,該方法使用 foreach 迴圈迭代人員列表,併為每個 18 歲或以上的人員呼叫名為 ProcessVoterActions 的方法。 我們假設此方法是處理器密集型的,並且還使用一些 I/O 將選民資訊儲存在資料庫中。 這是起始程式碼:

internal void ProcessAdultsWhoVote(List<Person> people)
{
    foreach (var person in people)
    {
        if (person.Age < 18) continue;
        ProcessVoterActions(person);
    }
}
private void ProcessVoterActions(Person adult)
{
    // Add adult to a voter database and process their
    data.
}

這根本不會利用並行處理。 我們可以透過使用 LINQ 過濾掉 18 歲以下的兒童來改進這一點,然後使用 Parallel.ForEach 迴圈呼叫 ProcessVoterActions

internal void ProcessAdultsWhoVoteInParallel(List<Person>
                                             people)
{
    var adults = people.Where(p => p.Age > 17);
    Parallel.ForEach(adults, ProcessVoterActions);
}

如果 ProcessVoterActions 需要一些時間為每個人執行,這肯定會提高效能。 然而,使用 PLINQ,我們可以進一步提高效能:

internal void ProcessAdultsWhoVoteWithPlinq(List<Person>
                                            people)
{
    var adults = people.AsParallel().Where(p => p.Age > 17);
    adults.ForAll(ProcessVoterActions);
}

現在,Where 查詢將並行執行。 如果我們期望 people 集合中有數千或數百萬個物件,這肯定會對效能有所幫助。 ForAll 擴充套件方法是另一個並行執行的 PLINQ 操作。 它旨在用於對查詢結果中的每個物件並行執行操作。

ForAll 的效能也將優於前面示例中的 Parallel.ForEach 操作。 一個區別是 PLINQ 的延遲執行。 在迭代 IEnumerable 結果之前,不會執行對 ProcessVoterActions 的這些呼叫。 另一個優點與在完成對資料的 PLINQ 查詢後使用 IEnumerable 執行標準 foreach 迴圈的優點相同。 資料必須先從多個執行緒合併回來,然後才能透過 foreachParallel.ForEach 進行列舉。 透過 ForAll 操作,資料可以保持 PLINQ 分段並在最後合併一次。 下圖說明了 Parallel.ForEachForAll 之間的區別:

圖 8.1 – PLINQ、資料分段和 ForAll 的優點

image

使用 PLINQ 查詢處理異常

在 .NET 專案中實現良好的異常處理非常重要。 這是軟體開發的基本實踐之一。 一般來說,在進行並行程式設計時,異常處理可能會更加複雜。 PLINQ 也是如此。 當 PLINQ 查詢內的並行操作中的任何異常未處理時,查詢將引發 AggregateException 型別的異常。 因此,至少,所有 PLINQ 查詢都應該在捕獲 AggregateException 異常型別的 try/catch 塊中執行。

讓我們以帶有 ProcessVoterActions 的 PLINQ ForAll 示例為例,並新增一些異常處理:

  1. 我們將在 .NET 控制檯應用程式中執行此示例,因此在 Visual Studio 中建立一個新專案並新增一個名為 Person 的類:
internal class Person
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Age { get; set; }
}
  1. 接下來,新增一個名為 PlinqExceptionsExample 的新類。

  2. 現在向 PlinqExceptionsExample 新增一個名為 ProcessVoterActions 的私有方法。 我們將為任何超過 120 歲的人丟擲 ArgumentException

private void ProcessVoterActions(Person adult)
{
    if (adult.Age > 120)
    {
        throw new ArgumentException("This person is  too old!", nameof(adult));
    }
    // Add adult to a voter database and process their
    data.
}
  1. 接下來,新增 ProcessAdultsWhoVoteWithPlinq 方法:
internal void ProcessAdultsWhoVoteWithPlinq
    (List<Person> people)
{
    try
    {
        var adults = people.AsParallel().Where(p =>  p.Age > 17);
        adults.ForAll(ProcessVoterActions);
    }
    catch (AggregateException ae)
    {
        foreach (var ex in ae.InnerExceptions)
        {
            Console.WriteLine($"Exception encountered while processing voters. Message:  {ex.Message}");
        }
    }
}

該方法的邏輯保持不變。 它使用 PLINQ Where 子句過濾掉子級,並呼叫 ProcessVoterActions 作為 ForAll 的委託。

  1. 最後,開啟 Program.cs 並新增一些程式碼以在名為 GetPeople 的行內函數中建立 List<Person> 的例項。 它可以包含任意數量的人,但至少其中一個人的年齡需要大於 120 歲。呼叫 ProcessAdultsWhoVoteWithPlinq,傳遞來自 GetPeople 的資料:
using LINQandPLINQsnippets;
var exceptionExample = new PlinqExceptionsExample();
exceptionExample.ProcessAdultsWhoVoteWithPlinq(GetPeople());
Console.ReadLine();
static List<Person> GetPeople()
{
    return new List<Person>
    {
        new Person { FirstName = "Bob", LastName =
            "Jones", Age = 23 },
        new Person { FirstName = "Sally", LastName =
            "Shen", Age = 2 },
        new Person { FirstName = "Joe", LastName =
            "Smith", Age = 45 },
        new Person { FirstName = "Lisa", LastName =
            "Samson", Age = 98 },
        new Person { FirstName = "Norman", LastName =
            "Patterson", Age = 121 },
        new Person { FirstName = "Steve", LastName =
            "Gates", Age = 40 },
        new Person { FirstName = "Richard", LastName =
            "Ng", Age = 18 }
    };
}
  1. 現在,執行程式並觀察控制檯輸出。 如果 Visual Studio 因異常而中斷,只需單擊“繼續”:

圖 8.2 – 在控制檯中接收到異常

image

從 PLINQ 查詢外部處理異常的問題是整個查詢會停止。 它無法執行完成。 如果出現不應停止整個過程的異常,則應在查詢內的程式碼中處理它,並繼續處理剩餘的專案。

如果您在 ProcessVoterActions 內處理異常,您就有機會優雅地處理它們並繼續。

使用 PLINQ 保留資料順序併合並資料

在為應用程式微調 PLINQ 查詢時,有一些擴充套件方法會影響您可以利用的資料排序。 可能需要保留物品的原始順序。 我們在本章中接觸了 AsOrdered 方法,我們將在本節中對其進行實驗。 當 PLINQ 操作完成並且專案作為最終列舉的一部分返回時,資料將從為在多個執行緒上操作而建立的段中合併。 可以透過使用 WithMergeOptions 擴充套件方法設定 ParallelMergeOptions 來控制合併行為。 我們將討論所提供的三個可用合併選項的行為。

PLINQ 資料順序示例

在本節中,我們將建立五個方法,每個方法都接受相同的資料集並對輸入資料執行相同的過濾。 但是,每個 PLINQ 查詢中的排序將以不同的方式處理。 我們將使用與上一節相同的 Person 類。 因此,您可以使用同一專案,也可以建立一個新的 .NET 控制檯應用程式專案並新增上一示例中的 People 類。 讓我們開始吧:

  1. 首先,開啟 Person 類並新增一個名為 IsImportant 的新 bool 屬性。 我們將使用它新增第二個資料點以在 PLINQ 查詢中進行過濾:
internal class Person
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Age { get; set; }
    public bool IsImportant { get; set; }
}
  1. 接下來,將一個新類新增到名為 OrderSamples 的專案中。

  2. 現在是時候開始新增查詢了。 在第一個查詢中,我們沒有指定 AsOrderedAsUnordered。 預設情況下,PLINQ 不應嘗試保留資料的原始順序。 在每個查詢中,我們返回年齡小於 18IsImportant 設定為 true 的每個 Person 物件:

internal IEnumerable<Person>
    GetImportantChildrenNoOrder(List<Person> people)
{
    return people.AsParallel()
        .Where(p => p.IsImportant && p.Age < 18);
}
  1. 在第二個示例中,我們在 AsParallel 之後顯式地將 IsUnordered 新增到查詢中。行為應該與第一個查詢相同,PLINQ 不關心專案的原始順序:
internal IEnumerable<Person>
    GetImportantChildrenUnordered(List<Person> people)
{
    return people.AsParallel().AsUnordered()
        .Where(p => p.IsImportant && p.Age < 18);
}
  1. 第三個示例將過濾器分解為兩個單獨的Where 子句; IsSequential 新增在第一個Where 子句之後。 您認為這將如何影響專案順序? 當我們執行程式時我們會發現:
internal IEnumerable<Person> GetImportantChildrenUnknownOrder(List<Person>  people)
{
    return people.AsParallel().Where(p => p.IsImportant)
        .AsSequential().Where(p => p.Age < 18);
}
  1. 在第四個示例中,我們使用 AsParallel().AsOrdered() 向 PLINQ 發出訊號,表明我們希望保留專案的原始順序:
internal IEnumerable<Person> GetImportantChildrenPreserveOrder(List<Person>   people)
{
    return people.AsParallel().AsOrdered()
        .Where(p => p.IsImportant && p.Age < 18);
}
  1. 在第五個也是最後一個示例中,我們在 AsOrdered 之後新增一個 Reverse 方法。 這應該以相反的方式保留專案的原始順序:
internal IEnumerable<Person> GetImportantChildrenReverseOrder(List<Person> people)
{
    return people.AsParallel().AsOrdered().Reverse()
        .Where(p => p.IsImportant && p.Age < 18);
}
  1. 接下來,開啟 Program.cs 並新增兩個本地函式。 我們將建立一個 Person 物件列表以傳遞給每個方法。 另一個將迭代 List<Person> 以將每個 FirstName 輸出到控制檯:
static List<Person> GetYoungPeople()
{
    return new List<Person>
    {
        new Person { FirstName = "Bob", LastName =
            "Jones", Age = 23 },
        new Person { FirstName = "Sally", LastName =
            "Shen", Age = 2, IsImportant = true },
        new Person { FirstName = "Joe", LastName =
            "Smith", Age = 5, IsImportant = true },
        new Person { FirstName = "Lisa", LastName =
            "Samson", Age = 9, IsImportant = true },
        new Person { FirstName = "Norman", LastName =
            "Patterson", Age = 17 },
        new Person { FirstName = "Steve", LastName =
            "Gates", Age = 20 },
        new Person { FirstName = "Richard", LastName =
            "Ng", Age = 16, IsImportant = true }
    };
}
static void OutputListToConsole(List<Person> list)
{
    foreach (var item in list)
    {
        Console.WriteLine(item.FirstName);
    }
}
  1. 最後,我們將新增呼叫每個方法的程式碼。 時間戳(包括毫秒)在每次方法呼叫之前和結束時都會輸出到控制檯。 您可以多次執行應用程式來檢查每個方法呼叫的效能。 嘗試在具有更多或更少核心和不同大小資料集的 PC 上執行它,看看這對輸出有何影響:
using LINQandPLINQsnippets;
var timeFmt = "hh:mm:ss.fff tt";
var orderExample = new OrderSamples();
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. AsParallel children:");
OutputListToConsole(orderExample.GetImportantChildrenNoOrder(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. AsUnordered children:");
OutputListToConsole(orderExample.GetImportantChildrenUnordered(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. Sequential after Wherechildren:");
OutputListToConsole(orderExample.GetImportantChildrenUnknownOrder(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. AsOrdered children:");
OutputListToConsole(orderExample.GetImportantChildrenPreserveOrder(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. Reverse order children:");
OutputListToConsole(orderExample.GetImportantChildrenReverseOrder(GetYoungPeople()).ToList());
Console.WriteLine($"Finish time: {DateTime.Now.ToString(timeFmt)}");
Console.ReadLine();
  1. 現在,執行程式並檢查輸出:

圖 8.3 – 比較五個 PLINQ 查詢中的專案順序

image

您可以從輸出中看到,只有在我們指定了 AsOrdered()AsOrdered().Reverse() 的最後兩個示例中,專案的順序才是可預測的。 在如此小的資料集上很難衡量不同 PLINQ 操作的影響。 如果您執行多次,您可能會在時間上看到不同的結果。 嘗試自行新增更大的資料集來試驗效能。

在 PLINQ 查詢中使用 WithMergeOptions

瞭解每個選項的行為很重要。 讓我們回顧一下 ParallelMergeOptions 列舉的每個成員。

ParallelMergeOptions.NotBuffered

NotBuffered 選項視為流資料。 每個專案在處理完成後立即從查詢中返回。 有一些 PLINQ 操作不支援此選項並將忽略它。 例如,在對合並資料完成排序之前,OrderByOrderByDescending 操作無法返回專案。 這些始終是FullyBuffered。但是,使用AsOrdered 的查詢可以使用此選項。 如果您的應用程式需要以流式傳輸方式使用專案,請使用此選項。

ParallelMergeOptions.AutoBuffered

AutoBuffered 選項返回收集的專案集。 專案集的大小以及返回以清除緩衝區的頻率是不可配置的或您的程式碼無法得知的。 如果您想以這種方式提供資料,此選項可能適合您的需求。 再次強調,OrderByOrderByDescending 操作將不接受此選項。 這是大多數 PLINQ 操作的預設設定,並且在大多數情況下總體上是最快的。 AutoBuffered 選項使 PLINQ 能夠根據當前系統條件靈活地根據需要緩衝專案。

ParallelMergeOptions.FullyBuffered

在查詢處理並緩衝所有結果之前,FullyBuffered 選項不會提供任何結果。 該選項將花費最長的時間來使第一個專案可用,但很多時候,它是提供整個資料集最快的。

ParallelMergeOptions.Default

還有 ParallelMergeOptions.Default 值,其作用與根本不呼叫 WithMergeOptions 相同。 您應該根據資料的使用方式來選擇合併選項。 如果沒有嚴格的要求,通常最好不要設定合併選項。

WithMergeOptions 的實際應用

讓我們建立對每個合併選項使用相同的 Person 查詢並且根本沒有設定合併選項的示例:

  1. 首先將 MergeSamples 類新增到您之前建立的控制檯應用程式專案中。 首先,新增以下三個方法來測試合併型別:
internal IEnumerable<Person> GetImportantChildrenNoMergeSpecified(List<Person>  people)
{
    return people.AsParallel()
        .Where(p => p.IsImportant && p.Age < 18)
        .Take(3);
}
internal IEnumerable<Person> GetImportantChildren  DefaultMerge(List<Person> people)
{
    return people.AsParallel()
		.WithMergeOptions(ParallelMergeOptions.Default)
        .Where(p => p.IsImportant && p.Age < 18).Take(3);
}
internal IEnumerable<Person> GetImportant ChildrenAutoBuffered(List<Person> people)
{
    return people.AsParallel().WithMergeOptions
        (ParallelMergeOptions.AutoBuffered)
		.Where(p => p.IsImportant && p.Age < 18).Take(3);
}
  1. 接下來,將以下兩個方法新增到 MergeSamples 類中:
internal IEnumerable<Person> GetImportant
    ChildrenNotBuffered(List<Person> people)
{
    return people.AsParallel().WithMergeOptions
        (ParallelMergeOptions.NotBuffered)
        .Where(p => p.IsImportant && p.Age <
               18).Take(3);
}
internal IEnumerable<Person> GetImportantChildren
    FullyBuffered(List<Person> people)
{
    return people.AsParallel().WithMergeOptions
        (ParallelMergeOptions.FullyBuffered)
		.Where(p =>p.IsImportant && p.Age < 18).Take(3);
}

最後兩個步驟中的每個方法都會執行一個 PLINQ 查詢,該查詢會篩選 IsImportant 等於 trueAge 小於 18。然後執行 Take(3) 操作以僅返回查詢中的前三項。

  1. Program.cs 中新增程式碼以呼叫每個方法並輸出每次呼叫之前的時間戳以及最後的最終時間戳。 這與我們在上一節中呼叫測試排序的方法時使用的過程相同:
using LINQandPLINQsnippets;

var timeFmt = "hh:mm:ss.fff tt";
var mergeExample = new MergeSamples();

Console.WriteLine($"Start time: {DateTime.Now.ToString (timeFmt)}. NoMerge children:");
OutputListToConsole(mergeExample.GetImportantChildrenNoMergeSpecified(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString(timeFmt)}. DefaultMerge  children:");
OutputListToConsole(mergeExample.GetImportantChildrenDefaultMerge(GetYoungPeople()).ToList());
Console.WriteLine($"Start time: {DateTime.Now.ToString (timeFmt)}. AutoBuffered children:");
OutputListToConsole(mergeExample.GetImportantChildrenAutoBuffered(GetYoungPeople()).ToList());
Console.WriteLine($"Start time:  {DateTime.Now.ToString(timeFmt)}. NotBuffered    children:");
OutputListToConsole(mergeExample.GetImportantChildrenNotBuffered(GetYoungPeople()).ToList());
Console.WriteLine($"Start time:{DateTime.Now.ToString(timeFmt)}.FullyBuffered children:");
OutputListToConsole(mergeExample.GetImportantChildrenFullyBuffered(GetYoungPeople()).ToList());
Console.WriteLine($"Finish time: { DateTime.Now.ToString(timeFmt)}");
Console.ReadLine();
  1. 現在,執行程式並檢查輸出:

圖 8.4 – 檢視 PLINQ 合併選項方法的輸出

image

第一個未指定合併選項的選項執行時間最長,但通常,第一次執行 PLINQ 查詢時,它會比後續執行慢。 剩下的查詢都非常快。 您應該在自己資料庫中的一些大型資料集上測試這些查詢,並檢視不同 PLINQ 運算子和不同合併選項的計時有何不同。 您甚至可以在每個專案的輸出之間進行計時,以瞭解 NotBufferedFullBuffered 的第一個專案返回的速度有多快。

.NET 中並行程式設計的資料結構

在 .NET 中進行並行程式設計以及使用 PLINQ 時,您應該利用 .NET 提供的資料結構、型別和原語。 在本節中,我們將討論併發集合和同步原語。

併發集合

在進行並行程式設計時,併發集合非常有用。 我們將在第 9 章中詳細介紹它們,但讓我們快速討論一下在使用 PLINQ 查詢時如何利用它們。

如果您只是使用 PLINQ 選擇和排序資料,則無需承擔 System.Collections.Concurrent 名稱空間中的集合所新增的開銷。 但是,如果您使用 ForAll 呼叫修改源資料中的專案的方法,則應使用這些當前集合之一,例如 BlockingCollection<T>ConcurrentBag<T>ConcurrentDictionary<TKey, TValue>。 它們還可以防止對集合進行任何同時的新增或刪除操作。

同步原語

如果您無法將併發集合引入現有程式碼庫,提供併發性和效能的另一種選擇是同步原語。 我們在第 1 章中介紹了其中的許多型別。System.Threading 名稱空間中的這些型別(包括 BarrierCountdownEventSemaphoreSlimSpinLockSpinWait)提供了執行緒安全性和效能的適當平衡。 其他鎖定機制(例如鎖和互斥鎖)的實施成本可能更高,從而對效能產生更大的影響。

如果我們想要保護使用 ForAllSpinLock 的 PLINQ 查詢之一,我們可以簡單地將方法包裝在 try/finally 塊中,並使用 SpinLock 上的 EnterExit 呼叫。以這個例子為例,我們正在檢查一個人的位置 年齡大於120。假設程式碼也修改了年齡:

private SpinLock _spinLock = new SpinLock();
internal void ProcessAdultsWhoVoteWithPlinq2(List<Person>  people)
{
    var adults = people.AsParallel().Where(p => p.Age > 17);
    adults.ForAll(ProcessVoterActions2);
}
private void ProcessVoterActions2(Person adult)
{
    var hasLock = false;
    if (adult.Age > 120)
    {
        try
        {
            _spinLock.Enter(hasLock);
            adult.Age = 120;
        }
        finally
        {
            if (hasLock) _spinLock.Exit();
        }
    }
}

相關文章